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/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 %} +
+ {% include 'includes/modal.html' with modal_heading=modal_heading|safe modal_description="Once you submit this request, you won’t be able to make further edits until it’s reviewed by our staff. You’ll only be able to withdraw your request." modal_button=modal_button|safe %} +
+ {% block after_form_content %}{% endblock %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 756ed4378..38c3e35b4 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -87,7 +87,7 @@ aria-live="polite" > {% else %} -

You don't have any registered domains yet

+

You don't have any registered domains.

{% endif %} @@ -95,7 +95,7 @@

Domain requests

{% if domain_applications %} - + @@ -138,7 +138,7 @@ aria-live="polite" > {% else %} -

You don't have any active domain requests right now

+

You haven't requested any domains.

{% endif %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index f031f4d76..5166e9c18 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -5,6 +5,7 @@ import logging from contextlib import contextmanager import random from string import ascii_uppercase +import uuid from django.test import TestCase from unittest.mock import MagicMock, Mock, patch from typing import List, Dict @@ -161,7 +162,7 @@ class AuditedAdminMockData: user = User.objects.get_or_create( first_name="{} first_name:{}".format(item_name, short_hand), last_name="{} last_name:{}".format(item_name, short_hand), - username="{} username:{}".format(item_name, short_hand), + username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand), )[0] return user @@ -405,8 +406,8 @@ def mock_user(): """A simple user.""" user_kwargs = dict( id=4, - first_name="Rachid", - last_name="Mrad", + first_name="Jeff", + last_name="Lebowski", ) mock_user, _ = User.objects.get_or_create(**user_kwargs) return mock_user @@ -619,6 +620,17 @@ class MockEppLib(TestCase): ], ex_date=datetime.date(2023, 5, 25), ) + mockDataExtensionDomain = fakedEppObject( + "fakePw", + cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35), + contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + hosts=["fake.host.com"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], + ex_date=datetime.date(2023, 11, 15), + ) mockDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData( "123", "123@mail.gov", datetime.datetime(2023, 5, 25, 19, 45, 35), "lastPw" ) @@ -819,6 +831,21 @@ class MockEppLib(TestCase): ex_date=datetime.date(2023, 5, 25), ) + mockDnsNeededRenewedDomainExpDate = fakedEppObject( + "fakeneeded.gov", + ex_date=datetime.date(2023, 2, 15), + ) + + mockMaximumRenewedDomainExpDate = fakedEppObject( + "fakemaximum.gov", + ex_date=datetime.date(2024, 12, 31), + ) + + mockRecentRenewedDomainExpDate = fakedEppObject( + "waterbutpurple.gov", + ex_date=datetime.date(2024, 11, 15), + ) + def _mockDomainName(self, _name, _avail=False): return MagicMock( res_data=[ @@ -917,6 +944,21 @@ class MockEppLib(TestCase): def mockRenewDomainCommand(self, _request, cleaned): if getattr(_request, "name", None) == "fake-error.gov": raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR) + elif getattr(_request, "name", None) == "waterbutpurple.gov": + return MagicMock( + res_data=[self.mockRecentRenewedDomainExpDate], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) + elif getattr(_request, "name", None) == "fakeneeded.gov": + return MagicMock( + res_data=[self.mockDnsNeededRenewedDomainExpDate], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) + elif getattr(_request, "name", None) == "fakemaximum.gov": + return MagicMock( + res_data=[self.mockMaximumRenewedDomainExpDate], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) else: return MagicMock( res_data=[self.mockRenewedDomainExpDate], @@ -942,6 +984,7 @@ class MockEppLib(TestCase): self.infoDomainTwoHosts if self.mockedSendFunction.call_count == 5 else self.infoDomainNoHost, None, ), + "waterbutpurple.gov": (self.mockDataExtensionDomain, None), "nameserverwithip.gov": (self.infoDomainHasIP, None), "namerserversubdomain.gov": (self.infoDomainCheckHostIPCombo, None), "freeman.gov": (self.InfoDomainWithContacts, None), diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 8a67fc191..9d6add249 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1023,7 +1023,7 @@ class ListHeaderAdminTest(TestCase): # Set the GET parameters for testing request.GET = { "status": "started", - "investigator": "Rachid Mrad", + "investigator": "Jeff Lebowski", "q": "search_value", } # Call the get_filters method @@ -1034,7 +1034,7 @@ class ListHeaderAdminTest(TestCase): filters, [ {"parameter_name": "status", "parameter_value": "started"}, - {"parameter_name": "investigator", "parameter_value": "Rachid Mrad"}, + {"parameter_name": "investigator", "parameter_value": "Jeff Lebowski"}, ], ) @@ -1110,8 +1110,8 @@ class AuditedAdminTest(TestCase): tested_fields = [ DomainApplication.authorizing_official.field, DomainApplication.submitter.field, - DomainApplication.investigator.field, - DomainApplication.creator.field, + # DomainApplication.investigator.field, + # DomainApplication.creator.field, DomainApplication.requested_domain.field, ] @@ -1166,7 +1166,7 @@ class AuditedAdminTest(TestCase): tested_fields = [ DomainInformation.authorizing_official.field, DomainInformation.submitter.field, - DomainInformation.creator.field, + # DomainInformation.creator.field, (DomainInformation.domain.field, ["name"]), (DomainInformation.domain_application.field, ["requested_domain__name"]), ] diff --git a/src/registrar/tests/test_copy_names_from_contacts_to_users.py b/src/registrar/tests/test_copy_names_from_contacts_to_users.py new file mode 100644 index 000000000..032203f4e --- /dev/null +++ b/src/registrar/tests/test_copy_names_from_contacts_to_users.py @@ -0,0 +1,98 @@ +from django.test import TestCase + +from registrar.models import ( + User, + Contact, +) + +from registrar.management.commands.copy_names_from_contacts_to_users import Command + + +class TestDataUpdates(TestCase): + def setUp(self): + """We cannot setup the user details because contacts will override the first and last names in its save method + so we will initiate the users, setup the contacts and link them, and leave the rest of the setup to the test(s). + """ + + self.user1 = User.objects.create(username="user1") + self.user2 = User.objects.create(username="user2") + self.user3 = User.objects.create(username="user3") + self.user4 = User.objects.create(username="user4") + # The last user created triggers the creation of a contact and attaches itself to it. See signals. + # This bs_user defuses that situation. + self.bs_user = User.objects.create() + + self.contact1 = Contact.objects.create( + user=self.user1, email="email1@igorville.gov", first_name="first1", last_name="last1" + ) + self.contact2 = Contact.objects.create( + user=self.user2, email="email2@igorville.gov", first_name="first2", last_name="last2" + ) + self.contact3 = Contact.objects.create( + user=self.user3, email="email3@igorville.gov", first_name="first3", last_name="last3" + ) + self.contact4 = Contact.objects.create(email="email4@igorville.gov", first_name="first4", last_name="last4") + + self.command = Command() + + def tearDown(self): + """Clean up""" + # Delete users and contacts + User.objects.all().delete() + Contact.objects.all().delete() + + def test_script_updates_linked_users(self): + """Test the script that copies contacts' first and last names into associated users that + are eligible (first or last are blank or undefined)""" + + # Set up the users' first and last names here so + # they that they don't get overwritten by Contact's save() + # User with no first or last names + self.user1.first_name = "" + self.user1.last_name = "" + self.user1.save() + + # User with a first name but no last name + self.user2.first_name = "First name but no last name" + self.user2.last_name = "" + self.user2.save() + + # User with a first and last name + self.user3.first_name = "An existing first name" + self.user3.last_name = "An existing last name" + self.user3.save() + + # Call the parent method the same way we do it in the script + skipped_contacts = [] + eligible_users = [] + processed_users = [] + ( + skipped_contacts, + eligible_users, + processed_users, + ) = self.command.process_contacts( + # Set debugging to False + False, + skipped_contacts, + eligible_users, + processed_users, + ) + + # Trigger DB refresh + self.user1.refresh_from_db() + self.user2.refresh_from_db() + self.user3.refresh_from_db() + + # Asserts + # The user that has no first and last names will get them from the contact + self.assertEqual(self.user1.first_name, "first1") + self.assertEqual(self.user1.last_name, "last1") + # The user that has a first but no last will be left alone + self.assertEqual(self.user2.first_name, "First name but no last name") + self.assertEqual(self.user2.last_name, "") + # The user that has a first and a last will be left alone + self.assertEqual(self.user3.first_name, "An existing first name") + self.assertEqual(self.user3.last_name, "An existing last name") + # The unlinked user will be left alone + self.assertEqual(self.user4.first_name, "") + self.assertEqual(self.user4.last_name, "") diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 83126ab7c..0e0839382 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -654,3 +654,62 @@ class TestUser(TestCase): """A new user who's neither transitioned nor invited should return True when tested with class method needs_identity_verification""" self.assertTrue(User.needs_identity_verification(self.user.email, self.user.username)) + + def test_check_domain_invitations_on_login_caps_email(self): + """A DomainInvitation with an email address with capital letters should match + a User record whose email address is not in caps""" + # create DomainInvitation with CAPS email that matches User email + # on a case-insensitive match + caps_email = "MAYOR@igorville.gov" + # mock the domain invitation save routine + with patch("registrar.models.DomainInvitation.save") as save_mock: + DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain) + self.user.check_domain_invitations_on_login() + # if check_domain_invitations_on_login properly matches exactly one + # Domain Invitation, then save routine should be called exactly once + save_mock.assert_called_once() + + +class TestContact(TestCase): + def setUp(self): + self.email = "mayor@igorville.gov" + self.user, _ = User.objects.get_or_create(email=self.email, first_name="Jeff", last_name="Lebowski") + self.contact, _ = Contact.objects.get_or_create(user=self.user) + + def tearDown(self): + super().tearDown() + Contact.objects.all().delete() + User.objects.all().delete() + + def test_saving_contact_updates_user_first_last_names(self): + """When a contact is updated, we propagate the changes to the linked user if it exists.""" + # User and Contact are created and linked as expected + self.assertEqual(self.contact.first_name, "Jeff") + self.assertEqual(self.contact.last_name, "Lebowski") + self.assertEqual(self.user.first_name, "Jeff") + self.assertEqual(self.user.last_name, "Lebowski") + + self.contact.first_name = "Joey" + self.contact.last_name = "Baloney" + self.contact.save() + + # Refresh the user object to reflect the changes made in the database + self.user.refresh_from_db() + + # Updating the contact's first and last names propagate to the user + self.assertEqual(self.contact.first_name, "Joey") + self.assertEqual(self.contact.last_name, "Baloney") + self.assertEqual(self.user.first_name, "Joey") + self.assertEqual(self.user.last_name, "Baloney") + + def test_saving_contact_does_not_update_user_email(self): + """When a contact's email is updated, the change is not propagated to the lined user.""" + self.contact.email = "joey.baloney@diaperville.com" + self.contact.save() + + # Refresh the user object to reflect the changes made in the database + self.user.refresh_from_db() + + # Updating the contact's email does not propagate + self.assertEqual(self.contact.email, "joey.baloney@diaperville.com") + self.assertEqual(self.user.email, "mayor@igorville.gov") diff --git a/src/registrar/tests/test_transition_domain_migrations.py b/src/registrar/tests/test_transition_domain_migrations.py index cfee68fea..f3fd76e88 100644 --- a/src/registrar/tests/test_transition_domain_migrations.py +++ b/src/registrar/tests/test_transition_domain_migrations.py @@ -18,7 +18,175 @@ from unittest.mock import patch from registrar.models.contact import Contact -from .common import less_console_noise +from .common import MockEppLib, less_console_noise + + +class TestExtendExpirationDates(MockEppLib): + def setUp(self): + """Defines the file name of migration_json and the folder its contained in""" + super().setUp() + # Create a valid domain that is updatable + Domain.objects.get_or_create( + name="waterbutpurple.gov", state=Domain.State.READY, expiration_date=datetime.date(2023, 11, 15) + ) + TransitionDomain.objects.get_or_create( + username="testytester@mail.com", + domain_name="waterbutpurple.gov", + epp_expiration_date=datetime.date(2023, 11, 15), + ) + # Create a domain with an invalid expiration date + Domain.objects.get_or_create( + name="fake.gov", state=Domain.State.READY, expiration_date=datetime.date(2022, 5, 25) + ) + TransitionDomain.objects.get_or_create( + username="themoonisactuallycheese@mail.com", + domain_name="fake.gov", + epp_expiration_date=datetime.date(2022, 5, 25), + ) + # Create a domain with an invalid state + Domain.objects.get_or_create( + name="fakeneeded.gov", state=Domain.State.DNS_NEEDED, expiration_date=datetime.date(2023, 11, 15) + ) + TransitionDomain.objects.get_or_create( + username="fakeneeded@mail.com", + domain_name="fakeneeded.gov", + epp_expiration_date=datetime.date(2023, 11, 15), + ) + # Create a domain with a date greater than the maximum + Domain.objects.get_or_create( + name="fakemaximum.gov", state=Domain.State.READY, expiration_date=datetime.date(2024, 12, 31) + ) + TransitionDomain.objects.get_or_create( + username="fakemaximum@mail.com", + domain_name="fakemaximum.gov", + epp_expiration_date=datetime.date(2024, 12, 31), + ) + + def tearDown(self): + """Deletes all DB objects related to migrations""" + super().tearDown() + # Delete domain information + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainInvitation.objects.all().delete() + TransitionDomain.objects.all().delete() + + # Delete users + User.objects.all().delete() + UserDomainRole.objects.all().delete() + + def run_extend_expiration_dates(self): + """ + This method executes the transfer_transition_domains_to_domains command. + + The 'call_command' function from Django's management framework is then used to + execute the load_transition_domain command with the specified arguments. + """ + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa + return_value=True, + ): + call_command("extend_expiration_dates") + + def test_extends_expiration_date_correctly(self): + """ + Tests that the extend_expiration_dates method extends dates as expected + """ + desired_domain = Domain.objects.filter(name="waterbutpurple.gov").get() + desired_domain.expiration_date = datetime.date(2024, 11, 15) + + # Run the expiration date script + self.run_extend_expiration_dates() + + current_domain = Domain.objects.filter(name="waterbutpurple.gov").get() + + self.assertEqual(desired_domain, current_domain) + # Explicitly test the expiration date + self.assertEqual(current_domain.expiration_date, datetime.date(2024, 11, 15)) + + def test_extends_expiration_date_skips_non_current(self): + """ + Tests that the extend_expiration_dates method correctly skips domains + with an expiration date less than a certain threshold. + """ + desired_domain = Domain.objects.filter(name="fake.gov").get() + desired_domain.expiration_date = datetime.date(2022, 5, 25) + + # Run the expiration date script + self.run_extend_expiration_dates() + + current_domain = Domain.objects.filter(name="fake.gov").get() + self.assertEqual(desired_domain, current_domain) + + # Explicitly test the expiration date. The extend_expiration_dates script + # will skip all dates less than date(2023, 11, 15), meaning that this domain + # should not be affected by the change. + self.assertEqual(current_domain.expiration_date, datetime.date(2022, 5, 25)) + + def test_extends_expiration_date_skips_maximum_date(self): + """ + Tests that the extend_expiration_dates method correctly skips domains + with an expiration date more than a certain threshold. + """ + desired_domain = Domain.objects.filter(name="fakemaximum.gov").get() + desired_domain.expiration_date = datetime.date(2024, 12, 31) + + # Run the expiration date script + self.run_extend_expiration_dates() + + current_domain = Domain.objects.filter(name="fakemaximum.gov").get() + self.assertEqual(desired_domain, current_domain) + + # Explicitly test the expiration date. The extend_expiration_dates script + # will skip all dates less than date(2023, 11, 15), meaning that this domain + # should not be affected by the change. + self.assertEqual(current_domain.expiration_date, datetime.date(2024, 12, 31)) + + def test_extends_expiration_date_skips_non_ready(self): + """ + Tests that the extend_expiration_dates method correctly skips domains not in the state "ready" + """ + desired_domain = Domain.objects.filter(name="fakeneeded.gov").get() + desired_domain.expiration_date = datetime.date(2023, 11, 15) + + # Run the expiration date script + self.run_extend_expiration_dates() + + current_domain = Domain.objects.filter(name="fakeneeded.gov").get() + self.assertEqual(desired_domain, current_domain) + + # Explicitly test the expiration date. The extend_expiration_dates script + # will skip all dates less than date(2023, 11, 15), meaning that this domain + # should not be affected by the change. + self.assertEqual(current_domain.expiration_date, datetime.date(2023, 11, 15)) + + def test_extends_expiration_date_idempotent(self): + """ + Tests the idempotency of the extend_expiration_dates command. + + Verifies that running the method multiple times does not change the expiration date + of a domain beyond the initial extension. + """ + desired_domain = Domain.objects.filter(name="waterbutpurple.gov").get() + desired_domain.expiration_date = datetime.date(2024, 11, 15) + + # Run the expiration date script + self.run_extend_expiration_dates() + + current_domain = Domain.objects.filter(name="waterbutpurple.gov").get() + self.assertEqual(desired_domain, current_domain) + + # Explicitly test the expiration date + self.assertEqual(desired_domain.expiration_date, datetime.date(2024, 11, 15)) + + # Run the expiration date script again + self.run_extend_expiration_dates() + + # The old domain shouldn't have changed + self.assertEqual(desired_domain, current_domain) + + # Explicitly test the expiration date - should be the same + self.assertEqual(desired_domain.expiration_date, datetime.date(2024, 11, 15)) class TestProcessedMigrations(TestCase): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index da6fe6205..57fa03f52 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -141,7 +141,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # 302 redirect to the first form page = self.app.get(reverse("application:")).follow() # submitting should get back the same page if the required field is empty - result = page.form.submit() + result = page.forms[0].submit() self.assertIn("What kind of U.S.-based government organization do you represent?", result) def test_application_multiple_applications_exist(self): @@ -164,6 +164,9 @@ class DomainApplicationTests(TestWithUser, WebTest): this test work. This test also looks for the long organization name on the summary page. + + This also tests for the presence of a modal trigger and the dynamic test + in the modal header on the submit page. """ num_pages_tested = 0 # elections, type_of_work, tribal_government, no_other_contacts @@ -178,11 +181,11 @@ class DomainApplicationTests(TestWithUser, WebTest): session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] # ---- TYPE PAGE ---- - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "federal" # test next button and validate data self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # should see results in db application = DomainApplication.objects.get() # there's only one self.assertEqual(application.organization_type, "federal") @@ -197,7 +200,7 @@ class DomainApplicationTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_page = type_result.follow() - federal_form = federal_page.form + federal_form = federal_page.forms[0] federal_form["organization_federal-federal_type"] = "executive" # test next button @@ -216,7 +219,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() - org_contact_form = org_contact_page.form + org_contact_form = org_contact_page.forms[0] # federal agency so we have to fill in federal_agency org_contact_form["organization_contact-federal_agency"] = "General Services Administration" org_contact_form["organization_contact-organization_name"] = "Testorg" @@ -249,7 +252,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) ao_page = org_contact_result.follow() - ao_form = ao_page.form + ao_form = ao_page.forms[0] ao_form["authorizing_official-first_name"] = "Testy ATO" ao_form["authorizing_official-last_name"] = "Tester ATO" ao_form["authorizing_official-title"] = "Chief Tester" @@ -276,7 +279,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) current_sites_page = ao_result.follow() - current_sites_form = current_sites_page.form + current_sites_form = current_sites_page.forms[0] current_sites_form["current_sites-0-website"] = "www.city.com" # test next button @@ -298,7 +301,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) dotgov_page = current_sites_result.follow() - dotgov_form = dotgov_page.form + dotgov_form = dotgov_page.forms[0] dotgov_form["dotgov_domain-requested_domain"] = "city" dotgov_form["dotgov_domain-0-alternative_domain"] = "city1" @@ -318,7 +321,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) purpose_page = dotgov_result.follow() - purpose_form = purpose_page.form + purpose_form = purpose_page.forms[0] purpose_form["purpose-purpose"] = "For all kinds of things." # test next button @@ -337,7 +340,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) your_contact_page = purpose_result.follow() - your_contact_form = your_contact_page.form + your_contact_form = your_contact_page.forms[0] your_contact_form["your_contact-first_name"] = "Testy you" your_contact_form["your_contact-last_name"] = "Tester you" @@ -365,7 +368,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) other_contacts_page = your_contact_result.follow() - other_contacts_form = other_contacts_page.form + other_contacts_form = other_contacts_page.forms[0] other_contacts_form["other_contacts-0-first_name"] = "Testy2" other_contacts_form["other_contacts-0-last_name"] = "Tester2" @@ -398,7 +401,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) anything_else_page = other_contacts_result.follow() - anything_else_form = anything_else_page.form + anything_else_form = anything_else_page.forms[0] anything_else_form["anything_else-anything_else"] = "Nothing else." @@ -418,7 +421,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) requirements_page = anything_else_result.follow() - requirements_form = requirements_page.form + requirements_form = requirements_page.forms[0] requirements_form["requirements-is_policy_acknowledged"] = True @@ -438,7 +441,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) review_page = requirements_result.follow() - review_form = review_page.form + review_form = review_page.forms[0] # Review page contains all the previously entered data # Let's make sure the long org name is displayed @@ -472,6 +475,14 @@ class DomainApplicationTests(TestWithUser, WebTest): self.assertContains(review_page, "(201) 555-5557") self.assertContains(review_page, "Nothing else.") + # We can't test the modal itself as it relies on JS for init and triggering, + # but we can test for the existence of its trigger: + self.assertContains(review_page, "toggle-submit-domain-request") + # And the existence of the modal's data parked and ready for the js init. + # The next assert also tests for the passed requested domain context from + # the view > application_form > modal + self.assertContains(review_page, "You are about to submit a domain request for city.gov") + # final submission results in a redirect to the "finished" URL self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) with less_console_noise(): @@ -540,7 +551,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # the conditional step titles shouldn't appear initially self.assertNotContains(type_page, self.TITLES["organization_federal"]) self.assertNotContains(type_page, self.TITLES["organization_election"]) - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "federal" # set the session ID before .submit() @@ -561,9 +572,9 @@ class DomainApplicationTests(TestWithUser, WebTest): # continuing on in the flow we need to see top-level agency on the # contact page - federal_page.form["organization_federal-federal_type"] = "executive" + federal_page.forms[0]["organization_federal-federal_type"] = "executive" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - federal_result = federal_page.form.submit() + federal_result = federal_page.forms[0].submit() # the post request should return a redirect to the contact # question self.assertEqual(federal_result.status_code, 302) @@ -586,7 +597,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # the conditional step titles shouldn't appear initially self.assertNotContains(type_page, self.TITLES["organization_federal"]) self.assertNotContains(type_page, self.TITLES["organization_election"]) - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "county" # set the session ID before .submit() @@ -606,9 +617,9 @@ class DomainApplicationTests(TestWithUser, WebTest): # continuing on in the flow we need to NOT see top-level agency on the # contact page - election_page.form["organization_election-is_election_board"] = "True" + election_page.forms[0]["organization_election-is_election_board"] = "True" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - election_result = election_page.form.submit() + election_result = election_page.forms[0].submit() # the post request should return a redirect to the contact # question self.assertEqual(election_result.status_code, 302) @@ -626,10 +637,10 @@ class DomainApplicationTests(TestWithUser, WebTest): # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "federal" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # follow first redirect self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) @@ -654,15 +665,15 @@ class DomainApplicationTests(TestWithUser, WebTest): # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = DomainApplication.OrganizationChoices.INTERSTATE self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # follow first redirect self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) contact_page = type_result.follow() - org_contact_form = contact_page.form + org_contact_form = contact_page.forms[0] self.assertNotIn("federal_agency", org_contact_form.fields) @@ -690,10 +701,10 @@ class DomainApplicationTests(TestWithUser, WebTest): # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = DomainApplication.OrganizationChoices.SPECIAL_DISTRICT self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_page.forms[0].submit() # follow first redirect self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) contact_page = type_result.follow() @@ -710,7 +721,7 @@ class DomainApplicationTests(TestWithUser, WebTest): session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - result = contacts_page.form.submit() + result = contacts_page.forms[0].submit() # follow first redirect self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) no_contacts_page = result.follow() @@ -727,10 +738,10 @@ class DomainApplicationTests(TestWithUser, WebTest): # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = DomainApplication.OrganizationChoices.INTERSTATE self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # follow first redirect self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) contact_page = type_result.follow() @@ -745,10 +756,10 @@ class DomainApplicationTests(TestWithUser, WebTest): # of a "session". We are going to do it manually, saving the session ID here # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = DomainApplication.OrganizationChoices.TRIBAL self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # the tribal government page comes immediately afterwards self.assertIn("/tribal_government", type_result.headers["Location"]) # follow first redirect @@ -767,18 +778,18 @@ class DomainApplicationTests(TestWithUser, WebTest): session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] # ---- TYPE PAGE ---- - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "federal" # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # ---- FEDERAL BRANCH PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_page = type_result.follow() - federal_form = federal_page.form + federal_form = federal_page.forms[0] federal_form["organization_federal-federal_type"] = "executive" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_result = federal_form.submit() @@ -787,7 +798,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() - org_contact_form = org_contact_page.form + org_contact_form = org_contact_page.forms[0] # federal agency so we have to fill in federal_agency org_contact_form["organization_contact-federal_agency"] = "General Services Administration" org_contact_form["organization_contact-organization_name"] = "Testorg" @@ -828,18 +839,18 @@ class DomainApplicationTests(TestWithUser, WebTest): # and then setting the cookie on each request. session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] # ---- TYPE PAGE ---- - type_form = type_page.form + type_form = type_page.forms[0] type_form["organization_type-organization_type"] = "federal" # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_result = type_page.form.submit() + type_result = type_form.submit() # ---- FEDERAL BRANCH PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_page = type_result.follow() - federal_form = federal_page.form + federal_form = federal_page.forms[0] federal_form["organization_federal-federal_type"] = "executive" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_result = federal_form.submit() @@ -848,7 +859,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() - org_contact_form = org_contact_page.form + org_contact_form = org_contact_page.forms[0] # federal agency so we have to fill in federal_agency org_contact_form["organization_contact-federal_agency"] = "General Services Administration" org_contact_form["organization_contact-organization_name"] = "Testorg" @@ -870,7 +881,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) ao_page = org_contact_result.follow() - ao_form = ao_page.form + ao_form = ao_page.forms[0] ao_form["authorizing_official-first_name"] = "Testy ATO" ao_form["authorizing_official-last_name"] = "Tester ATO" ao_form["authorizing_official-title"] = "Chief Tester" @@ -884,7 +895,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) current_sites_page = ao_result.follow() - current_sites_form = current_sites_page.form + current_sites_form = current_sites_page.forms[0] current_sites_form["current_sites-0-website"] = "www.city.com" # test saving the page @@ -917,7 +928,7 @@ class DomainApplicationTests(TestWithUser, WebTest): current_sites_page = self.app.get(reverse("application:current_sites")) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] # fill in the form field - current_sites_form = current_sites_page.form + current_sites_form = current_sites_page.forms[0] self.assertIn("current_sites-0-website", current_sites_form.fields) self.assertNotIn("current_sites-1-website", current_sites_form.fields) current_sites_form["current_sites-0-website"] = "https://example.com" @@ -926,7 +937,7 @@ class DomainApplicationTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) current_sites_result = current_sites_form.submit("submit_button", value="save") self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - current_sites_form = current_sites_result.follow().form + current_sites_form = current_sites_result.follow().forms[0] # verify that there are two form fields value = current_sites_form["current_sites-0-website"].value @@ -1086,6 +1097,18 @@ class DomainApplicationTests(TestWithUser, WebTest): detail_page = home_page.click("Manage", index=0) self.assertContains(detail_page, "Federal: an agency of the U.S. government") + def test_submit_modal_no_domain_text_fallback(self): + """When user clicks on submit your domain request and the requested domain + is null (possible through url direct access to the review page), present + fallback copy in the modal's header. + + NOTE: This may be a moot point if we implement a more solid pattern in the + future, like not a submit action at all on the review page.""" + + review_page = self.app.get(reverse("application:review")) + self.assertContains(review_page, "toggle-submit-domain-request") + self.assertContains(review_page, "You are about to submit an incomplete request") + class TestWithDomainPermissions(TestWithUser): def setUp(self): @@ -1355,29 +1378,55 @@ class TestDomainManagers(TestDomainOverview): out the boto3 SES email sending here. """ # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_result = add_page.form.submit() self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() - self.assertContains(success_page, EMAIL) + self.assertContains(success_page, email_address) self.assertContains(success_page, "Cancel") # link to cancel invitation - self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) + + @boto3_mocking.patching + def test_domain_invitation_created_for_caps_email(self): + """Add user on a nonexistent email with CAPS creates an invitation to lowercase email. + + Adding a non-existent user sends an email as a side-effect, so mock + out the boto3 SES email sending here. + """ + # make sure there is no user with this email + email_address = "mayor@igorville.gov" + caps_email_address = "MAYOR@igorville.gov" + User.objects.filter(email=email_address).delete() + + self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) + + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = caps_email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result = add_page.form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + + self.assertContains(success_page, email_address) + self.assertContains(success_page, "Cancel") # link to cancel invitation + self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) @boto3_mocking.patching def test_domain_invitation_email_sent(self): """Inviting a non-existent user sends them an email.""" # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) @@ -1386,28 +1435,28 @@ class TestDomainManagers(TestDomainOverview): with boto3_mocking.clients.handler_for("sesv2", mock_client): add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( FromEmailAddress=settings.DEFAULT_FROM_EMAIL, - Destination={"ToAddresses": [EMAIL]}, + Destination={"ToAddresses": [email_address]}, Content=ANY, ) def test_domain_invitation_cancel(self): """Posting to the delete view deletes an invitation.""" - EMAIL = "mayor@igorville.gov" - invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL) + email_address = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) with self.assertRaises(DomainInvitation.DoesNotExist): DomainInvitation.objects.get(id=invitation.id) def test_domain_invitation_cancel_no_permissions(self): """Posting to the delete view as a different user should fail.""" - EMAIL = "mayor@igorville.gov" - invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL) + email_address = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) other_user = User() other_user.save() @@ -1419,20 +1468,20 @@ class TestDomainManagers(TestDomainOverview): @boto3_mocking.patching def test_domain_invitation_flow(self): """Send an invitation to a new user, log in and load the dashboard.""" - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + email_address = "mayor@igorville.gov" + User.objects.filter(email=email_address).delete() add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = EMAIL + add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) add_page.form.submit() # user was invited, create them - new_user = User.objects.create(username=EMAIL, email=EMAIL) + new_user = User.objects.create(username=email_address, email=email_address) # log them in to `self.app` self.app.set_user(new_user.username) # and manually call the on each login callback diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py index bb1b3aee6..0a6eb5b7b 100644 --- a/src/registrar/views/application.py +++ b/src/registrar/views/application.py @@ -321,12 +321,21 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView): def get_context_data(self): """Define context for access on all wizard pages.""" + # Build the submit button that we'll pass to the modal. + modal_button = '" + # Concatenate the modal header that we'll pass to the modal. + if self.application.requested_domain: + modal_heading = "You are about to submit a domain request for " + str(self.application.requested_domain) + else: + modal_heading = "You are about to submit an incomplete request" return { "form_titles": self.TITLES, "steps": self.steps, # Add information about which steps should be unlocked "visited": self.storage.get("step_history", []), "is_federal": self.application.is_federal(), + "modal_button": modal_button, + "modal_heading": modal_heading, } def get_step_list(self) -> list:
Your domain applicationsYour domain requests
Domain name