diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 50801b79b..b321b9107 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -16,6 +16,7 @@ from dateutil.relativedelta import relativedelta # type: ignore from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website from registrar.utility import csv_export +from registrar.utility.errors import FSMApplicationError, FSMErrorCodes from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR from registrar.widgets import NoAutocompleteFilteredSelectMultiple @@ -92,9 +93,14 @@ class DomainRequestAdminForm(forms.ModelForm): # first option in status transitions is current state available_transitions = [(current_state, domain_request.get_status_display())] - transitions = get_available_FIELD_transitions( - domain_request, models.DomainRequest._meta.get_field("status") - ) + if domain_request.investigator is not None: + transitions = get_available_FIELD_transitions( + domain_request, models.DomainRequest._meta.get_field("status") + ) + else: + transitions = self.get_custom_field_transitions( + domain_request, models.DomainRequest._meta.get_field("status") + ) for transition in transitions: available_transitions.append((transition.target, transition.target.label)) @@ -105,6 +111,73 @@ class DomainRequestAdminForm(forms.ModelForm): if not domain_request.creator.is_restricted(): self.fields["status"].widget.choices = available_transitions + def get_custom_field_transitions(self, instance, field): + """Custom implementation of get_available_FIELD_transitions + in the FSM. Allows us to still display fields filtered out by a condition.""" + curr_state = field.get_state(instance) + transitions = field.transitions[instance.__class__] + + for name, transition in transitions.items(): + meta = transition._django_fsm + if meta.has_transition(curr_state): + yield meta.get_transition(curr_state) + + def clean(self): + """ + Override of the default clean on the form. + This is so we can inject custom form-level error messages. + """ + # clean is called from clean_forms, which is called from is_valid + # after clean_fields. it is used to determine form level errors. + # is_valid is typically called from view during a post + cleaned_data = super().clean() + status = cleaned_data.get("status") + investigator = cleaned_data.get("investigator") + + # Get the old status + initial_status = self.initial.get("status", None) + + # We only care about investigator when in these statuses + checked_statuses = [ + DomainRequest.DomainRequestStatus.APPROVED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.DomainRequestStatus.INELIGIBLE, + ] + + # If a status change occured, check for validity + if status != initial_status and status in checked_statuses: + # Checks the "investigators" field for validity. + # That field must obey certain conditions when an domain request is approved. + # Will call "add_error" if any issues are found. + self._check_for_valid_investigator(investigator) + + return cleaned_data + + def _check_for_valid_investigator(self, investigator) -> bool: + """ + Checks if the investigator field is not none, and is staff. + Adds form errors on failure. + """ + + is_valid = False + + # Check if an investigator is assigned. No approval is possible without one. + error_message = None + if investigator is None: + # Lets grab the error message from a common location + error_message = FSMApplicationError.get_error_message(FSMErrorCodes.NO_INVESTIGATOR) + elif not investigator.is_staff: + error_message = FSMApplicationError.get_error_message(FSMErrorCodes.INVESTIGATOR_NOT_STAFF) + else: + is_valid = True + + if error_message is not None: + self.add_error("investigator", error_message) + + return is_valid + # Based off of this excellent example: https://djangosnippets.org/snippets/10471/ class MultiFieldSortableChangeList(admin.views.main.ChangeList): @@ -1054,72 +1127,24 @@ class DomainRequestAdmin(ListHeaderAdmin): # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): - if obj and obj.creator.status != models.User.RESTRICTED: - if change: # Check if the domain request is being edited - # Get the original domain request from the database - original_obj = models.DomainRequest.objects.get(pk=obj.pk) + """Custom save_model definition that handles edge cases""" - if ( - obj - and original_obj.status == models.DomainRequest.DomainRequestStatus.APPROVED - and obj.status != models.DomainRequest.DomainRequestStatus.APPROVED - and not obj.domain_is_not_active() - ): - # If an admin tried to set an approved domain request to - # another status and the related domain is already - # active, shortcut the action and throw a friendly - # error message. This action would still not go through - # shortcut or not as the rules are duplicated on the model, - # but the error would be an ugly Django error screen. + # == Check that the obj is in a valid state == # - # Clear the success message - messages.set_level(request, messages.ERROR) + # If obj is none, something went very wrong. + # The form should have blocked this, so lets forbid it. + if not obj: + logger.error(f"Invalid value for obj ({obj})") + messages.set_level(request, messages.ERROR) + messages.error( + request, + "Could not save DomainRequest. Something went wrong.", + ) + return None - messages.error( - request, - "This action is not permitted. The domain is already active.", - ) - - elif ( - obj and obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason - ): - # This condition should never be triggered. - # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) - # because we clean up the rejection reason in the transition in the model. - - # Clear the success message - messages.set_level(request, messages.ERROR) - - messages.error( - request, - "A rejection reason is required.", - ) - - else: - if obj.status != original_obj.status: - status_method_mapping = { - models.DomainRequest.DomainRequestStatus.STARTED: None, - models.DomainRequest.DomainRequestStatus.SUBMITTED: obj.submit, - models.DomainRequest.DomainRequestStatus.IN_REVIEW: obj.in_review, - models.DomainRequest.DomainRequestStatus.ACTION_NEEDED: obj.action_needed, - models.DomainRequest.DomainRequestStatus.APPROVED: obj.approve, - models.DomainRequest.DomainRequestStatus.WITHDRAWN: obj.withdraw, - models.DomainRequest.DomainRequestStatus.REJECTED: obj.reject, - models.DomainRequest.DomainRequestStatus.INELIGIBLE: (obj.reject_with_prejudice), - } - selected_method = status_method_mapping.get(obj.status) - if selected_method is None: - logger.warning("Unknown status selected in django admin") - else: - # This is an fsm in model which will throw an error if the - # transition condition is violated, so we roll back the - # status to what it was before the admin user changed it and - # let the fsm method set it. - obj.status = original_obj.status - selected_method() - - super().save_model(request, obj, form, change) - else: + # If the user is restricted or we're saving an invalid model, + # forbid this action. + if not obj or obj.creator.status == models.User.RESTRICTED: # Clear the success message messages.set_level(request, messages.ERROR) @@ -1128,6 +1153,117 @@ class DomainRequestAdmin(ListHeaderAdmin): "This action is not permitted for domain requests with a restricted creator.", ) + return None + + # == Check if we're making a change or not == # + + # If we're not making a change (adding a record), run save model as we do normally + if not change: + return super().save_model(request, obj, form, change) + + # == Handle non-status changes == # + + # Get the original domain request from the database. + original_obj = models.DomainRequest.objects.get(pk=obj.pk) + if obj.status == original_obj.status: + # If the status hasn't changed, let the base function take care of it + return super().save_model(request, obj, form, change) + + # == Handle status changes == # + + # Run some checks on the current object for invalid status changes + obj, should_save = self._handle_status_change(request, obj, original_obj) + + # We should only save if we don't display any errors in the step above. + if should_save: + return super().save_model(request, obj, form, change) + + def _handle_status_change(self, request, obj, original_obj): + """ + Checks for various conditions when a status change is triggered. + In the event that it is valid, the status will be mapped to + the appropriate method. + + In the event that we should not status change, an error message + will be displayed. + + Returns a tuple: (obj: DomainRequest, should_proceed: bool) + """ + + should_proceed = True + error_message = None + + # Get the method that should be run given the status + selected_method = self.get_status_method_mapping(obj) + if selected_method is None: + logger.warning("Unknown status selected in django admin") + + # If the status is not mapped properly, saving could cause + # weird issues down the line. Instead, we should block this. + should_proceed = False + return should_proceed + + request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + if request_is_not_approved and not obj.domain_is_not_active(): + # If an admin tried to set an approved domain request to + # another status and the related domain is already + # active, shortcut the action and throw a friendly + # error message. This action would still not go through + # shortcut or not as the rules are duplicated on the model, + # but the error would be an ugly Django error screen. + error_message = "This action is not permitted. The domain is already active." + elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: + # This condition should never be triggered. + # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) + # because we clean up the rejection reason in the transition in the model. + error_message = "A rejection reason is required." + else: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we roll back the + # status to what it was before the admin user changed it and + # let the fsm method set it. + obj.status = original_obj.status + + # Try to perform the status change. + # Catch FSMApplicationError's and return the message, + # as these are typically user errors. + try: + selected_method() + except FSMApplicationError as err: + logger.warning(f"An error encountered when trying to change status: {err}") + error_message = err.message + + if error_message is not None: + # Clear the success message + messages.set_level(request, messages.ERROR) + # Display the error + messages.error( + request, + error_message, + ) + + # If an error message exists, we shouldn't proceed + should_proceed = False + + return (obj, should_proceed) + + def get_status_method_mapping(self, domain_request): + """Returns what method should be ran given an domain request object""" + # Define a per-object mapping + status_method_mapping = { + models.DomainRequest.DomainRequestStatus.STARTED: None, + models.DomainRequest.DomainRequestStatus.SUBMITTED: domain_request.submit, + models.DomainRequest.DomainRequestStatus.IN_REVIEW: domain_request.in_review, + models.DomainRequest.DomainRequestStatus.ACTION_NEEDED: domain_request.action_needed, + models.DomainRequest.DomainRequestStatus.APPROVED: domain_request.approve, + models.DomainRequest.DomainRequestStatus.WITHDRAWN: domain_request.withdraw, + models.DomainRequest.DomainRequestStatus.REJECTED: domain_request.reject, + models.DomainRequest.DomainRequestStatus.INELIGIBLE: (domain_request.reject_with_prejudice), + } + + # Grab the method + return status_method_mapping.get(domain_request.status, None) + def get_readonly_fields(self, request, obj=None): """Set the read-only state on form elements. We have 2 conditions that determine which fields are read-only: diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 9c0d7517c..dc67bc8b6 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -277,11 +277,6 @@ h1, h2, h3, } } -// Hides the "clear" button on autocomplete, as we already have one to use -.select2-selection__clear { - display: none; -} - // Fixes a display issue where the list was entirely white, or had too much whitespace .select2-dropdown { display: inline-grid !important; diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py index a78bdd1ab..a37e29d6b 100644 --- a/src/registrar/fixtures_domain_requests.py +++ b/src/registrar/fixtures_domain_requests.py @@ -1,6 +1,7 @@ import logging import random from faker import Faker +from django.db import transaction from registrar.models import ( User, @@ -184,6 +185,14 @@ class DomainRequestFixture: logger.warning(e) return + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + cls._create_domain_requests(users) + + @classmethod + def _create_domain_requests(cls, users): + """Creates DomainRequests given a list of users""" for user in users: logger.debug("Loading domain requests for %s" % user) for app in cls.DA: @@ -211,8 +220,16 @@ class DomainFixture(DomainRequestFixture): logger.warning(e) return + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + with transaction.atomic(): + # approve each user associated with `in review` status domains + DomainFixture._approve_domain_requests(users) + + @staticmethod + def _approve_domain_requests(users): + """Approves all provided domain requests if they are in the state in_review""" for user in users: - # approve one of each users in review status domains domain_request = DomainRequest.objects.filter( creator=user, status=DomainRequest.DomainRequestStatus.IN_REVIEW ).last() @@ -220,5 +237,13 @@ class DomainFixture(DomainRequestFixture): # We don't want fixtures sending out real emails to # fake email addresses, so we just skip that and log it instead + + # All approvals require an investigator, so if there is none, + # assign one. + if domain_request.investigator is None: + # All "users" in fixtures have admin perms per prior config. + # No need to check for that. + domain_request.investigator = random.choice(users) # nosec + domain_request.approve(send_email=False) domain_request.save() diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index f03fb025d..e89809484 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -1,5 +1,6 @@ import logging from faker import Faker +from django.db import transaction from registrar.models import ( User, @@ -186,5 +187,12 @@ class UserFixture: @classmethod def load(cls): - cls.load_users(cls, cls.ADMINS, "full_access_group") - cls.load_users(cls, cls.STAFF, "cisa_analysts_group") + # Lumped under .atomic to ensure we don't make redundant DB calls. + # This bundles them all together, and then saves it in a single call. + # This is slightly different then bulk_create or bulk_update, in that + # you still get the same behaviour of .save(), but those incremental + # steps now do not need to close/reopen a db connection, + # instead they share one. + with transaction.atomic(): + cls.load_users(cls, cls.ADMINS, "full_access_group") + cls.load_users(cls, cls.STAFF, "cisa_analysts_group") diff --git a/src/registrar/migrations/0073_alter_domainapplication_approved_domain_and_more.py b/src/registrar/migrations/0073_alter_domainapplication_approved_domain_and_more.py new file mode 100644 index 000000000..1eacf0e56 --- /dev/null +++ b/src/registrar/migrations/0073_alter_domainapplication_approved_domain_and_more.py @@ -0,0 +1,110 @@ +# Generated by Django 4.2.10 on 2024-03-12 16:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0072_alter_publiccontact_fax_alter_publiccontact_voice"), + ] + + operations = [ + migrations.AlterField( + model_name="domainapplication", + name="approved_domain", + field=models.OneToOneField( + blank=True, + help_text="The approved domain", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="domain_request", + to="registrar.domain", + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="creator", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_requests_created", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="investigator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="domain_requests_investigating", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="other_contacts", + field=models.ManyToManyField( + blank=True, related_name="contact_domain_requests", to="registrar.contact", verbose_name="contacts" + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="requested_domain", + field=models.OneToOneField( + blank=True, + help_text="The requested domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_request", + to="registrar.draftdomain", + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="submitter", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submitted_domain_requests", + to="registrar.contact", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="domain_application", + field=models.OneToOneField( + blank=True, + help_text="Associated domain request", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_info", + to="registrar.domainapplication", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="other_contacts", + field=models.ManyToManyField( + blank=True, + related_name="contact_domain_requests_information", + to="registrar.contact", + verbose_name="contacts", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="submitter", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submitted_domain_requests_information", + to="registrar.contact", + ), + ), + ] diff --git a/src/registrar/migrations/0073_domainrequest_and_more.py b/src/registrar/migrations/0073_domainrequest_and_more.py deleted file mode 100644 index 88608b03a..000000000 --- a/src/registrar/migrations/0073_domainrequest_and_more.py +++ /dev/null @@ -1,685 +0,0 @@ -# Generated by Django 4.2.10 on 2024-03-07 21:52 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django_fsm - - -class Migration(migrations.Migration): - - dependencies = [ - ("registrar", "0072_alter_publiccontact_fax_alter_publiccontact_voice"), - ] - - operations = [ - migrations.CreateModel( - name="DomainRequest", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "status", - django_fsm.FSMField( - choices=[ - ("started", "Started"), - ("submitted", "Submitted"), - ("in review", "In review"), - ("action needed", "Action needed"), - ("approved", "Approved"), - ("withdrawn", "Withdrawn"), - ("rejected", "Rejected"), - ("ineligible", "Ineligible"), - ], - default="started", - max_length=50, - ), - ), - ( - "rejection_reason", - models.TextField( - blank=True, - choices=[ - ("purpose_not_met", "Purpose requirements not met"), - ("requestor_not_eligible", "Requestor not eligible to make request"), - ("org_has_domain", "Org already has a .gov domain"), - ("contacts_not_verified", "Org contacts couldn't be verified"), - ("org_not_eligible", "Org not eligible for a .gov domain"), - ("naming_not_met", "Naming requirements not met"), - ("other", "Other/Unspecified"), - ], - null=True, - ), - ), - ( - "organization_type", - models.CharField( - blank=True, - choices=[ - ("federal", "Federal"), - ("interstate", "Interstate"), - ("state_or_territory", "State or territory"), - ("tribal", "Tribal"), - ("county", "County"), - ("city", "City"), - ("special_district", "Special district"), - ("school_district", "School district"), - ], - help_text="Type of organization", - max_length=255, - null=True, - ), - ), - ( - "federally_recognized_tribe", - models.BooleanField(help_text="Is the tribe federally recognized", null=True), - ), - ( - "state_recognized_tribe", - models.BooleanField(help_text="Is the tribe recognized by a state", null=True), - ), - ("tribe_name", models.CharField(blank=True, help_text="Name of tribe", null=True)), - ( - "federal_agency", - models.CharField( - blank=True, - choices=[ - ( - "Administrative Conference of the United States", - "Administrative Conference of the United States", - ), - ("Advisory Council on Historic Preservation", "Advisory Council on Historic Preservation"), - ("American Battle Monuments Commission", "American Battle Monuments Commission"), - ("AMTRAK", "AMTRAK"), - ("Appalachian Regional Commission", "Appalachian Regional Commission"), - ( - "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", - "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", - ), - ("Appraisal Subcommittee", "Appraisal Subcommittee"), - ("Architect of the Capitol", "Architect of the Capitol"), - ("Armed Forces Retirement Home", "Armed Forces Retirement Home"), - ( - "Barry Goldwater Scholarship and Excellence in Education Foundation", - "Barry Goldwater Scholarship and Excellence in Education Foundation", - ), - ( - "Barry Goldwater Scholarship and Excellence in Education Program", - "Barry Goldwater Scholarship and Excellence in Education Program", - ), - ("Central Intelligence Agency", "Central Intelligence Agency"), - ("Chemical Safety Board", "Chemical Safety Board"), - ( - "Christopher Columbus Fellowship Foundation", - "Christopher Columbus Fellowship Foundation", - ), - ( - "Civil Rights Cold Case Records Review Board", - "Civil Rights Cold Case Records Review Board", - ), - ( - "Commission for the Preservation of America's Heritage Abroad", - "Commission for the Preservation of America's Heritage Abroad", - ), - ("Commission of Fine Arts", "Commission of Fine Arts"), - ( - "Committee for Purchase From People Who Are Blind or Severely Disabled", - "Committee for Purchase From People Who Are Blind or Severely Disabled", - ), - ("Commodity Futures Trading Commission", "Commodity Futures Trading Commission"), - ("Congressional Budget Office", "Congressional Budget Office"), - ("Consumer Financial Protection Bureau", "Consumer Financial Protection Bureau"), - ("Consumer Product Safety Commission", "Consumer Product Safety Commission"), - ( - "Corporation for National & Community Service", - "Corporation for National & Community Service", - ), - ( - "Corporation for National and Community Service", - "Corporation for National and Community Service", - ), - ( - "Council of Inspectors General on Integrity and Efficiency", - "Council of Inspectors General on Integrity and Efficiency", - ), - ("Court Services and Offender Supervision", "Court Services and Offender Supervision"), - ("Cyberspace Solarium Commission", "Cyberspace Solarium Commission"), - ( - "DC Court Services and Offender Supervision Agency", - "DC Court Services and Offender Supervision Agency", - ), - ("DC Pre-trial Services", "DC Pre-trial Services"), - ("Defense Nuclear Facilities Safety Board", "Defense Nuclear Facilities Safety Board"), - ("Delta Regional Authority", "Delta Regional Authority"), - ("Denali Commission", "Denali Commission"), - ("Department of Agriculture", "Department of Agriculture"), - ("Department of Commerce", "Department of Commerce"), - ("Department of Defense", "Department of Defense"), - ("Department of Education", "Department of Education"), - ("Department of Energy", "Department of Energy"), - ("Department of Health and Human Services", "Department of Health and Human Services"), - ("Department of Homeland Security", "Department of Homeland Security"), - ( - "Department of Housing and Urban Development", - "Department of Housing and Urban Development", - ), - ("Department of Justice", "Department of Justice"), - ("Department of Labor", "Department of Labor"), - ("Department of State", "Department of State"), - ("Department of the Interior", "Department of the Interior"), - ("Department of the Treasury", "Department of the Treasury"), - ("Department of Transportation", "Department of Transportation"), - ("Department of Veterans Affairs", "Department of Veterans Affairs"), - ("Director of National Intelligence", "Director of National Intelligence"), - ("Dwight D. Eisenhower Memorial Commission", "Dwight D. Eisenhower Memorial Commission"), - ("Election Assistance Commission", "Election Assistance Commission"), - ("Environmental Protection Agency", "Environmental Protection Agency"), - ("Equal Employment Opportunity Commission", "Equal Employment Opportunity Commission"), - ("Executive Office of the President", "Executive Office of the President"), - ("Export-Import Bank of the United States", "Export-Import Bank of the United States"), - ("Export/Import Bank of the U.S.", "Export/Import Bank of the U.S."), - ("Farm Credit Administration", "Farm Credit Administration"), - ("Farm Credit System Insurance Corporation", "Farm Credit System Insurance Corporation"), - ("Federal Communications Commission", "Federal Communications Commission"), - ("Federal Deposit Insurance Corporation", "Federal Deposit Insurance Corporation"), - ("Federal Election Commission", "Federal Election Commission"), - ("Federal Energy Regulatory Commission", "Federal Energy Regulatory Commission"), - ( - "Federal Financial Institutions Examination Council", - "Federal Financial Institutions Examination Council", - ), - ("Federal Housing Finance Agency", "Federal Housing Finance Agency"), - ("Federal Judiciary", "Federal Judiciary"), - ("Federal Labor Relations Authority", "Federal Labor Relations Authority"), - ("Federal Maritime Commission", "Federal Maritime Commission"), - ( - "Federal Mediation and Conciliation Service", - "Federal Mediation and Conciliation Service", - ), - ( - "Federal Mine Safety and Health Review Commission", - "Federal Mine Safety and Health Review Commission", - ), - ( - "Federal Permitting Improvement Steering Council", - "Federal Permitting Improvement Steering Council", - ), - ("Federal Reserve Board of Governors", "Federal Reserve Board of Governors"), - ("Federal Reserve System", "Federal Reserve System"), - ("Federal Trade Commission", "Federal Trade Commission"), - ("General Services Administration", "General Services Administration"), - ("gov Administration", "gov Administration"), - ("Government Accountability Office", "Government Accountability Office"), - ("Government Publishing Office", "Government Publishing Office"), - ("Gulf Coast Ecosystem Restoration Council", "Gulf Coast Ecosystem Restoration Council"), - ("Harry S Truman Scholarship Foundation", "Harry S Truman Scholarship Foundation"), - ("Harry S. Truman Scholarship Foundation", "Harry S. Truman Scholarship Foundation"), - ("Institute of Museum and Library Services", "Institute of Museum and Library Services"), - ("Institute of Peace", "Institute of Peace"), - ("Inter-American Foundation", "Inter-American Foundation"), - ( - "International Boundary and Water Commission: United States and Mexico", - "International Boundary and Water Commission: United States and Mexico", - ), - ( - "International Boundary Commission: United States and Canada", - "International Boundary Commission: United States and Canada", - ), - ( - "International Joint Commission: United States and Canada", - "International Joint Commission: United States and Canada", - ), - ( - "James Madison Memorial Fellowship Foundation", - "James Madison Memorial Fellowship Foundation", - ), - ("Japan-United States Friendship Commission", "Japan-United States Friendship Commission"), - ("Japan-US Friendship Commission", "Japan-US Friendship Commission"), - ( - "John F. Kennedy Center for Performing Arts", - "John F. Kennedy Center for Performing Arts", - ), - ( - "John F. Kennedy Center for the Performing Arts", - "John F. Kennedy Center for the Performing Arts", - ), - ("Legal Services Corporation", "Legal Services Corporation"), - ("Legislative Branch", "Legislative Branch"), - ("Library of Congress", "Library of Congress"), - ("Marine Mammal Commission", "Marine Mammal Commission"), - ( - "Medicaid and CHIP Payment and Access Commission", - "Medicaid and CHIP Payment and Access Commission", - ), - ("Medical Payment Advisory Commission", "Medical Payment Advisory Commission"), - ("Medicare Payment Advisory Commission", "Medicare Payment Advisory Commission"), - ("Merit Systems Protection Board", "Merit Systems Protection Board"), - ("Millennium Challenge Corporation", "Millennium Challenge Corporation"), - ( - "Morris K. Udall and Stewart L. Udall Foundation", - "Morris K. Udall and Stewart L. Udall Foundation", - ), - ( - "National Aeronautics and Space Administration", - "National Aeronautics and Space Administration", - ), - ( - "National Archives and Records Administration", - "National Archives and Records Administration", - ), - ("National Capital Planning Commission", "National Capital Planning Commission"), - ("National Council on Disability", "National Council on Disability"), - ("National Credit Union Administration", "National Credit Union Administration"), - ("National Endowment for the Arts", "National Endowment for the Arts"), - ("National Endowment for the Humanities", "National Endowment for the Humanities"), - ( - "National Foundation on the Arts and the Humanities", - "National Foundation on the Arts and the Humanities", - ), - ("National Gallery of Art", "National Gallery of Art"), - ("National Indian Gaming Commission", "National Indian Gaming Commission"), - ("National Labor Relations Board", "National Labor Relations Board"), - ("National Mediation Board", "National Mediation Board"), - ("National Science Foundation", "National Science Foundation"), - ( - "National Security Commission on Artificial Intelligence", - "National Security Commission on Artificial Intelligence", - ), - ("National Transportation Safety Board", "National Transportation Safety Board"), - ( - "Networking Information Technology Research and Development", - "Networking Information Technology Research and Development", - ), - ("Non-Federal Agency", "Non-Federal Agency"), - ("Northern Border Regional Commission", "Northern Border Regional Commission"), - ("Nuclear Regulatory Commission", "Nuclear Regulatory Commission"), - ("Nuclear Safety Oversight Committee", "Nuclear Safety Oversight Committee"), - ("Nuclear Waste Technical Review Board", "Nuclear Waste Technical Review Board"), - ( - "Occupational Safety & Health Review Commission", - "Occupational Safety & Health Review Commission", - ), - ( - "Occupational Safety and Health Review Commission", - "Occupational Safety and Health Review Commission", - ), - ("Office of Compliance", "Office of Compliance"), - ("Office of Congressional Workplace Rights", "Office of Congressional Workplace Rights"), - ("Office of Government Ethics", "Office of Government Ethics"), - ( - "Office of Navajo and Hopi Indian Relocation", - "Office of Navajo and Hopi Indian Relocation", - ), - ("Office of Personnel Management", "Office of Personnel Management"), - ("Open World Leadership Center", "Open World Leadership Center"), - ("Overseas Private Investment Corporation", "Overseas Private Investment Corporation"), - ("Peace Corps", "Peace Corps"), - ("Pension Benefit Guaranty Corporation", "Pension Benefit Guaranty Corporation"), - ("Postal Regulatory Commission", "Postal Regulatory Commission"), - ("Presidio Trust", "Presidio Trust"), - ( - "Privacy and Civil Liberties Oversight Board", - "Privacy and Civil Liberties Oversight Board", - ), - ("Public Buildings Reform Board", "Public Buildings Reform Board"), - ( - "Public Defender Service for the District of Columbia", - "Public Defender Service for the District of Columbia", - ), - ("Railroad Retirement Board", "Railroad Retirement Board"), - ("Securities and Exchange Commission", "Securities and Exchange Commission"), - ("Selective Service System", "Selective Service System"), - ("Small Business Administration", "Small Business Administration"), - ("Smithsonian Institution", "Smithsonian Institution"), - ("Social Security Administration", "Social Security Administration"), - ("Social Security Advisory Board", "Social Security Advisory Board"), - ("Southeast Crescent Regional Commission", "Southeast Crescent Regional Commission"), - ("Southwest Border Regional Commission", "Southwest Border Regional Commission"), - ("State Justice Institute", "State Justice Institute"), - ("State, Local, and Tribal Government", "State, Local, and Tribal Government"), - ("Stennis Center for Public Service", "Stennis Center for Public Service"), - ("Surface Transportation Board", "Surface Transportation Board"), - ("Tennessee Valley Authority", "Tennessee Valley Authority"), - ("The Executive Office of the President", "The Executive Office of the President"), - ("The Intelligence Community", "The Intelligence Community"), - ("The Legislative Branch", "The Legislative Branch"), - ("The Supreme Court", "The Supreme Court"), - ( - "The United States World War One Centennial Commission", - "The United States World War One Centennial Commission", - ), - ("U.S. Access Board", "U.S. Access Board"), - ("U.S. Agency for Global Media", "U.S. Agency for Global Media"), - ("U.S. Agency for International Development", "U.S. Agency for International Development"), - ("U.S. Capitol Police", "U.S. Capitol Police"), - ("U.S. Chemical Safety Board", "U.S. Chemical Safety Board"), - ( - "U.S. China Economic and Security Review Commission", - "U.S. China Economic and Security Review Commission", - ), - ( - "U.S. Commission for the Preservation of Americas Heritage Abroad", - "U.S. Commission for the Preservation of Americas Heritage Abroad", - ), - ("U.S. Commission of Fine Arts", "U.S. Commission of Fine Arts"), - ("U.S. Commission on Civil Rights", "U.S. Commission on Civil Rights"), - ( - "U.S. Commission on International Religious Freedom", - "U.S. Commission on International Religious Freedom", - ), - ("U.S. Courts", "U.S. Courts"), - ("U.S. Department of Agriculture", "U.S. Department of Agriculture"), - ("U.S. Interagency Council on Homelessness", "U.S. Interagency Council on Homelessness"), - ("U.S. International Trade Commission", "U.S. International Trade Commission"), - ("U.S. Nuclear Waste Technical Review Board", "U.S. Nuclear Waste Technical Review Board"), - ("U.S. Office of Special Counsel", "U.S. Office of Special Counsel"), - ("U.S. Peace Corps", "U.S. Peace Corps"), - ("U.S. Postal Service", "U.S. Postal Service"), - ("U.S. Semiquincentennial Commission", "U.S. Semiquincentennial Commission"), - ("U.S. Trade and Development Agency", "U.S. Trade and Development Agency"), - ( - "U.S.-China Economic and Security Review Commission", - "U.S.-China Economic and Security Review Commission", - ), - ("Udall Foundation", "Udall Foundation"), - ("United States AbilityOne", "United States AbilityOne"), - ("United States Access Board", "United States Access Board"), - ( - "United States African Development Foundation", - "United States African Development Foundation", - ), - ("United States Agency for Global Media", "United States Agency for Global Media"), - ("United States Arctic Research Commission", "United States Arctic Research Commission"), - ( - "United States Global Change Research Program", - "United States Global Change Research Program", - ), - ("United States Holocaust Memorial Museum", "United States Holocaust Memorial Museum"), - ("United States Institute of Peace", "United States Institute of Peace"), - ( - "United States Interagency Council on Homelessness", - "United States Interagency Council on Homelessness", - ), - ( - "United States International Development Finance Corporation", - "United States International Development Finance Corporation", - ), - ( - "United States International Trade Commission", - "United States International Trade Commission", - ), - ("United States Postal Service", "United States Postal Service"), - ("United States Senate", "United States Senate"), - ( - "United States Trade and Development Agency", - "United States Trade and Development Agency", - ), - ( - "Utah Reclamation Mitigation and Conservation Commission", - "Utah Reclamation Mitigation and Conservation Commission", - ), - ("Vietnam Education Foundation", "Vietnam Education Foundation"), - ("Western Hemisphere Drug Policy Commission", "Western Hemisphere Drug Policy Commission"), - ( - "Woodrow Wilson International Center for Scholars", - "Woodrow Wilson International Center for Scholars", - ), - ("World War I Centennial Commission", "World War I Centennial Commission"), - ], - help_text="Federal agency", - null=True, - ), - ), - ( - "federal_type", - models.CharField( - blank=True, - choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], - help_text="Federal government branch", - max_length=50, - null=True, - ), - ), - ( - "is_election_board", - models.BooleanField(blank=True, help_text="Is your organization an election office?", null=True), - ), - ( - "organization_name", - models.CharField(blank=True, db_index=True, help_text="Organization name", null=True), - ), - ( - "address_line1", - models.CharField(blank=True, help_text="Street address", null=True, verbose_name="Address line 1"), - ), - ( - "address_line2", - models.CharField( - blank=True, - help_text="Street address line 2 (optional)", - null=True, - verbose_name="Address line 2", - ), - ), - ("city", models.CharField(blank=True, help_text="City", null=True)), - ( - "state_territory", - models.CharField( - blank=True, - choices=[ - ("AL", "Alabama (AL)"), - ("AK", "Alaska (AK)"), - ("AS", "American Samoa (AS)"), - ("AZ", "Arizona (AZ)"), - ("AR", "Arkansas (AR)"), - ("CA", "California (CA)"), - ("CO", "Colorado (CO)"), - ("CT", "Connecticut (CT)"), - ("DE", "Delaware (DE)"), - ("DC", "District of Columbia (DC)"), - ("FL", "Florida (FL)"), - ("GA", "Georgia (GA)"), - ("GU", "Guam (GU)"), - ("HI", "Hawaii (HI)"), - ("ID", "Idaho (ID)"), - ("IL", "Illinois (IL)"), - ("IN", "Indiana (IN)"), - ("IA", "Iowa (IA)"), - ("KS", "Kansas (KS)"), - ("KY", "Kentucky (KY)"), - ("LA", "Louisiana (LA)"), - ("ME", "Maine (ME)"), - ("MD", "Maryland (MD)"), - ("MA", "Massachusetts (MA)"), - ("MI", "Michigan (MI)"), - ("MN", "Minnesota (MN)"), - ("MS", "Mississippi (MS)"), - ("MO", "Missouri (MO)"), - ("MT", "Montana (MT)"), - ("NE", "Nebraska (NE)"), - ("NV", "Nevada (NV)"), - ("NH", "New Hampshire (NH)"), - ("NJ", "New Jersey (NJ)"), - ("NM", "New Mexico (NM)"), - ("NY", "New York (NY)"), - ("NC", "North Carolina (NC)"), - ("ND", "North Dakota (ND)"), - ("MP", "Northern Mariana Islands (MP)"), - ("OH", "Ohio (OH)"), - ("OK", "Oklahoma (OK)"), - ("OR", "Oregon (OR)"), - ("PA", "Pennsylvania (PA)"), - ("PR", "Puerto Rico (PR)"), - ("RI", "Rhode Island (RI)"), - ("SC", "South Carolina (SC)"), - ("SD", "South Dakota (SD)"), - ("TN", "Tennessee (TN)"), - ("TX", "Texas (TX)"), - ("UM", "United States Minor Outlying Islands (UM)"), - ("UT", "Utah (UT)"), - ("VT", "Vermont (VT)"), - ("VI", "Virgin Islands (VI)"), - ("VA", "Virginia (VA)"), - ("WA", "Washington (WA)"), - ("WV", "West Virginia (WV)"), - ("WI", "Wisconsin (WI)"), - ("WY", "Wyoming (WY)"), - ("AA", "Armed Forces Americas (AA)"), - ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), - ("AP", "Armed Forces Pacific (AP)"), - ], - help_text="State, territory, or military post", - max_length=2, - null=True, - ), - ), - ( - "zipcode", - models.CharField(blank=True, db_index=True, help_text="Zip code", max_length=10, null=True), - ), - ( - "urbanization", - models.CharField(blank=True, help_text="Urbanization (required for Puerto Rico only)", null=True), - ), - ( - "about_your_organization", - models.TextField(blank=True, help_text="Information about your organization", null=True), - ), - ("purpose", models.TextField(blank=True, help_text="Purpose of your domain", null=True)), - ( - "no_other_contacts_rationale", - models.TextField(blank=True, help_text="Reason for listing no additional contacts", null=True), - ), - ("anything_else", models.TextField(blank=True, help_text="Anything else?", null=True)), - ( - "is_policy_acknowledged", - models.BooleanField(blank=True, help_text="Acknowledged .gov acceptable use policy", null=True), - ), - ("submission_date", models.DateField(blank=True, default=None, help_text="Date submitted", null=True)), - ("notes", models.TextField(blank=True, help_text="Notes about this request", null=True)), - ( - "alternative_domains", - models.ManyToManyField(blank=True, related_name="alternatives+", to="registrar.website"), - ), - ( - "approved_domain", - models.OneToOneField( - blank=True, - help_text="The approved domain", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="domain_request", - to="registrar.domain", - ), - ), - ( - "authorizing_official", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="authorizing_official", - to="registrar.contact", - ), - ), - ( - "creator", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="domain_requests_created", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "current_websites", - models.ManyToManyField( - blank=True, related_name="current+", to="registrar.website", verbose_name="websites" - ), - ), - ( - "investigator", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="domain_requests_investigating", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "other_contacts", - models.ManyToManyField( - blank=True, - related_name="contact_domain_requests", - to="registrar.contact", - verbose_name="contacts", - ), - ), - ( - "requested_domain", - models.OneToOneField( - blank=True, - help_text="The requested domain", - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="domain_request", - to="registrar.draftdomain", - ), - ), - ( - "submitter", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="submitted_domain_requests", - to="registrar.contact", - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.RemoveField( - model_name="domaininformation", - name="domain_application", - ), - migrations.AlterField( - model_name="domaininformation", - name="other_contacts", - field=models.ManyToManyField( - blank=True, - related_name="contact_domain_requests_information", - to="registrar.contact", - verbose_name="contacts", - ), - ), - migrations.AlterField( - model_name="domaininformation", - name="submitter", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="submitted_domain_requests_information", - to="registrar.contact", - ), - ), - migrations.DeleteModel( - name="DomainApplication", - ), - migrations.AddField( - model_name="domaininformation", - name="domain_request", - field=models.OneToOneField( - blank=True, - help_text="Associated domain request", - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="DomainRequest_info", - to="registrar.domainrequest", - ), - ), - ] diff --git a/src/registrar/migrations/0074_rename_domainapplication_domainrequest_and_more.py b/src/registrar/migrations/0074_rename_domainapplication_domainrequest_and_more.py new file mode 100644 index 000000000..2dae12340 --- /dev/null +++ b/src/registrar/migrations/0074_rename_domainapplication_domainrequest_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.10 on 2024-03-12 16:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0073_alter_domainapplication_approved_domain_and_more"), + ] + + operations = [ + migrations.RenameModel( + old_name="DomainApplication", + new_name="DomainRequest", + ), + migrations.RenameField( + model_name="domaininformation", + old_name="domain_application", + new_name="domain_request", + ), + ] diff --git a/src/registrar/migrations/0074_create_groups_v08.py b/src/registrar/migrations/0075_create_groups_v08.py similarity index 94% rename from src/registrar/migrations/0074_create_groups_v08.py rename to src/registrar/migrations/0075_create_groups_v08.py index 0c28cee52..b0b2ed740 100644 --- a/src/registrar/migrations/0074_create_groups_v08.py +++ b/src/registrar/migrations/0075_create_groups_v08.py @@ -25,7 +25,7 @@ def create_groups(apps, schema_editor) -> Any: class Migration(migrations.Migration): dependencies = [ - ("registrar", "0073_domainrequest_and_more"), + ("registrar", "0074_rename_domainapplication_domainrequest_and_more"), ] operations = [ diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 417713197..2282af726 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -9,6 +9,7 @@ from django.db import models from django_fsm import FSMField, transition # type: ignore from django.utils import timezone from registrar.models.domain import Domain +from registrar.utility.errors import FSMApplicationError, FSMErrorCodes from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError @@ -645,6 +646,14 @@ class DomainRequest(TimeStampedModel): except EmailSendingError: logger.warning("Failed to send confirmation email", exc_info=True) + def investigator_exists_and_is_staff(self): + """Checks if the current investigator is in a valid state for a state transition""" + is_valid = True + # Check if an investigator is assigned. No approval is possible without one. + if self.investigator is None or not self.investigator.is_staff: + is_valid = False + return is_valid + @transition( field="status", source=[ @@ -656,7 +665,7 @@ class DomainRequest(TimeStampedModel): target=DomainRequestStatus.SUBMITTED, ) def submit(self): - """Submit a domain request that is started. + """Submit an domain request that is started. As a side effect, an email notification is sent.""" @@ -664,10 +673,7 @@ class DomainRequest(TimeStampedModel): # can raise more informative exceptions # requested_domain could be None here - if not hasattr(self, "requested_domain"): - raise ValueError("Requested domain is missing.") - - if self.requested_domain is None: + if not hasattr(self, "requested_domain") or self.requested_domain is None: raise ValueError("Requested domain is missing.") DraftDomain = apps.get_model("registrar.DraftDomain") @@ -704,10 +710,10 @@ class DomainRequest(TimeStampedModel): DomainRequestStatus.INELIGIBLE, ], target=DomainRequestStatus.IN_REVIEW, - conditions=[domain_is_not_active], + conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) def in_review(self): - """Investigate a domain request that has been submitted. + """Investigate an domain request that has been submitted. This action is logged. @@ -736,10 +742,10 @@ class DomainRequest(TimeStampedModel): DomainRequestStatus.INELIGIBLE, ], target=DomainRequestStatus.ACTION_NEEDED, - conditions=[domain_is_not_active], + conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) def action_needed(self): - """Send back a domain request that is under investigation or rejected. + """Send back an domain request that is under investigation or rejected. This action is logged. @@ -768,9 +774,10 @@ class DomainRequest(TimeStampedModel): DomainRequestStatus.REJECTED, ], target=DomainRequestStatus.APPROVED, + conditions=[investigator_exists_and_is_staff], ) def approve(self, send_email=True): - """Approve a domain request that has been submitted. + """Approve an domain request that has been submitted. This action cleans up the rejection status if moving away from rejected. @@ -781,8 +788,12 @@ class DomainRequest(TimeStampedModel): # create the domain Domain = apps.get_model("registrar.Domain") + + # == Check that the domain_request is valid == # if Domain.objects.filter(name=self.requested_domain.name).exists(): - raise ValueError("Cannot approve. Requested domain is already in use.") + raise FSMApplicationError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE) + + # == Create the domain and related components == # created_domain = Domain.objects.create(name=self.requested_domain.name) self.approved_domain = created_domain @@ -799,6 +810,7 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None + # == Send out an email == # self._send_status_update_email( "domain request approved", "emails/status_change_approved.txt", @@ -812,7 +824,7 @@ class DomainRequest(TimeStampedModel): target=DomainRequestStatus.WITHDRAWN, ) def withdraw(self): - """Withdraw a domain request that has been submitted.""" + """Withdraw an domain request that has been submitted.""" self._send_status_update_email( "withdraw", @@ -824,10 +836,10 @@ class DomainRequest(TimeStampedModel): field="status", source=[DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED, DomainRequestStatus.APPROVED], target=DomainRequestStatus.REJECTED, - conditions=[domain_is_not_active], + conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) def reject(self): - """Reject a domain request that has been submitted. + """Reject an domain request that has been submitted. As side effects this will delete the domain and domain_information (will cascade), and send an email notification.""" @@ -850,7 +862,7 @@ class DomainRequest(TimeStampedModel): DomainRequestStatus.REJECTED, ], target=DomainRequestStatus.INELIGIBLE, - conditions=[domain_is_not_active], + conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) def reject_with_prejudice(self): """The applicant is a bad actor, reject with prejudice. diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index adb1a2142..24151261f 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -529,6 +529,7 @@ def completed_domain_request( user=False, submitter=False, name="city.gov", + investigator=None, ): """A completed domain request.""" if not user: @@ -558,6 +559,13 @@ def completed_domain_request( email="testy2@town.com", phone="(555) 555 5557", ) + if not investigator: + investigator, _ = User.objects.get_or_create( + username="incrediblyfakeinvestigator", + first_name="Joe", + last_name="Bob", + is_staff=True, + ) domain_request_kwargs = dict( organization_type="federal", federal_type="executive", @@ -573,6 +581,7 @@ def completed_domain_request( submitter=submitter, creator=user, status=status, + investigator=investigator, ) if has_about_your_organization: domain_request_kwargs["about_your_organization"] = "e-Government" @@ -591,6 +600,13 @@ def completed_domain_request( return domain_request +def set_domain_request_investigators(domain_request_list: list[DomainRequest], investigator_user: User): + """Helper method that sets the investigator field of all provided domain requests""" + for request in domain_request_list: + request.investigator = investigator_user + request.save() + + def multiple_unalphabetical_domain_objects( domain_type=AuditedAdminMockData.DOMAIN_REQUEST, ): diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 9b12dbf98..4d5315b80 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -17,7 +17,7 @@ from registrar.models import ( import boto3_mocking from registrar.models.transition_domain import TransitionDomain from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore -from .common import MockSESClient, less_console_noise, completed_domain_request +from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators from django_fsm import TransitionNotAllowed @@ -52,6 +52,18 @@ class TestDomainRequest(TestCase): status=DomainRequest.DomainRequestStatus.INELIGIBLE, name="ineligible.gov" ) + # Store all domain request statuses in a variable for ease of use + self.all_domain_requests = [ + self.started_domain_request, + self.submitted_domain_request, + self.in_review_domain_request, + self.action_needed_domain_request, + self.approved_domain_request, + self.withdrawn_domain_request, + self.rejected_domain_request, + self.ineligible_domain_request, + ] + self.mock_client = MockSESClient() def tearDown(self): @@ -219,6 +231,65 @@ class TestDomainRequest(TestCase): domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0) + def assert_fsm_transition_raises_error(self, test_cases, method_to_run): + """Given a list of test cases, check if each transition throws the intended error""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): + for domain_request, exception_type in test_cases: + with self.subTest(domain_request=domain_request, exception_type=exception_type): + with self.assertRaises(exception_type): + # Retrieve the method by name from the domain_request object and call it + method = getattr(domain_request, method_to_run) + # Call the method + method() + + def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run): + """Given a list of test cases, ensure that none of them throw transition errors""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): + for domain_request, exception_type in test_cases: + with self.subTest(domain_request=domain_request, exception_type=exception_type): + try: + # Retrieve the method by name from the DomainRequest object and call it + method = getattr(domain_request, method_to_run) + # Call the method + method() + except exception_type: + self.fail(f"{exception_type} was raised, but it was not expected.") + + def test_submit_transition_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator. + For submit, this should be valid in all cases. + """ + + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + + def test_submit_transition_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator user that is not staff. + For submit, this should be valid in all cases. + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + def test_submit_transition_allowed(self): """ Test that calling submit from allowable statuses does raises TransitionNotAllowed. @@ -230,14 +301,27 @@ class TestDomainRequest(TestCase): (self.withdrawn_domain_request, TransitionNotAllowed), ] + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + + def test_submit_transition_allowed_twice(self): + """ + Test that rotating between submit and in_review doesn't throw an error + """ with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.submit() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + try: + # Make a submission + self.in_review_domain_request.submit() + + # Rerun the old method to get back to the original state + self.in_review_domain_request.in_review() + + # Make another submission + self.in_review_domain_request.submit() + except TransitionNotAllowed: + self.fail("TransitionNotAllowed was raised, but it was not expected.") + + self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED) def test_submit_transition_not_allowed(self): """ @@ -250,12 +334,7 @@ class TestDomainRequest(TestCase): (self.ineligible_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.submit() + self.assert_fsm_transition_raises_error(test_cases, "submit") def test_in_review_transition_allowed(self): """ @@ -269,14 +348,43 @@ class TestDomainRequest(TestCase): (self.ineligible_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.in_review() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review") + + def test_in_review_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "in_review") + + def test_in_review_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff. + This should throw an exception. + """ + + test_cases = [ + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "in_review") def test_in_review_transition_not_allowed(self): """ @@ -288,12 +396,7 @@ class TestDomainRequest(TestCase): (self.withdrawn_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.in_review() + self.assert_fsm_transition_raises_error(test_cases, "in_review") def test_action_needed_transition_allowed(self): """ @@ -305,13 +408,43 @@ class TestDomainRequest(TestCase): (self.rejected_domain_request, TransitionNotAllowed), (self.ineligible_domain_request, TransitionNotAllowed), ] - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.action_needed() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + + self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed") + + def test_action_needed_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") + + def test_action_needed_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") def test_action_needed_transition_not_allowed(self): """ @@ -323,11 +456,8 @@ class TestDomainRequest(TestCase): (self.action_needed_domain_request, TransitionNotAllowed), (self.withdrawn_domain_request, TransitionNotAllowed), ] - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.action_needed() + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") def test_approved_transition_allowed(self): """ @@ -340,14 +470,40 @@ class TestDomainRequest(TestCase): (self.rejected_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.approve() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + self.assert_fsm_transition_does_not_raise_error(test_cases, "approve") + + def test_approved_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "approve") + + def test_approved_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "approve") def test_approved_skips_sending_email(self): """ @@ -372,13 +528,7 @@ class TestDomainRequest(TestCase): (self.withdrawn_domain_request, TransitionNotAllowed), (self.ineligible_domain_request, TransitionNotAllowed), ] - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.approve() + self.assert_fsm_transition_raises_error(test_cases, "approve") def test_withdraw_transition_allowed(self): """ @@ -390,14 +540,42 @@ class TestDomainRequest(TestCase): (self.action_needed_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.withdraw() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + + def test_withdraw_transition_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator. + For withdraw, this should be valid in all cases. + """ + + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + + def test_withdraw_transition_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff. + For withdraw, this should be valid in all cases. + """ + + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") def test_withdraw_transition_not_allowed(self): """ @@ -411,12 +589,7 @@ class TestDomainRequest(TestCase): (self.ineligible_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.withdraw() + self.assert_fsm_transition_raises_error(test_cases, "withdraw") def test_reject_transition_allowed(self): """ @@ -428,14 +601,40 @@ class TestDomainRequest(TestCase): (self.approved_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.reject() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + self.assert_fsm_transition_does_not_raise_error(test_cases, "reject") + + def test_reject_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "reject") + + def test_reject_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "reject") def test_reject_transition_not_allowed(self): """ @@ -449,12 +648,7 @@ class TestDomainRequest(TestCase): (self.ineligible_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.reject() + self.assert_fsm_transition_raises_error(test_cases, "reject") def test_reject_with_prejudice_transition_allowed(self): """ @@ -467,14 +661,42 @@ class TestDomainRequest(TestCase): (self.rejected_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - domain_request.reject_with_prejudice() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice") + + def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + + def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") def test_reject_with_prejudice_transition_not_allowed(self): """ @@ -487,12 +709,7 @@ class TestDomainRequest(TestCase): (self.ineligible_domain_request, TransitionNotAllowed), ] - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - domain_request.reject_with_prejudice() + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") def test_transition_not_allowed_approved_in_review_when_domain_is_active(self): """Create a domain request with status approved, create a matching domain that @@ -666,7 +883,10 @@ class TestPermissions(TestCase): def test_approval_creates_role(self): draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() - domain_request = DomainRequest.objects.create(creator=user, requested_domain=draft_domain) + investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True) + domain_request = DomainRequest.objects.create( + creator=user, requested_domain=draft_domain, investigator=investigator + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with less_console_noise(): @@ -697,10 +917,12 @@ class TestDomainInformation(TestCase): @boto3_mocking.patching def test_approval_creates_info(self): - self.maxDiff = None draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() - domain_request = DomainRequest.objects.create(creator=user, requested_domain=draft_domain, notes="test notes") + investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True) + domain_request = DomainRequest.objects.create( + creator=user, requested_domain=draft_domain, notes="test notes", investigator=investigator + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with less_console_noise(): diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index fd47d907f..8887aae1f 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -324,7 +324,10 @@ class TestDomainCreation(MockEppLib): with less_console_noise(): draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() - domain_request = DomainRequest.objects.create(creator=user, requested_domain=draft_domain) + investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True) + domain_request = DomainRequest.objects.create( + creator=user, requested_domain=draft_domain, investigator=investigator + ) mock_client = MockSESClient() with boto3_mocking.clients.handler_for("sesv2", mock_client): diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 03cb81893..00c65ce57 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -71,6 +71,48 @@ class GenericError(Exception): return self._error_mapping.get(code) +class FSMErrorCodes(IntEnum): + """Used when doing FSM transitions. + Overview of generic error codes: + - 1 APPROVE_DOMAIN_IN_USE The domain is already in use + - 2 NO_INVESTIGATOR No investigator is assigned + - 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user + - 4 INVESTIGATOR_NOT_SUBMITTER The form submitter is not the investigator + """ + + APPROVE_DOMAIN_IN_USE = 1 + NO_INVESTIGATOR = 2 + INVESTIGATOR_NOT_STAFF = 3 + INVESTIGATOR_NOT_SUBMITTER = 4 + + +class FSMApplicationError(Exception): + """ + Used to raise exceptions when doing FSM Transitions. + Uses `FSMErrorCodes` as an enum. + """ + + _error_mapping = { + FSMErrorCodes.APPROVE_DOMAIN_IN_USE: ("Cannot approve. Requested domain is already in use."), + FSMErrorCodes.NO_INVESTIGATOR: ("Investigator is required for this status."), + FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."), + FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."), + } + + def __init__(self, *args, code=None, **kwargs): + super().__init__(*args, **kwargs) + self.code = code + if self.code in self._error_mapping: + self.message = self._error_mapping.get(self.code) + + def __str__(self): + return f"{self.message}" + + @classmethod + def get_error_message(cls, code=None): + return cls._error_mapping.get(code) + + class NameserverErrorCodes(IntEnum): """Used in the NameserverError class for error mapping.