diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 2ce34dc76..a45e27982 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -493,3 +493,34 @@ The `load_organization_data` script has five optional parameters. These are as f | 3 | **directory** | Specifies the directory containing the files that will be parsed. Defaults to "migrationdata" | | 4 | **domain_additional_filename** | Specifies the filename of domain_additional. Used as an override for the JSON. Has no default. | | 5 | **organization_adhoc_filename** | Specifies the filename of organization_adhoc. Used as an override for the JSON. Has no default. | + + +## Extend Domain Extension Dates +This section outlines how to extend the expiration date of all ready domains (or a select subset) by a defined period of time. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Extend domains +```./manage.py extend_expiration_dates``` + +### Running locally +```docker-compose exec app ./manage.py extend_expiration_dates``` + +##### Optional parameters +| | Parameter | Description | +|:-:|:-------------------------- |:----------------------------------------------------------------------------| +| 1 | **extensionAmount** | Determines the period of time to extend by, in years. Defaults to 1 year. | +| 2 | **debug** | Increases logging detail. Defaults to False. | +| 3 | **limitParse** | Determines how many domains to parse. Defaults to all. | +| 4 | **disableIdempotentCheck** | Boolean that determines if we should check for idempotence or not. Compares the proposed extension date to the value in TransitionDomains. Defaults to False. | diff --git a/src/.pa11yci b/src/.pa11yci index 6bb5727e0..0ab3f4dd7 100644 --- a/src/.pa11yci +++ b/src/.pa11yci @@ -19,7 +19,6 @@ "http://localhost:8080/register/other_contacts/", "http://localhost:8080/register/anything_else/", "http://localhost:8080/register/requirements/", - "http://localhost:8080/register/review/", "http://localhost:8080/register/finished/" ] } diff --git a/src/api/views.py b/src/api/views.py index 068844919..a7dd7600a 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -91,15 +91,17 @@ def available(request, domain=""): # validate that the given domain could be a domain name and fail early if # not. if not (DraftDomain.string_could_be_domain(domain) or DraftDomain.string_could_be_domain(domain + ".gov")): - return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["invalid"]}) + return JsonResponse({"available": False, "code": "invalid", "message": DOMAIN_API_MESSAGES["invalid"]}) # a domain is available if it is NOT in the list of current domains try: if check_domain_available(domain): - return JsonResponse({"available": True, "message": DOMAIN_API_MESSAGES["success"]}) + return JsonResponse({"available": True, "code": "success", "message": DOMAIN_API_MESSAGES["success"]}) else: - return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["unavailable"]}) + return JsonResponse( + {"available": False, "code": "unavailable", "message": DOMAIN_API_MESSAGES["unavailable"]} + ) except Exception: - return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["error"]}) + return JsonResponse({"available": False, "code": "error", "message": DOMAIN_API_MESSAGES["error"]}) @require_http_methods(["GET"]) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index ce6b3acd3..b0e6417db 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -46,8 +46,14 @@ class OpenIdConnectBackend(ModelBackend): # defaults _will_ be updated, these are not fallbacks "defaults": openid_data, } - user, created = UserModel.objects.update_or_create(**args) - if created: + + user, created = UserModel.objects.get_or_create(**args) + + if not created: + # If user exists, update existing user + self.update_existing_user(user, args["defaults"]) + else: + # If user is created, configure the user user = self.configure_user(user, **kwargs) else: try: @@ -58,6 +64,16 @@ class OpenIdConnectBackend(ModelBackend): user.on_each_login() return user + def update_existing_user(self, user, kwargs): + """Update other fields without overwriting first_name and last_name. + Overwrite first_name and last_name if not empty string""" + + for key, value in kwargs.items(): + # Check if the key is not first_name or last_name or value is not empty string + if key not in ["first_name", "last_name"] or value: + setattr(user, key, value) + user.save() + def clean_username(self, username): """ Performs any cleaning on the "username" prior to using it to get or diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py new file mode 100644 index 000000000..ac7f74903 --- /dev/null +++ b/src/djangooidc/tests/test_backends.py @@ -0,0 +1,99 @@ +from django.test import TestCase +from registrar.models import User +from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure + + +class OpenIdConnectBackendTestCase(TestCase): + def setUp(self): + self.backend = OpenIdConnectBackend() + self.kwargs = { + "sub": "test_user", + "given_name": "John", + "family_name": "Doe", + "email": "john.doe@example.com", + "phone": "123456789", + } + + def tearDown(self) -> None: + User.objects.all().delete() + + def test_authenticate_with_create_user(self): + """Test that authenticate creates a new user if it does not find + existing user""" + # Ensure that the authenticate method creates a new user + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + self.assertEqual(user.username, "test_user") + + # Verify that user fields are correctly set + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + + def test_authenticate_with_existing_user(self): + """Test that authenticate updates an existing user if it finds one. + For this test, given_name and family_name are supplied""" + # Create an existing user with the same username + existing_user = User.objects.create_user(username="test_user") + + # Ensure that the authenticate method updates the existing user + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + self.assertEqual(user, existing_user) # The same user instance should be returned + + # Verify that user fields are correctly updated + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + + def test_authenticate_with_existing_user_no_name(self): + """Test that authenticate updates an existing user if it finds one. + For this test, given_name and family_name are not supplied""" + # Create an existing user with the same username and with first and last names + existing_user = User.objects.create_user(username="test_user", first_name="John", last_name="Doe") + + # Remove given_name and family_name from the input, self.kwargs + self.kwargs.pop("given_name", None) + self.kwargs.pop("family_name", None) + + # Ensure that the authenticate method updates the existing user + # and preserves existing first and last names + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + self.assertEqual(user, existing_user) # The same user instance should be returned + + # Verify that user fields are correctly updated + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + + def test_authenticate_with_existing_user_different_name(self): + """Test that authenticate updates an existing user if it finds one. + For this test, given_name and family_name are supplied and overwrite""" + # Create an existing user with the same username and with first and last names + existing_user = User.objects.create_user(username="test_user", first_name="WillBe", last_name="Replaced") + + # Ensure that the authenticate method updates the existing user + # and preserves existing first and last names + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + self.assertEqual(user, existing_user) # The same user instance should be returned + + # Verify that user fields are correctly updated + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + + def test_authenticate_with_unknown_user(self): + """Test that authenticate returns None when no kwargs are supplied""" + # Ensure that the authenticate method handles the case when the user is not found + user = self.backend.authenticate(request=None, **{}) + self.assertIsNone(user) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 8b55aa29d..ac96393b4 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -28,6 +28,17 @@ class DomainAddUserForm(forms.Form): email = forms.EmailField(label="Email") + def clean(self): + """clean form data by lowercasing email""" + cleaned_data = super().clean() + + # Lowercase the value of the 'email' field + email_value = cleaned_data.get("email") + if email_value: + cleaned_data["email"] = email_value.lower() + + return cleaned_data + class DomainNameserverForm(forms.Form): """Form for changing nameservers.""" diff --git a/src/registrar/management/commands/copy_names_from_contacts_to_users.py b/src/registrar/management/commands/copy_names_from_contacts_to_users.py new file mode 100644 index 000000000..50e1bea3d --- /dev/null +++ b/src/registrar/management/commands/copy_names_from_contacts_to_users.py @@ -0,0 +1,235 @@ +import logging +import argparse +import sys + +from django.core.management import BaseCommand + +from registrar.management.commands.utility.terminal_helper import ( + TerminalColors, + TerminalHelper, +) +from registrar.models.contact import Contact +from registrar.models.user import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = """Copy first and last names from a contact to + a related user if it exists and if its first and last name + properties are null or blank strings.""" + + # ====================================================== + # ===================== ARGUMENTS ===================== + # ====================================================== + def add_arguments(self, parser): + parser.add_argument("--debug", action=argparse.BooleanOptionalAction) + + # ====================================================== + # ===================== PRINTING ====================== + # ====================================================== + def print_debug_mode_statements(self, debug_on: bool): + """Prints additional terminal statements to indicate if --debug + or --limitParse are in use""" + TerminalHelper.print_conditional( + debug_on, + f"""{TerminalColors.OKCYAN} + ----------DEBUG MODE ON---------- + Detailed print statements activated. + {TerminalColors.ENDC} + """, + ) + + def print_summary_of_findings( + self, + skipped_contacts, + eligible_users, + processed_users, + debug_on, + ): + """Prints to terminal a summary of findings from + copying first and last names from contacts to users""" + + total_eligible_users = len(eligible_users) + total_skipped_contacts = len(skipped_contacts) + total_processed_users = len(processed_users) + + logger.info( + f"""{TerminalColors.OKGREEN} + ============= FINISHED =============== + Skipped {total_skipped_contacts} contacts + Found {total_eligible_users} users linked to contacts + Processed {total_processed_users} users + {TerminalColors.ENDC} + """ # noqa + ) + + # DEBUG: + TerminalHelper.print_conditional( + debug_on, + f"""{TerminalColors.YELLOW} + ======= DEBUG OUTPUT ======= + Users who have a linked contact: + {eligible_users} + + Processed users (users who have a linked contact and a missing first or last name): + {processed_users} + + ===== SKIPPED CONTACTS ===== + {skipped_contacts} + + {TerminalColors.ENDC} + """, + ) + + # ====================================================== + # =================== USER ===================== + # ====================================================== + def update_user(self, contact: Contact, debug_on: bool): + """Given a contact with a first_name and last_name, find & update an existing + corresponding user if her first_name and last_name are null. + + Returns tuple of eligible (is linked to the contact) and processed + (first and last are blank) users. + """ + + user_exists = User.objects.filter(contact=contact).exists() + if user_exists: + try: + # ----------------------- UPDATE USER ----------------------- + # ---- GET THE USER + eligible_user = User.objects.get(contact=contact) + processed_user = None + # DEBUG: + TerminalHelper.print_conditional( + debug_on, + f"""{TerminalColors.YELLOW} + > Found linked user for contact: + {contact} {contact.email} {contact.first_name} {contact.last_name} + > The linked user is {eligible_user} {eligible_user.username} + {TerminalColors.ENDC}""", # noqa + ) + + # ---- UPDATE THE USER IF IT DOES NOT HAVE A FIRST AND LAST NAMES + # ---- LET'S KEEP A LIGHT TOUCH + if not eligible_user.first_name and not eligible_user.last_name: + # (expression has type "str | None", variable has type "str | int | Combinable") + # so we'll ignore type + eligible_user.first_name = contact.first_name # type: ignore + eligible_user.last_name = contact.last_name # type: ignore + eligible_user.save() + processed_user = eligible_user + + return ( + eligible_user, + processed_user, + ) + + except Exception as error: + logger.warning( + f""" + {TerminalColors.FAIL} + !!! ERROR: An exception occured in the + User table for the following user: + {contact.email} {contact.first_name} {contact.last_name} + + Exception is: {error} + ----------TERMINATING----------""" + ) + sys.exit() + else: + return None, None + + # ====================================================== + # ================= PROCESS CONTACTS ================== + # ====================================================== + + def process_contacts( + self, + debug_on, + skipped_contacts=[], + eligible_users=[], + processed_users=[], + ): + for contact in Contact.objects.all(): + TerminalHelper.print_conditional( + debug_on, + f"{TerminalColors.OKCYAN}" + "Processing Contact: " + f"{contact.email}," + f" {contact.first_name}," + f" {contact.last_name}" + f"{TerminalColors.ENDC}", + ) + + # ====================================================== + # ====================== USER ======================= + (eligible_user, processed_user) = self.update_user(contact, debug_on) + + debug_string = "" + if eligible_user: + # ---------------- UPDATED ---------------- + eligible_users.append(contact.email) + debug_string = f"eligible user: {eligible_user}" + if processed_user: + processed_users.append(contact.email) + debug_string = f"processed user: {processed_user}" + else: + skipped_contacts.append(contact.email) + debug_string = f"skipped user: {contact.email}" + + # DEBUG: + TerminalHelper.print_conditional( + debug_on, + (f"{TerminalColors.OKCYAN} {debug_string} {TerminalColors.ENDC}"), + ) + + return ( + skipped_contacts, + eligible_users, + processed_users, + ) + + # ====================================================== + # ===================== HANDLE ======================== + # ====================================================== + def handle( + self, + **options, + ): + """Parse entries in Contact table + and update valid corresponding entries in the + User table.""" + + # grab command line arguments and store locally... + debug_on = options.get("debug") + + self.print_debug_mode_statements(debug_on) + + logger.info( + f"""{TerminalColors.OKCYAN} + ========================== + Beginning Data Transfer + ========================== + {TerminalColors.ENDC}""" + ) + + logger.info( + f"""{TerminalColors.OKCYAN} + ========= Adding Domains and Domain Invitations ========= + {TerminalColors.ENDC}""" + ) + ( + skipped_contacts, + eligible_users, + processed_users, + ) = self.process_contacts( + debug_on, + ) + + self.print_summary_of_findings( + skipped_contacts, + eligible_users, + processed_users, + debug_on, + ) diff --git a/src/registrar/management/commands/extend_expiration_dates.py b/src/registrar/management/commands/extend_expiration_dates.py new file mode 100644 index 000000000..5e203e488 --- /dev/null +++ b/src/registrar/management/commands/extend_expiration_dates.py @@ -0,0 +1,212 @@ +"""Data migration: Extends expiration dates for valid domains""" + +import argparse +from datetime import date +import logging + +from django.core.management import BaseCommand +from epplibwrapper.errors import RegistryError +from registrar.models import Domain +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper + +from registrar.models.transition_domain import TransitionDomain + +try: + from epplib.exceptions import TransportError +except ImportError: + pass + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Extends expiration dates for valid domains" + + def __init__(self): + """Sets global variables for code tidyness""" + super().__init__() + self.update_success = [] + self.update_skipped = [] + self.update_failed = [] + self.expiration_minimum_cutoff = date(2023, 11, 15) + self.expiration_maximum_cutoff = date(2023, 12, 30) + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument( + "--extensionAmount", + type=int, + default=1, + help="Determines the period (in years) to extend expiration dates by", + ) + parser.add_argument( + "--limitParse", + type=int, + default=0, + help="Sets a cap on the number of records to parse", + ) + parser.add_argument( + "--disableIdempotentCheck", action=argparse.BooleanOptionalAction, help="Disable script idempotence" + ) + parser.add_argument("--debug", action=argparse.BooleanOptionalAction, help="Increases log chattiness") + + def handle(self, **options): + """ + Extends the expiration dates for valid domains. + + If a parse limit is set and it's less than the total number of valid domains, + the number of domains to change is set to the parse limit. + + Includes an idempotence check. + """ + + # Retrieve command line options + extension_amount = options.get("extensionAmount") + limit_parse = options.get("limitParse") + disable_idempotence = options.get("disableIdempotentCheck") + debug = options.get("debug") + + # Does a check to see if parse_limit is a positive int. + # Raise an error if not. + self.check_if_positive_int(limit_parse, "limitParse") + + valid_domains = Domain.objects.filter( + expiration_date__gte=self.expiration_minimum_cutoff, + expiration_date__lte=self.expiration_maximum_cutoff, + state=Domain.State.READY, + ).order_by("name") + + domains_to_change_count = valid_domains.count() + if limit_parse != 0: + domains_to_change_count = limit_parse + valid_domains = valid_domains[:limit_parse] + + # Determines if we should continue code execution or not. + # If the user prompts 'N', a sys.exit() will be called. + self.prompt_user_to_proceed(extension_amount, domains_to_change_count) + + for domain in valid_domains: + try: + is_idempotent = self.idempotence_check(domain, extension_amount) + if not disable_idempotence and not is_idempotent: + self.update_skipped.append(domain.name) + logger.info(f"{TerminalColors.YELLOW}" f"Skipping update for {domain}" f"{TerminalColors.ENDC}") + else: + domain.renew_domain(extension_amount) + self.update_success.append(domain.name) + logger.info( + f"{TerminalColors.OKCYAN}" + f"Successfully updated expiration date for {domain}" + f"{TerminalColors.ENDC}" + ) + # Catches registry errors. Failures indicate bad data, or a faulty connection. + except (RegistryError, KeyError, TransportError) as err: + self.update_failed.append(domain.name) + logger.error( + f"{TerminalColors.FAIL}" f"Failed to update expiration date for {domain}" f"{TerminalColors.ENDC}" + ) + logger.error(err) + except Exception as err: + self.log_script_run_summary(debug) + raise err + self.log_script_run_summary(debug) + + # == Helper functions == # + def idempotence_check(self, domain: Domain, extension_amount): + """Determines if the proposed operation violates idempotency""" + # Because our migration data had a hard stop date, we can determine if our change + # is valid simply checking the date is within a valid range and it was updated + # in epp or not. + # CAVEAT: This is a workaround. A more robust solution would be a db flag + current_expiration_date = domain.registry_expiration_date + transition_domains = TransitionDomain.objects.filter( + domain_name=domain.name, epp_expiration_date=current_expiration_date + ) + + return transition_domains.count() > 0 + + def prompt_user_to_proceed(self, extension_amount, domains_to_change_count): + """Asks if the user wants to proceed with this action""" + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=f""" + ==Extension Amount== + Period: {extension_amount} year(s) + + ==Proposed Changes== + Domains to change: {domains_to_change_count} + """, + prompt_title="Do you wish to proceed with these changes?", + ) + + logger.info(f"{TerminalColors.MAGENTA}" "Preparing to extend expiration dates..." f"{TerminalColors.ENDC}") + + def check_if_positive_int(self, value: int, var_name: str): + """ + Determines if the given integer value is positive or not. + If not, it raises an ArgumentTypeError + """ + if value < 0: + raise argparse.ArgumentTypeError( + f"{value} is an invalid integer value for {var_name}. " "Must be positive." + ) + + return value + + def log_script_run_summary(self, debug): + """Prints success, failed, and skipped counts, as well as + all affected domains.""" + update_success_count = len(self.update_success) + update_failed_count = len(self.update_failed) + update_skipped_count = len(self.update_skipped) + + # Prepare debug messages + debug_messages = { + "success": (f"{TerminalColors.OKCYAN}Updated these Domains: {self.update_success}{TerminalColors.ENDC}\n"), + "skipped": (f"{TerminalColors.YELLOW}Skipped these Domains: {self.update_skipped}{TerminalColors.ENDC}\n"), + "failed": ( + f"{TerminalColors.FAIL}Failed to update these Domains: {self.update_failed}{TerminalColors.ENDC}\n" + ), + } + + # Print out a list of everything that was changed, if we have any changes to log. + # Otherwise, don't print anything. + TerminalHelper.print_conditional( + debug, + f"{debug_messages.get('success') if update_success_count > 0 else ''}" + f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}" + f"{debug_messages.get('failed') if update_failed_count > 0 else ''}", + ) + + if update_failed_count == 0 and update_skipped_count == 0: + logger.info( + f"""{TerminalColors.OKGREEN} + ============= FINISHED =============== + Updated {update_success_count} Domain entries + {TerminalColors.ENDC} + """ + ) + elif update_failed_count == 0: + logger.info( + f"""{TerminalColors.YELLOW} + ============= FINISHED =============== + Updated {update_success_count} Domain entries + + ----- IDEMPOTENCY CHECK FAILED ----- + Skipped updating {update_skipped_count} Domain entries + {TerminalColors.ENDC} + """ + ) + else: + logger.info( + f"""{TerminalColors.FAIL} + ============= FINISHED =============== + Updated {update_success_count} Domain entries + + ----- UPDATE FAILED ----- + Failed to update {update_failed_count} Domain entries, + Skipped updating {update_skipped_count} Domain entries + {TerminalColors.ENDC} + """ + ) diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 0a7ba4fa1..6b3b6ddb2 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -59,6 +59,16 @@ class Contact(TimeStampedModel): names = [n for n in [self.first_name, self.middle_name, self.last_name] if n] return " ".join(names) if names else "Unknown" + def save(self, *args, **kwargs): + # Call the parent class's save method to perform the actual save + super().save(*args, **kwargs) + + # Update the related User object's first_name and last_name + if self.user: + self.user.first_name = self.first_name + self.user.last_name = self.last_name + self.user.save() + def __str__(self): if self.first_name or self.last_name: return self.get_formatted_name() diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index c92f540f1..44cb45433 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1195,7 +1195,7 @@ class Domain(TimeStampedModel, DomainHelper): logger.error(e) logger.error(e.code) raise e - if e.code == ErrorCode.OBJECT_DOES_NOT_EXIST and self.state != Domain.State.DELETED: + if e.code == ErrorCode.OBJECT_DOES_NOT_EXIST and self.state == Domain.State.UNKNOWN: # avoid infinite loop already_tried_to_create = True self.dns_needed_from_unknown() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index ec2d06c70..d79e4c9ee 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -101,7 +101,7 @@ class User(AbstractUser): """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.DomainInvitationStatus.INVITED + email__iexact=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED ): try: invitation.retrieve() diff --git a/src/registrar/templates/application_form.html b/src/registrar/templates/application_form.html index db72a1fc2..cec2416fb 100644 --- a/src/registrar/templates/application_form.html +++ b/src/registrar/templates/application_form.html @@ -85,16 +85,29 @@ class="usa-button usa-button--outline" >Save and return to manage your domains {% else %} - + aria-controls="toggle-submit-domain-request" + data-open-modal + >Submit your domain request {% endif %} {% endblock %} +
You can enter your name servers, as well as other DNS-related information, in the following sections:
{% url 'domain-dns-nameservers' pk=domain.id as url %} -You don't have any registered domains yet
+You don't have any registered domains.
{% endif %} @@ -95,7 +95,7 @@Domain name | @@ -138,7 +138,7 @@ aria-live="polite" > {% else %} -
---|