diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py
index 66f7de13f..fa9dadcd4 100644
--- a/src/api/tests/test_available.py
+++ b/src/api/tests/test_available.py
@@ -69,8 +69,8 @@ class AvailableViewTest(MockEppLib):
self.assertTrue(check_domain_available("igorville.gov"))
# input is lowercased so GSA.GOV should also not be available
self.assertFalse(check_domain_available("GSA.gov"))
- # input is lowercased so IGORVILLE.GOV should also not be available
- self.assertFalse(check_domain_available("IGORVILLE.gov"))
+ # input is lowercased so IGORVILLE.GOV should also be available
+ self.assertTrue(check_domain_available("IGORVILLE.gov"))
def test_domain_available_dotgov(self):
"""Domain searches work without trailing .gov"""
diff --git a/src/api/views.py b/src/api/views.py
index 700a8b1d5..068844919 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -32,7 +32,9 @@ DOMAIN_API_MESSAGES = {
"Read more about choosing your .gov domain.".format(public_site_url("domains/choosing"))
),
"invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).",
- "success": "That domain is available!",
+ "success": "That domain is available! We’ll try to give you the domain you want, \
+ but it's not guaranteed. After you complete this form, we’ll \
+ evaluate whether your request meets our requirements.",
"error": GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
}
diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py
index b2b1acd8e..91bfddc66 100644
--- a/src/djangooidc/oidc.py
+++ b/src/djangooidc/oidc.py
@@ -100,7 +100,9 @@ class Client(oic.Client):
"state": session["state"],
"nonce": session["nonce"],
"redirect_uri": self.registration_response["redirect_uris"][0],
- "acr_values": self.behaviour.get("acr_value"),
+ # acr_value may be passed in session if overriding, as in the case
+ # of step up auth, otherwise get from settings.py
+ "acr_values": session.get("acr_value") or self.behaviour.get("acr_value"),
}
if extra_args is not None:
@@ -162,7 +164,6 @@ class Client(oic.Client):
logger.error(err)
logger.error("Unable to parse response for %s" % state)
raise o_e.AuthenticationFailed(locator=state)
-
# ErrorResponse is not raised, it is passed back...
if isinstance(authn_response, ErrorResponse):
error = authn_response.get("error", "")
@@ -207,7 +208,6 @@ class Client(oic.Client):
logger.error(err)
logger.error("Unable to request user info for %s" % state)
raise o_e.AuthenticationFailed(locator=state)
-
# ErrorResponse is not raised, it is passed back...
if isinstance(info_response, ErrorResponse):
logger.error("Unable to get user info (%s) for %s" % (info_response.get("error", ""), state))
@@ -272,6 +272,11 @@ class Client(oic.Client):
super(Client, self).store_response(resp, info)
+ def get_step_up_acr_value(self):
+ """returns the step_up_acr_value from settings
+ this helper function is called from djangooidc views"""
+ return self.behaviour.get("step_up_acr_value")
+
def __repr__(self):
return "Client {} {} {}".format(
self.client_id,
diff --git a/src/djangooidc/tests/test_oidc.py b/src/djangooidc/tests/test_oidc.py
new file mode 100644
index 000000000..21249aa90
--- /dev/null
+++ b/src/djangooidc/tests/test_oidc.py
@@ -0,0 +1,30 @@
+import logging
+
+from django.test import TestCase
+
+from django.conf import settings
+
+from djangooidc.oidc import Client
+
+logger = logging.getLogger(__name__)
+
+
+class OidcTest(TestCase):
+ def test_oidc_create_authn_request_with_acr_value(self):
+ """Test that create_authn_request returns a redirect with an acr_value
+ when an acr_value is passed through session.
+
+ This test is only valid locally. On local, client can be initialized.
+ Client initialization does not work in pipeline, so test is useless in
+ pipeline. However, it will not fail in pipeline."""
+ try:
+ # Initialize provider using pyOICD
+ OP = getattr(settings, "OIDC_ACTIVE_PROVIDER")
+ CLIENT = Client(OP)
+ session = {"acr_value": "some_acr_value_maybe_ial2"}
+ response = CLIENT.create_authn_request(session)
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("some_acr_value_maybe_ial2", response.url)
+ except Exception as err:
+ logger.warning(err)
+ logger.warning("Unable to configure OpenID Connect provider in pipeline. Cannot execute this test.")
diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py
index 5ff36a77c..da12f4fd5 100644
--- a/src/djangooidc/tests/test_views.py
+++ b/src/djangooidc/tests/test_views.py
@@ -1,8 +1,9 @@
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
from django.http import HttpResponse
-from django.test import Client, TestCase
+from django.test import Client, TestCase, RequestFactory
from django.urls import reverse
+from ..views import login_callback
from .common import less_console_noise
@@ -11,6 +12,7 @@ from .common import less_console_noise
class ViewsTest(TestCase):
def setUp(self):
self.client = Client()
+ self.factory = RequestFactory()
def say_hi(*args):
return HttpResponse("Hi")
@@ -59,19 +61,83 @@ class ViewsTest(TestCase):
# mock
mock_client.callback.side_effect = self.user_info
# test
- with less_console_noise():
+ with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise():
response = self.client.get(reverse("openid_login_callback"))
# assert
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("logout"))
+ def test_login_callback_no_step_up_auth(self, mock_client):
+ """Walk through login_callback when requires_step_up_auth returns False
+ and assert that we have a redirect to /"""
+ # setup
+ session = self.client.session
+ session.save()
+ # mock
+ mock_client.callback.side_effect = self.user_info
+ # test
+ with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise():
+ response = self.client.get(reverse("openid_login_callback"))
+ # assert
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/")
+
+ def test_requires_step_up_auth(self, mock_client):
+ """Invoke login_callback passing it a request when requires_step_up_auth returns True
+ and assert that session is updated and create_authn_request (mock) is called."""
+ # Configure the mock to return an expected value for get_step_up_acr_value
+ mock_client.return_value.get_step_up_acr_value.return_value = "step_up_acr_value"
+
+ # Create a mock request
+ request = self.factory.get("/some-url")
+ request.session = {"acr_value": ""}
+
+ # Ensure that the CLIENT instance used in login_callback is the mock
+ # patch requires_step_up_auth to return True
+ with patch("djangooidc.views.requires_step_up_auth", return_value=True), patch(
+ "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock()
+ ) as mock_create_authn_request:
+ login_callback(request)
+
+ # create_authn_request only gets called when requires_step_up_auth is True
+ # and it changes this acr_value in request.session
+
+ # Assert that acr_value is no longer empty string
+ self.assertNotEqual(request.session["acr_value"], "")
+ # And create_authn_request was called again
+ mock_create_authn_request.assert_called_once()
+
+ def test_does_not_requires_step_up_auth(self, mock_client):
+ """Invoke login_callback passing it a request when requires_step_up_auth returns False
+ and assert that session is not updated and create_authn_request (mock) is not called.
+
+ Possibly redundant with test_login_callback_requires_step_up_auth"""
+ # Create a mock request
+ request = self.factory.get("/some-url")
+ request.session = {"acr_value": ""}
+
+ # Ensure that the CLIENT instance used in login_callback is the mock
+ # patch requires_step_up_auth to return False
+ with patch("djangooidc.views.requires_step_up_auth", return_value=False), patch(
+ "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock()
+ ) as mock_create_authn_request:
+ login_callback(request)
+
+ # create_authn_request only gets called when requires_step_up_auth is True
+ # and it changes this acr_value in request.session
+
+ # Assert that acr_value is NOT updated by testing that it is still an empty string
+ self.assertEqual(request.session["acr_value"], "")
+ # Assert create_authn_request was not called
+ mock_create_authn_request.assert_not_called()
+
@patch("djangooidc.views.authenticate")
def test_login_callback_raises(self, mock_auth, mock_client):
# mock
mock_client.callback.side_effect = self.user_info
mock_auth.return_value = None
# test
- with less_console_noise():
+ with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise():
response = self.client.get(reverse("openid_login_callback"))
# assert
self.assertEqual(response.status_code, 401)
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
index ea893daf2..b5905df48 100644
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -11,7 +11,7 @@ from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client
from djangooidc import exceptions as o_e
-
+from registrar.models import User
logger = logging.getLogger(__name__)
@@ -68,6 +68,12 @@ def login_callback(request):
try:
query = parse_qs(request.GET.urlencode())
userinfo = CLIENT.callback(query, request.session)
+ # test for need for identity verification and if it is satisfied
+ # if not satisfied, redirect user to login with stepped up acr_value
+ if requires_step_up_auth(userinfo):
+ # add acr_value to request.session
+ request.session["acr_value"] = CLIENT.get_step_up_acr_value()
+ return CLIENT.create_authn_request(request.session)
user = authenticate(request=request, **userinfo)
if user:
login(request, user)
@@ -79,10 +85,27 @@ def login_callback(request):
return error_page(request, err)
+def requires_step_up_auth(userinfo):
+ """if User.needs_identity_verification and step_up_acr_value not in
+ ial returned from callback, return True"""
+ step_up_acr_value = CLIENT.get_step_up_acr_value()
+ acr_value = userinfo.get("ial", "")
+ uuid = userinfo.get("sub", "")
+ email = userinfo.get("email", "")
+ if acr_value != step_up_acr_value:
+ # The acr of this attempt is not at the highest level
+ # so check if the user needs the higher level
+ return User.needs_identity_verification(email, uuid)
+ else:
+ # This attempt already came back at the highest level
+ # so does not require step up
+ return False
+
+
def logout(request, next_page=None):
"""Redirect the user to the authentication provider (OP) logout page."""
try:
- username = request.user.username
+ user = request.user
request_args = {
"client_id": CLIENT.client_id,
"state": request.session["state"],
@@ -94,7 +117,6 @@ def logout(request, next_page=None):
request_args.update(
{"post_logout_redirect_uri": CLIENT.registration_response["post_logout_redirect_uris"][0]}
)
-
url = CLIENT.provider_info["end_session_endpoint"]
url += "?" + urlencode(request_args)
return HttpResponseRedirect(url)
@@ -104,7 +126,7 @@ def logout(request, next_page=None):
# Always remove Django session stuff - even if not logged out from OP.
# Don't wait for the callback as it may never come.
auth_logout(request)
- logger.info("Successfully logged out user %s" % username)
+ logger.info("Successfully logged out user %s" % user)
next_page = getattr(settings, "LOGOUT_REDIRECT_URL", None)
if next_page:
request.session["next"] = next_page
diff --git a/src/epplibwrapper/client.py b/src/epplibwrapper/client.py
index cfb41f4ea..9ed437aef 100644
--- a/src/epplibwrapper/client.py
+++ b/src/epplibwrapper/client.py
@@ -17,7 +17,7 @@ except ImportError:
from django.conf import settings
from .cert import Cert, Key
-from .errors import LoginError, RegistryError
+from .errors import ErrorCode, LoginError, RegistryError
from .socket import Socket
from .utility.pool import EPPConnectionPool
@@ -115,7 +115,7 @@ class EPPLibWrapper:
except TransportError as err:
message = f"{cmd_type} failed to execute due to a connection error."
logger.error(f"{message} Error: {err}", exc_info=True)
- raise RegistryError(message) from err
+ raise RegistryError(message, code=ErrorCode.TRANSPORT_ERROR) from err
except LoginError as err:
# For linter due to it not liking this line length
text = "failed to execute due to a registry login error."
@@ -163,7 +163,8 @@ class EPPLibWrapper:
try:
return self._send(command)
except RegistryError as err:
- if err.should_retry() and counter < 3:
+ if counter < 3 and (err.should_retry() or err.is_transport_error()):
+ logger.info(f"Retrying transport error. Attempt #{counter+1} of 3.")
counter += 1
sleep((counter * 50) / 1000) # sleep 50 ms to 150 ms
else: # don't try again
diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py
index d34ed5e91..2b7bdd255 100644
--- a/src/epplibwrapper/errors.py
+++ b/src/epplibwrapper/errors.py
@@ -4,13 +4,15 @@ from enum import IntEnum
class ErrorCode(IntEnum):
"""
Overview of registry response codes from RFC 5730. See RFC 5730 for full text.
-
+ - 0 System connection error
- 1000 - 1500 Success
- 2000 - 2308 Registrar did something silly
- 2400 - 2500 Registry did something silly
- 2501 - 2502 Something malicious or abusive may have occurred
"""
+ TRANSPORT_ERROR = 0
+
COMMAND_COMPLETED_SUCCESSFULLY = 1000
COMMAND_COMPLETED_SUCCESSFULLY_ACTION_PENDING = 1001
COMMAND_COMPLETED_SUCCESSFULLY_NO_MESSAGES = 1300
@@ -67,6 +69,9 @@ class RegistryError(Exception):
def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED
+ def is_transport_error(self):
+ return self.code == ErrorCode.TRANSPORT_ERROR
+
# connection errors have error code of None and [Errno 99] in the err message
def is_connection_error(self):
return self.code is None
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 429bd762f..518c67869 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -527,14 +527,14 @@ class DomainApplicationAdminForm(forms.ModelForm):
current_state = application.status
# first option in status transitions is current state
- available_transitions = [(current_state, current_state)]
+ available_transitions = [(current_state, application.get_status_display())]
transitions = get_available_FIELD_transitions(
application, models.DomainApplication._meta.get_field("status")
)
for transition in transitions:
- available_transitions.append((transition.target, transition.target))
+ available_transitions.append((transition.target, transition.target.label))
# only set the available transitions if the user is not restricted
# from editing the domain application; otherwise, the form will be
@@ -650,10 +650,10 @@ class DomainApplicationAdmin(ListHeaderAdmin):
if (
obj
- and original_obj.status == models.DomainApplication.APPROVED
+ and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED
and (
- obj.status == models.DomainApplication.REJECTED
- or obj.status == models.DomainApplication.INELIGIBLE
+ obj.status == models.DomainApplication.ApplicationStatus.REJECTED
+ or obj.status == models.DomainApplication.ApplicationStatus.INELIGIBLE
)
and not obj.domain_is_not_active()
):
@@ -675,14 +675,14 @@ class DomainApplicationAdmin(ListHeaderAdmin):
else:
if obj.status != original_obj.status:
status_method_mapping = {
- models.DomainApplication.STARTED: None,
- models.DomainApplication.SUBMITTED: obj.submit,
- models.DomainApplication.IN_REVIEW: obj.in_review,
- models.DomainApplication.ACTION_NEEDED: obj.action_needed,
- models.DomainApplication.APPROVED: obj.approve,
- models.DomainApplication.WITHDRAWN: obj.withdraw,
- models.DomainApplication.REJECTED: obj.reject,
- models.DomainApplication.INELIGIBLE: (obj.reject_with_prejudice),
+ models.DomainApplication.ApplicationStatus.STARTED: None,
+ models.DomainApplication.ApplicationStatus.SUBMITTED: obj.submit,
+ models.DomainApplication.ApplicationStatus.IN_REVIEW: obj.in_review,
+ models.DomainApplication.ApplicationStatus.ACTION_NEEDED: obj.action_needed,
+ models.DomainApplication.ApplicationStatus.APPROVED: obj.approve,
+ models.DomainApplication.ApplicationStatus.WITHDRAWN: obj.withdraw,
+ models.DomainApplication.ApplicationStatus.REJECTED: obj.reject,
+ models.DomainApplication.ApplicationStatus.INELIGIBLE: (obj.reject_with_prejudice),
}
selected_method = status_method_mapping.get(obj.status)
if selected_method is None:
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 9bdcd4e98..c99daf72b 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -541,6 +541,7 @@ OIDC_PROVIDERS = {
"scope": ["email", "profile:name", "phone"],
"user_info_request": ["email", "first_name", "last_name", "phone"],
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
+ "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
},
"client_registration": {
"client_id": "cisa_dotgov_registrar",
@@ -558,6 +559,7 @@ OIDC_PROVIDERS = {
"scope": ["email", "profile:name", "phone"],
"user_info_request": ["email", "first_name", "last_name", "phone"],
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
+ "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
},
"client_registration": {
"client_id": ("urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"),
diff --git a/src/registrar/fixtures_applications.py b/src/registrar/fixtures_applications.py
index aea153aef..ad3ae0820 100644
--- a/src/registrar/fixtures_applications.py
+++ b/src/registrar/fixtures_applications.py
@@ -49,28 +49,28 @@ class DomainApplicationFixture:
# },
DA = [
{
- "status": "started",
- "organization_name": "Example - Finished but not Submitted",
+ "status": DomainApplication.ApplicationStatus.STARTED,
+ "organization_name": "Example - Finished but not submitted",
},
{
- "status": "submitted",
- "organization_name": "Example - Submitted but pending Investigation",
+ "status": DomainApplication.ApplicationStatus.SUBMITTED,
+ "organization_name": "Example - Submitted but pending investigation",
},
{
- "status": "in review",
- "organization_name": "Example - In Investigation",
+ "status": DomainApplication.ApplicationStatus.IN_REVIEW,
+ "organization_name": "Example - In investigation",
},
{
- "status": "in review",
+ "status": DomainApplication.ApplicationStatus.IN_REVIEW,
"organization_name": "Example - Approved",
},
{
- "status": "withdrawn",
+ "status": DomainApplication.ApplicationStatus.WITHDRAWN,
"organization_name": "Example - Withdrawn",
},
{
- "status": "action needed",
- "organization_name": "Example - Action Needed",
+ "status": DomainApplication.ApplicationStatus.ACTION_NEEDED,
+ "organization_name": "Example - Action needed",
},
{
"status": "rejected",
@@ -214,7 +214,9 @@ class DomainFixture(DomainApplicationFixture):
for user in users:
# approve one of each users in review status domains
- application = DomainApplication.objects.filter(creator=user, status=DomainApplication.IN_REVIEW).last()
+ application = DomainApplication.objects.filter(
+ creator=user, status=DomainApplication.ApplicationStatus.IN_REVIEW
+ ).last()
logger.debug(f"Approving {application} for {user}")
application.approve()
application.save()
diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py
index 9a8899e2b..5310c4610 100644
--- a/src/registrar/forms/application_wizard.py
+++ b/src/registrar/forms/application_wizard.py
@@ -262,7 +262,7 @@ class OrganizationContactForm(RegistrarForm):
validators=[
RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
- message="Enter a zip code in the form of 12345 or 12345-6789.",
+ message="Enter a zip code in the required format, like 12345 or 12345-6789.",
)
],
)
diff --git a/src/registrar/forms/common.py b/src/registrar/forms/common.py
index 3ab36cf6f..e2a234e71 100644
--- a/src/registrar/forms/common.py
+++ b/src/registrar/forms/common.py
@@ -1,6 +1,6 @@
# common.py
#
-# ALGORITHM_CHOICES are options for alg attribute in DS Data
+# ALGORITHM_CHOICES are options for alg attribute in DS data
# reference:
# https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
ALGORITHM_CHOICES = [
@@ -18,7 +18,7 @@ ALGORITHM_CHOICES = [
(15, "(15) Ed25519"),
(16, "(16) Ed448"),
]
-# DIGEST_TYPE_CHOICES are options for digestType attribute in DS Data
+# DIGEST_TYPE_CHOICES are options for digestType attribute in DS data
# reference: https://datatracker.ietf.org/doc/html/rfc4034#appendix-A.2
DIGEST_TYPE_CHOICES = [
(1, "(1) SHA-1"),
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index ff41b9268..8b55aa29d 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -239,7 +239,7 @@ class DomainOrgNameAddressForm(forms.ModelForm):
validators=[
RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
- message="Enter a zip code in the form of 12345 or 12345-6789.",
+ message="Enter a zip code in the required format, like 12345 or 12345-6789.",
)
],
)
@@ -302,7 +302,7 @@ class DomainDnssecForm(forms.Form):
class DomainDsdataForm(forms.Form):
- """Form for adding or editing DNSSEC DS Data to a domain."""
+ """Form for adding or editing DNSSEC DS data to a domain."""
def validate_hexadecimal(value):
"""
diff --git a/src/registrar/management/commands/load_domain_invitations.py b/src/registrar/management/commands/load_domain_invitations.py
index 28eb09def..32a63d860 100644
--- a/src/registrar/management/commands/load_domain_invitations.py
+++ b/src/registrar/management/commands/load_domain_invitations.py
@@ -62,7 +62,7 @@ class Command(BaseCommand):
DomainInvitation(
email=email_address.lower(),
domain=domain,
- status=DomainInvitation.INVITED,
+ status=DomainInvitation.DomainInvitationStatus.INVITED,
)
)
logger.info("Creating %d invitations", len(to_create))
diff --git a/src/registrar/migrations/0055_alter_domain_state_alter_domainapplication_status_and_more.py b/src/registrar/migrations/0055_alter_domain_state_alter_domainapplication_status_and_more.py
new file mode 100644
index 000000000..9b6bac48c
--- /dev/null
+++ b/src/registrar/migrations/0055_alter_domain_state_alter_domainapplication_status_and_more.py
@@ -0,0 +1,70 @@
+# Generated by Django 4.2.7 on 2023-12-06 16:16
+
+from django.db import migrations, models
+import django_fsm
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0054_alter_domainapplication_federal_agency_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="domain",
+ name="state",
+ field=django_fsm.FSMField(
+ choices=[
+ ("unknown", "Unknown"),
+ ("dns needed", "Dns needed"),
+ ("ready", "Ready"),
+ ("on hold", "On hold"),
+ ("deleted", "Deleted"),
+ ],
+ default="unknown",
+ help_text="Very basic info about the lifecycle of this domain object",
+ max_length=21,
+ protected=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainapplication",
+ name="status",
+ field=django_fsm.FSMField(
+ choices=[
+ ("started", "Started"),
+ ("submitted", "Submitted"),
+ ("in review", "In review"),
+ ("action needed", "Action needed"),
+ ("approved", "Approved"),
+ ("withdrawn", "Withdrawn"),
+ ("rejected", "Rejected"),
+ ("ineligible", "Ineligible"),
+ ],
+ default="started",
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininvitation",
+ name="status",
+ field=django_fsm.FSMField(
+ choices=[("invited", "Invited"), ("retrieved", "Retrieved")],
+ default="invited",
+ max_length=50,
+ protected=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="transitiondomain",
+ name="status",
+ field=models.CharField(
+ blank=True,
+ choices=[("ready", "Ready"), ("on hold", "On hold"), ("unknown", "Unknown")],
+ default="ready",
+ help_text="domain status during the transfer",
+ max_length=255,
+ verbose_name="Status",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 94430fb36..c92f540f1 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -122,20 +122,20 @@ class Domain(TimeStampedModel, DomainHelper):
"""These capture (some of) the states a domain object can be in."""
# the state is indeterminate
- UNKNOWN = "unknown"
+ UNKNOWN = "unknown", "Unknown"
# The domain object exists in the registry
# but nameservers don't exist for it yet
- DNS_NEEDED = "dns needed"
+ DNS_NEEDED = "dns needed", "Dns needed"
# Domain has had nameservers set, may or may not be active
- READY = "ready"
+ READY = "ready", "Ready"
# Registrar manually changed state to client hold
- ON_HOLD = "on hold"
+ ON_HOLD = "on hold", "On hold"
# previously existed but has been deleted from the registry
- DELETED = "deleted"
+ DELETED = "deleted", "Deleted"
class Cache(property):
"""
@@ -174,7 +174,8 @@ class Domain(TimeStampedModel, DomainHelper):
"""Check if a domain is available."""
if not cls.string_could_be_domain(domain):
raise ValueError("Not a valid domain: %s" % str(domain))
- req = commands.CheckDomain([domain])
+ domain_name = domain.lower()
+ req = commands.CheckDomain([domain_name])
return registry.send(req, cleaned=True).res_data[0].avail
@classmethod
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index 2eb2075b7..12eda4caf 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -19,25 +19,16 @@ class DomainApplication(TimeStampedModel):
"""A registrant's application for a new domain."""
- # #### Constants for choice fields ####
- STARTED = "started"
- SUBMITTED = "submitted"
- IN_REVIEW = "in review"
- ACTION_NEEDED = "action needed"
- APPROVED = "approved"
- WITHDRAWN = "withdrawn"
- REJECTED = "rejected"
- INELIGIBLE = "ineligible"
- STATUS_CHOICES = [
- (STARTED, STARTED),
- (SUBMITTED, SUBMITTED),
- (IN_REVIEW, IN_REVIEW),
- (ACTION_NEEDED, ACTION_NEEDED),
- (APPROVED, APPROVED),
- (WITHDRAWN, WITHDRAWN),
- (REJECTED, REJECTED),
- (INELIGIBLE, INELIGIBLE),
- ]
+ # Constants for choice fields
+ class ApplicationStatus(models.TextChoices):
+ STARTED = "started", "Started"
+ SUBMITTED = "submitted", "Submitted"
+ IN_REVIEW = "in review", "In review"
+ ACTION_NEEDED = "action needed", "Action needed"
+ APPROVED = "approved", "Approved"
+ WITHDRAWN = "withdrawn", "Withdrawn"
+ REJECTED = "rejected", "Rejected"
+ INELIGIBLE = "ineligible", "Ineligible"
class StateTerritoryChoices(models.TextChoices):
ALABAMA = "AL", "Alabama (AL)"
@@ -363,8 +354,8 @@ class DomainApplication(TimeStampedModel):
# #### Internal fields about the application #####
status = FSMField(
- choices=STATUS_CHOICES, # possible states as an array of constants
- default=STARTED, # sensible default
+ choices=ApplicationStatus.choices, # possible states as an array of constants
+ default=ApplicationStatus.STARTED, # sensible default
protected=False, # can change state directly, particularly in Django admin
)
# This is the application user who created this application. The contact
@@ -592,7 +583,11 @@ class DomainApplication(TimeStampedModel):
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)
- @transition(field="status", source=[STARTED, ACTION_NEEDED, WITHDRAWN], target=SUBMITTED)
+ @transition(
+ field="status",
+ source=[ApplicationStatus.STARTED, ApplicationStatus.ACTION_NEEDED, ApplicationStatus.WITHDRAWN],
+ target=ApplicationStatus.SUBMITTED,
+ )
def submit(self):
"""Submit an application that is started.
@@ -618,7 +613,7 @@ class DomainApplication(TimeStampedModel):
"emails/submission_confirmation_subject.txt",
)
- @transition(field="status", source=SUBMITTED, target=IN_REVIEW)
+ @transition(field="status", source=ApplicationStatus.SUBMITTED, target=ApplicationStatus.IN_REVIEW)
def in_review(self):
"""Investigate an application that has been submitted.
@@ -630,7 +625,11 @@ class DomainApplication(TimeStampedModel):
"emails/status_change_in_review_subject.txt",
)
- @transition(field="status", source=[IN_REVIEW, REJECTED], target=ACTION_NEEDED)
+ @transition(
+ field="status",
+ source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.REJECTED],
+ target=ApplicationStatus.ACTION_NEEDED,
+ )
def action_needed(self):
"""Send back an application that is under investigation or rejected.
@@ -644,8 +643,13 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
- source=[SUBMITTED, IN_REVIEW, REJECTED, INELIGIBLE],
- target=APPROVED,
+ source=[
+ ApplicationStatus.SUBMITTED,
+ ApplicationStatus.IN_REVIEW,
+ ApplicationStatus.REJECTED,
+ ApplicationStatus.INELIGIBLE,
+ ],
+ target=ApplicationStatus.APPROVED,
)
def approve(self):
"""Approve an application that has been submitted.
@@ -678,7 +682,11 @@ class DomainApplication(TimeStampedModel):
"emails/status_change_approved_subject.txt",
)
- @transition(field="status", source=[SUBMITTED, IN_REVIEW], target=WITHDRAWN)
+ @transition(
+ field="status",
+ source=[ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW],
+ target=ApplicationStatus.WITHDRAWN,
+ )
def withdraw(self):
"""Withdraw an application that has been submitted."""
self._send_status_update_email(
@@ -689,8 +697,8 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
- source=[IN_REVIEW, APPROVED],
- target=REJECTED,
+ source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.APPROVED],
+ target=ApplicationStatus.REJECTED,
conditions=[domain_is_not_active],
)
def reject(self):
@@ -698,7 +706,7 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information
(will cascade), and send an email notification."""
- if self.status == self.APPROVED:
+ if self.status == self.ApplicationStatus.APPROVED:
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
@@ -714,8 +722,8 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
- source=[IN_REVIEW, APPROVED],
- target=INELIGIBLE,
+ source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.APPROVED],
+ target=ApplicationStatus.INELIGIBLE,
conditions=[domain_is_not_active],
)
def reject_with_prejudice(self):
@@ -727,7 +735,7 @@ class DomainApplication(TimeStampedModel):
permissions classes test against. This will also delete the domain
and domain_information (will cascade) when they exist."""
- if self.status == self.APPROVED:
+ if self.status == self.ApplicationStatus.APPROVED:
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py
index 1e0b7fec8..12082142d 100644
--- a/src/registrar/models/domain_invitation.py
+++ b/src/registrar/models/domain_invitation.py
@@ -15,8 +15,10 @@ logger = logging.getLogger(__name__)
class DomainInvitation(TimeStampedModel):
- INVITED = "invited"
- RETRIEVED = "retrieved"
+ # Constants for status field
+ class DomainInvitationStatus(models.TextChoices):
+ INVITED = "invited", "Invited"
+ RETRIEVED = "retrieved", "Retrieved"
email = models.EmailField(
null=False,
@@ -31,18 +33,15 @@ class DomainInvitation(TimeStampedModel):
)
status = FSMField(
- choices=[
- (INVITED, INVITED),
- (RETRIEVED, RETRIEVED),
- ],
- default=INVITED,
+ choices=DomainInvitationStatus.choices,
+ default=DomainInvitationStatus.INVITED,
protected=True, # can't alter state except through transition methods!
)
def __str__(self):
return f"Invitation for {self.email} on {self.domain} is {self.status}"
- @transition(field="status", source=INVITED, target=RETRIEVED)
+ @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py
index 28bdc4fc7..89032d2be 100644
--- a/src/registrar/models/transition_domain.py
+++ b/src/registrar/models/transition_domain.py
@@ -4,7 +4,7 @@ from .utility.time_stamped_model import TimeStampedModel
class StatusChoices(models.TextChoices):
READY = "ready", "Ready"
- ON_HOLD = "on hold", "On Hold"
+ ON_HOLD = "on hold", "On hold"
UNKNOWN = "unknown", "Unknown"
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 2daa3c253..0a153b5c8 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -3,6 +3,8 @@ import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
+from registrar.models.user_domain_role import UserDomainRole
+
from .domain_invitation import DomainInvitation
from .transition_domain import TransitionDomain
from .domain import Domain
@@ -64,10 +66,42 @@ class User(AbstractUser):
def is_restricted(self):
return self.status == self.RESTRICTED
+ @classmethod
+ def needs_identity_verification(cls, email, uuid):
+ """A method used by our oidc classes to test whether a user needs email/uuid verification
+ or the full identity PII verification"""
+
+ # An existing user who is a domain manager of a domain (that is,
+ # they have an entry in UserDomainRole for their User)
+ try:
+ existing_user = cls.objects.get(username=uuid)
+ if existing_user and UserDomainRole.objects.filter(user=existing_user).exists():
+ return False
+ except cls.DoesNotExist:
+ # Do nothing when the user is not found, as we're checking for existence.
+ pass
+ except Exception as err:
+ raise err
+
+ # A new incoming user who is a domain manager for one of the domains
+ # that we inputted from Verisign (that is, their email address appears
+ # in the username field of a TransitionDomain)
+ if TransitionDomain.objects.filter(username=email).exists():
+ return False
+
+ # A new incoming user who is being invited to be a domain manager (that is,
+ # their email address is in DomainInvitation for an invitation that is not yet "retrieved").
+ if DomainInvitation.objects.filter(email=email, status=DomainInvitation.INVITED).exists():
+ return False
+
+ return True
+
def check_domain_invitations_on_login(self):
"""When a user first arrives on the site, we need to retrieve any domain
invitations that match their email address."""
- for invitation in DomainInvitation.objects.filter(email=self.email, status=DomainInvitation.INVITED):
+ for invitation in DomainInvitation.objects.filter(
+ email=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED
+ ):
try:
invitation.retrieve()
invitation.save()
diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html
index 3bf02f0e0..fbabf39a7 100644
--- a/src/registrar/templates/application_status.html
+++ b/src/registrar/templates/application_status.html
@@ -31,7 +31,7 @@
Status:
{% if domainapplication.status == 'approved' %} Approved
- {% elif domainapplication.status == 'in review' %} In Review
+ {% elif domainapplication.status == 'in review' %} In review
{% elif domainapplication.status == 'rejected' %} Rejected
{% elif domainapplication.status == 'submitted' %} Submitted
{% elif domainapplication.status == 'ineligible' %} Ineligible
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index 81a350f82..470df7537 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -18,7 +18,7 @@
Status:
{% if domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED%}
- DNS Needed
+ DNS needed
{% else %}
{{ domain.state|title }}
{% endif %}
diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html
index d049675fc..15343413b 100644
--- a/src/registrar/templates/domain_dsdata.html
+++ b/src/registrar/templates/domain_dsdata.html
@@ -1,13 +1,13 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
-{% block title %}DS Data | {{ domain.name }} | {% endblock %}
+{% block title %}DS data | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% if domain.dnssecdata is None %}
- You have no DS Data added. Enable DNSSEC by adding DS Data.
+ You have no DS data added. Enable DNSSEC by adding DS data.
{% endif %}
@@ -16,11 +16,11 @@
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
-
DS Data
+
DS data
In order to enable DNSSEC, you must first configure it with your DNS hosting service.
-
Enter the values given by your DNS provider for DS Data.
+
Enter the values given by your DNS provider for DS data.
{% include "includes/required_fields.html" %}
@@ -31,9 +31,9 @@
{% for form in formset %}