diff --git a/.github/actions/django-security-check/README.md b/.github/actions/django-security-check/README.md index 4eddcf74c..94f02a97c 100644 --- a/.github/actions/django-security-check/README.md +++ b/.github/actions/django-security-check/README.md @@ -38,7 +38,7 @@ jobs: id: check uses: victoriadrake/django-security-check@master - name: Upload output - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: security-check-output path: output.txt diff --git a/.github/workflows/deploy-manual.yaml b/.github/workflows/deploy-manual.yaml index e0bbee436..ba85342b0 100644 --- a/.github/workflows/deploy-manual.yaml +++ b/.github/workflows/deploy-manual.yaml @@ -30,6 +30,7 @@ on: - litterbox - ms - ad + - ag # GitHub Actions has no "good" way yet to dynamically input branches branch: description: 'Branch to deploy' diff --git a/.github/workflows/security-check.yaml b/.github/workflows/security-check.yaml index dd75d5c98..aea700613 100644 --- a/.github/workflows/security-check.yaml +++ b/.github/workflows/security-check.yaml @@ -44,7 +44,7 @@ jobs: id: check uses: ./.github/actions/django-security-check - name: Upload output - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: security-check-output path: ./src/output.txt diff --git a/docs/architecture/diagrams/model_timeline.md b/docs/architecture/diagrams/model_timeline.md index f05b9e056..eed2610eb 100644 --- a/docs/architecture/diagrams/model_timeline.md +++ b/docs/architecture/diagrams/model_timeline.md @@ -42,7 +42,6 @@ class DomainRequest { creator (User) investigator (User) senior_official (Contact) - submitter (Contact) other_contacts (Contacts) approved_domain (Domain) requested_domain (DraftDomain) @@ -80,7 +79,7 @@ class Contact { -- } -DomainRequest *-r-* Contact : senior_official, submitter, other_contacts +DomainRequest *-r-* Contact : senior_official, other_contacts class DraftDomain { Requested domain diff --git a/docs/architecture/diagrams/models_diagram.md b/docs/architecture/diagrams/models_diagram.md index 455f8fb09..1856407aa 100644 --- a/docs/architecture/diagrams/models_diagram.md +++ b/docs/architecture/diagrams/models_diagram.md @@ -38,7 +38,6 @@ class "registrar.Contact " as registrar.Contact #d6f4e9 { + id (BigAutoField) + created_at (DateTimeField) + updated_at (DateTimeField) - ~ user (OneToOneField) + first_name (CharField) + middle_name (CharField) + last_name (CharField) @@ -47,7 +46,6 @@ class "registrar.Contact " as registrar.Contact #d6f4e9 { + phone (PhoneNumberField) -- } -registrar.Contact -- registrar.User class "registrar.Host " as registrar.Host #d6f4e9 { @@ -143,6 +141,8 @@ class "registrar.FederalAgency " as registrar.FederalAgency #d6f4e9 { + updated_at (DateTimeField) + agency (CharField) + federal_type (CharField) + + initials (CharField) + + is_fceb (BooleanField) -- } @@ -159,6 +159,7 @@ class "registrar.DomainRequest " as registrar.DomainRequest #d6f4e9 { + action_needed_reason_email (TextField) ~ federal_agency (ForeignKey) ~ portfolio (ForeignKey) + ~ sub_organization (ForeignKey) ~ creator (ForeignKey) ~ investigator (ForeignKey) + generic_org_type (CharField) @@ -179,7 +180,6 @@ class "registrar.DomainRequest " as registrar.DomainRequest #d6f4e9 { ~ senior_official (ForeignKey) ~ approved_domain (OneToOneField) ~ requested_domain (OneToOneField) - ~ submitter (ForeignKey) + purpose (TextField) + no_other_contacts_rationale (TextField) + anything_else (TextField) @@ -198,12 +198,12 @@ class "registrar.DomainRequest " as registrar.DomainRequest #d6f4e9 { } registrar.DomainRequest -- registrar.FederalAgency registrar.DomainRequest -- registrar.Portfolio +registrar.DomainRequest -- registrar.Suborganization registrar.DomainRequest -- registrar.User registrar.DomainRequest -- registrar.User registrar.DomainRequest -- registrar.Contact registrar.DomainRequest -- registrar.Domain registrar.DomainRequest -- registrar.DraftDomain -registrar.DomainRequest -- registrar.Contact registrar.DomainRequest *--* registrar.Website registrar.DomainRequest *--* registrar.Website registrar.DomainRequest *--* registrar.Contact @@ -218,6 +218,7 @@ class "registrar.DomainInformation " as registrar.DomainInformation # ~ federal_agency (ForeignKey) ~ creator (ForeignKey) ~ portfolio (ForeignKey) + ~ sub_organization (ForeignKey) ~ domain_request (OneToOneField) + generic_org_type (CharField) + organization_type (CharField) @@ -236,7 +237,6 @@ class "registrar.DomainInformation " as registrar.DomainInformation # + about_your_organization (TextField) ~ senior_official (ForeignKey) ~ domain (OneToOneField) - ~ submitter (ForeignKey) + purpose (TextField) + no_other_contacts_rationale (TextField) + anything_else (TextField) @@ -253,10 +253,10 @@ class "registrar.DomainInformation " as registrar.DomainInformation # registrar.DomainInformation -- registrar.FederalAgency registrar.DomainInformation -- registrar.User registrar.DomainInformation -- registrar.Portfolio +registrar.DomainInformation -- registrar.Suborganization registrar.DomainInformation -- registrar.DomainRequest registrar.DomainInformation -- registrar.Contact registrar.DomainInformation -- registrar.Domain -registrar.DomainInformation -- registrar.Contact registrar.DomainInformation *--* registrar.Contact @@ -285,6 +285,38 @@ class "registrar.DomainInvitation " as registrar.DomainInvitation #d6 registrar.DomainInvitation -- registrar.Domain +class "registrar.UserPortfolioPermission " as registrar.UserPortfolioPermission #d6f4e9 { + user portfolio permission + -- + + id (BigAutoField) + + created_at (DateTimeField) + + updated_at (DateTimeField) + ~ user (ForeignKey) + ~ portfolio (ForeignKey) + + roles (ArrayField) + + additional_permissions (ArrayField) + -- +} +registrar.UserPortfolioPermission -- registrar.User +registrar.UserPortfolioPermission -- registrar.Portfolio + + +class "registrar.PortfolioInvitation " as registrar.PortfolioInvitation #d6f4e9 { + portfolio invitation + -- + + id (BigAutoField) + + created_at (DateTimeField) + + updated_at (DateTimeField) + + email (EmailField) + ~ portfolio (ForeignKey) + + portfolio_roles (ArrayField) + + portfolio_additional_permissions (ArrayField) + + status (FSMField) + -- +} +registrar.PortfolioInvitation -- registrar.Portfolio + + class "registrar.TransitionDomain " as registrar.TransitionDomain #d6f4e9 { transition domain -- @@ -409,10 +441,11 @@ class "registrar.Portfolio " as registrar.Portfolio #d6f4e9 { + created_at (DateTimeField) + updated_at (DateTimeField) ~ creator (ForeignKey) + + organization_name (CharField) + + organization_type (CharField) + notes (TextField) ~ federal_agency (ForeignKey) - + organization_type (CharField) - + organization_name (CharField) + ~ senior_official (ForeignKey) + address_line1 (CharField) + address_line2 (CharField) + city (CharField) @@ -424,6 +457,7 @@ class "registrar.Portfolio " as registrar.Portfolio #d6f4e9 { } registrar.Portfolio -- registrar.User registrar.Portfolio -- registrar.FederalAgency +registrar.Portfolio -- registrar.SeniorOfficial class "registrar.DomainGroup " as registrar.DomainGroup #d6f4e9 { @@ -454,7 +488,21 @@ class "registrar.Suborganization " as registrar.Suborganization #d6f4 registrar.Suborganization -- registrar.Portfolio -@enduml -``` +class "registrar.SeniorOfficial " as registrar.SeniorOfficial #d6f4e9 { + senior official + -- + + id (BigAutoField) + + created_at (DateTimeField) + + updated_at (DateTimeField) + + first_name (CharField) + + last_name (CharField) + + title (CharField) + + phone (PhoneNumberField) + + email (EmailField) + ~ federal_agency (ForeignKey) + -- +} +registrar.SeniorOfficial -- registrar.FederalAgency - + +@enduml diff --git a/docs/architecture/diagrams/models_diagram.svg b/docs/architecture/diagrams/models_diagram.svg index f8cf3a46d..85c0e7620 100644 --- a/docs/architecture/diagrams/models_diagram.svg +++ b/docs/architecture/diagrams/models_diagram.svg @@ -1 +1 @@ -registrarregistrar.ContactRegistrarcontactid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)user (OneToOneField)first_name (CharField)middle_name (CharField)last_name (CharField)title (CharField)email (EmailField)phone (PhoneNumberField)registrar.UserRegistraruserid (BigAutoField)password (CharField)last_login (DateTimeField)is_superuser (BooleanField)username (CharField)first_name (CharField)last_name (CharField)email (EmailField)is_staff (BooleanField)is_active (BooleanField)date_joined (DateTimeField)status (CharField)phone (PhoneNumberField)middle_name (CharField)title (CharField)verification_type (CharField)groups (ManyToManyField)user_permissions (ManyToManyField)domains (ManyToManyField)registrar.HostRegistrarhostid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)domain (ForeignKey)registrar.DomainRegistrardomainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (DomainField)state (FSMField)expiration_date (DateField)security_contact_registry_id (TextField)deleted (DateField)first_ready (DateField)dsdata_last_change (TextField)registrar.HostIPRegistrarhost ipid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)address (CharField)host (ForeignKey)registrar.PublicContactRegistrarpublic contactid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)contact_type (CharField)registry_id (CharField)domain (ForeignKey)name (CharField)org (CharField)street1 (CharField)street2 (CharField)street3 (CharField)city (CharField)sp (CharField)pc (CharField)cc (CharField)email (EmailField)voice (CharField)fax (CharField)pw (CharField)registrar.UserDomainRoleRegistraruser domain roleid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)user (ForeignKey)domain (ForeignKey)role (TextField)registrar.FederalAgencyRegistrarFederal agencyid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)agency (CharField)federal_type (CharField)registrar.DomainRequestRegistrardomain requestid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)status (FSMField)rejection_reason (TextField)action_needed_reason (TextField)action_needed_reason_email (TextField)federal_agency (ForeignKey)portfolio (ForeignKey)creator (ForeignKey)investigator (ForeignKey)generic_org_type (CharField)is_election_board (BooleanField)organization_type (CharField)federally_recognized_tribe (BooleanField)state_recognized_tribe (BooleanField)tribe_name (CharField)federal_type (CharField)organization_name (CharField)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)about_your_organization (TextField)senior_official (ForeignKey)approved_domain (OneToOneField)requested_domain (OneToOneField)submitter (ForeignKey)purpose (TextField)no_other_contacts_rationale (TextField)anything_else (TextField)has_anything_else_text (BooleanField)cisa_representative_email (EmailField)cisa_representative_first_name (CharField)cisa_representative_last_name (CharField)has_cisa_representative (BooleanField)is_policy_acknowledged (BooleanField)submission_date (DateField)notes (TextField)current_websites (ManyToManyField)alternative_domains (ManyToManyField)other_contacts (ManyToManyField)registrar.PortfolioRegistrarportfolioid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)creator (ForeignKey)notes (TextField)federal_agency (ForeignKey)organization_type (CharField)organization_name (CharField)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)security_contact_email (EmailField)registrar.DraftDomainRegistrardraft domainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)registrar.WebsiteRegistrarwebsiteid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)website (CharField)registrar.DomainInformationRegistrardomain informationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)federal_agency (ForeignKey)creator (ForeignKey)portfolio (ForeignKey)domain_request (OneToOneField)generic_org_type (CharField)organization_type (CharField)federally_recognized_tribe (BooleanField)state_recognized_tribe (BooleanField)tribe_name (CharField)federal_type (CharField)is_election_board (BooleanField)organization_name (CharField)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)about_your_organization (TextField)senior_official (ForeignKey)domain (OneToOneField)submitter (ForeignKey)purpose (TextField)no_other_contacts_rationale (TextField)anything_else (TextField)has_anything_else_text (BooleanField)cisa_representative_email (EmailField)cisa_representative_first_name (CharField)cisa_representative_last_name (CharField)has_cisa_representative (BooleanField)is_policy_acknowledged (BooleanField)notes (TextField)other_contacts (ManyToManyField)registrar.DomainInvitationRegistrardomain invitationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)email (EmailField)domain (ForeignKey)status (FSMField)registrar.TransitionDomainRegistrartransition domainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)username (CharField)domain_name (CharField)status (CharField)email_sent (BooleanField)processed (BooleanField)generic_org_type (CharField)organization_name (CharField)federal_type (CharField)federal_agency (CharField)epp_creation_date (DateField)epp_expiration_date (DateField)first_name (CharField)middle_name (CharField)last_name (CharField)title (CharField)email (EmailField)phone (CharField)address_line (CharField)city (CharField)state_territory (CharField)zipcode (CharField)registrar.VerifiedByStaffRegistrarverified by staffid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)email (EmailField)requestor (ForeignKey)notes (TextField)registrar.UserGroupRegistrarUser groupid (AutoField)name (CharField)group_ptr (OneToOneField)permissions (ManyToManyField)registrar.WaffleFlagRegistrarwaffle flagid (BigAutoField)name (CharField)everyone (BooleanField)percent (DecimalField)testing (BooleanField)superusers (BooleanField)staff (BooleanField)authenticated (BooleanField)languages (TextField)rollout (BooleanField)note (TextField)created (DateTimeField)modified (DateTimeField)groups (ManyToManyField)users (ManyToManyField)registrar.DomainGroupRegistrardomain groupid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)portfolio (ForeignKey)domains (ManyToManyField)registrar.SuborganizationRegistrarsuborganizationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)portfolio (ForeignKey) \ No newline at end of file +registrarregistrar.ContactRegistrarcontactid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)first_name (CharField)middle_name (CharField)last_name (CharField)title (CharField)email (EmailField)phone (PhoneNumberField)registrar.HostRegistrarhostid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)domain (ForeignKey)registrar.DomainRegistrardomainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (DomainField)state (FSMField)expiration_date (DateField)security_contact_registry_id (TextField)deleted (DateField)first_ready (DateField)dsdata_last_change (TextField)registrar.HostIPRegistrarhost ipid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)address (CharField)host (ForeignKey)registrar.PublicContactRegistrarpublic contactid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)contact_type (CharField)registry_id (CharField)domain (ForeignKey)name (CharField)org (CharField)street1 (CharField)street2 (CharField)street3 (CharField)city (CharField)sp (CharField)pc (CharField)cc (CharField)email (EmailField)voice (CharField)fax (CharField)pw (CharField)registrar.UserDomainRoleRegistraruser domain roleid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)user (ForeignKey)domain (ForeignKey)role (TextField)registrar.UserRegistraruserid (BigAutoField)password (CharField)last_login (DateTimeField)is_superuser (BooleanField)username (CharField)first_name (CharField)last_name (CharField)email (EmailField)is_staff (BooleanField)is_active (BooleanField)date_joined (DateTimeField)status (CharField)portfolio (ForeignKey)portfolio_roles (ArrayField)portfolio_additional_permissions (ArrayField)phone (PhoneNumberField)middle_name (CharField)title (CharField)verification_type (CharField)groups (ManyToManyField)user_permissions (ManyToManyField)domains (ManyToManyField)registrar.FederalAgencyRegistrarFederal agencyid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)agency (CharField)federal_type (CharField)initials (CharField)is_fceb (BooleanField)registrar.DomainRequestRegistrardomain requestid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)status (FSMField)rejection_reason (TextField)action_needed_reason (TextField)action_needed_reason_email (TextField)federal_agency (ForeignKey)portfolio (ForeignKey)sub_organization (ForeignKey)creator (ForeignKey)investigator (ForeignKey)generic_org_type (CharField)is_election_board (BooleanField)organization_type (CharField)federally_recognized_tribe (BooleanField)state_recognized_tribe (BooleanField)tribe_name (CharField)federal_type (CharField)organization_name (CharField)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)about_your_organization (TextField)senior_official (ForeignKey)approved_domain (OneToOneField)requested_domain (OneToOneField)purpose (TextField)no_other_contacts_rationale (TextField)anything_else (TextField)has_anything_else_text (BooleanField)cisa_representative_email (EmailField)cisa_representative_first_name (CharField)cisa_representative_last_name (CharField)has_cisa_representative (BooleanField)is_policy_acknowledged (BooleanField)submission_date (DateField)notes (TextField)current_websites (ManyToManyField)alternative_domains (ManyToManyField)other_contacts (ManyToManyField)registrar.PortfolioRegistrarportfolioid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)creator (ForeignKey)organization_name (CharField)organization_type (CharField)notes (TextField)federal_agency (ForeignKey)senior_official (ForeignKey)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)security_contact_email (EmailField)registrar.SuborganizationRegistrarsuborganizationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)portfolio (ForeignKey)registrar.DraftDomainRegistrardraft domainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)registrar.WebsiteRegistrarwebsiteid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)website (CharField)registrar.DomainInformationRegistrardomain informationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)federal_agency (ForeignKey)creator (ForeignKey)portfolio (ForeignKey)sub_organization (ForeignKey)domain_request (OneToOneField)generic_org_type (CharField)organization_type (CharField)federally_recognized_tribe (BooleanField)state_recognized_tribe (BooleanField)tribe_name (CharField)federal_type (CharField)is_election_board (BooleanField)organization_name (CharField)address_line1 (CharField)address_line2 (CharField)city (CharField)state_territory (CharField)zipcode (CharField)urbanization (CharField)about_your_organization (TextField)senior_official (ForeignKey)domain (OneToOneField)purpose (TextField)no_other_contacts_rationale (TextField)anything_else (TextField)has_anything_else_text (BooleanField)cisa_representative_email (EmailField)cisa_representative_first_name (CharField)cisa_representative_last_name (CharField)has_cisa_representative (BooleanField)is_policy_acknowledged (BooleanField)notes (TextField)other_contacts (ManyToManyField)registrar.DomainInvitationRegistrardomain invitationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)email (EmailField)domain (ForeignKey)status (FSMField)registrar.PortfolioInvitationRegistrarportfolio invitationid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)email (EmailField)portfolio (ForeignKey)portfolio_roles (ArrayField)portfolio_additional_permissions (ArrayField)status (FSMField)registrar.TransitionDomainRegistrartransition domainid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)username (CharField)domain_name (CharField)status (CharField)email_sent (BooleanField)processed (BooleanField)generic_org_type (CharField)organization_name (CharField)federal_type (CharField)federal_agency (CharField)epp_creation_date (DateField)epp_expiration_date (DateField)first_name (CharField)middle_name (CharField)last_name (CharField)title (CharField)email (EmailField)phone (CharField)address_line (CharField)city (CharField)state_territory (CharField)zipcode (CharField)registrar.VerifiedByStaffRegistrarverified by staffid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)email (EmailField)requestor (ForeignKey)notes (TextField)registrar.UserGroupRegistrarUser groupid (AutoField)name (CharField)group_ptr (OneToOneField)permissions (ManyToManyField)registrar.WaffleFlagRegistrarwaffle flagid (BigAutoField)name (CharField)everyone (BooleanField)percent (DecimalField)testing (BooleanField)superusers (BooleanField)staff (BooleanField)authenticated (BooleanField)languages (TextField)rollout (BooleanField)note (TextField)created (DateTimeField)modified (DateTimeField)groups (ManyToManyField)users (ManyToManyField)registrar.SeniorOfficialRegistrarsenior officialid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)first_name (CharField)last_name (CharField)title (CharField)phone (PhoneNumberField)email (EmailField)federal_agency (ForeignKey)registrar.DomainGroupRegistrardomain groupid (BigAutoField)created_at (DateTimeField)updated_at (DateTimeField)name (CharField)portfolio (ForeignKey)domains (ManyToManyField) \ No newline at end of file diff --git a/docs/developer/generating-emails-guide.md b/docs/developer/generating-emails-guide.md index dd0a55e64..cc5f9d41b 100644 --- a/docs/developer/generating-emails-guide.md +++ b/docs/developer/generating-emails-guide.md @@ -14,7 +14,7 @@ - Starting Location: Home page - Workflow: (Domain requests Table) Manage domain - Workflow Step: Click "Manage" -> Click "Withdraw request" -> (confirmation prompt) -> Click "Withdraw request" (inside prompt) -- Notes: You can also do this through Django Admin by switching a domain of status "submitted" to "withdrawn", but you need to be the submitter (email listed on Your Contact Information). +- Notes: You can also do this through Django Admin by switching a domain of status "submitted" to "withdrawn", but you need to be the creator. - [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/domain_request_withdrawn.txt) ### Domain Request Withdrawn Subject @@ -25,7 +25,7 @@ - Starting Location: Django Admin - Workflow: Analyst Admin - Workflow Step: Click "domain requests" -> Click a domain request in a status of "submitted", "In review", "rejected", or "ineligible" -> Click status dropdown -> (select "approved") -> click "Save" -- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create a domain request, then set the status to "approved". This will send you an email. +- Notes: Note that this will send an email to the creator. To test this with your own email, you need to create a domain request, then set the status to "approved". This will send you an email. - [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_approved.txt) ### Status Change Approved Subject @@ -36,7 +36,7 @@ - Starting Location: Django Admin - Workflow: Analyst Admin - Workflow Step: Click "domain requests" -> Click a domain request in a status of "In review", or "approved" -> Click status dropdown -> (select "rejected") -> click "Save" -- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create a domain request, then set the status to "in review" (and click save). Then, go back to the same application and set the status to "rejected". This will send you an email. +- Notes: Note that this will send an email to the creator. To test this with your own email, you need to create a domain request, then set the status to "in review" (and click save). Then, go back to the same application and set the status to "rejected". This will send you an email. - [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_rejected.txt) ### Status Change Rejected Subject @@ -47,7 +47,7 @@ - Starting Location: Home Page - Workflow: Start domain request - Workflow Step: Click "Start a new domain request" -> (fill out the form) -> On the last step ("Review and submit your domain request "), click "Submit your domain request" -- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information) +- Notes: Note that this will send an email to the creator. - [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/submission_confirmation.txt) ### Submission Confirmation Subject diff --git a/docs/developer/management_script_helpers.md b/docs/developer/management_script_helpers.md index 104e4dc13..a43bb16aa 100644 --- a/docs/developer/management_script_helpers.md +++ b/docs/developer/management_script_helpers.md @@ -62,4 +62,5 @@ The class provides the following optional configuration variables: The class also provides helper methods: - `get_class_name`: Returns a display-friendly class name for the terminal prompt - `get_failure_message`: Returns the message to display if a record fails to update -- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped) \ No newline at end of file +- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped) +- `custom_filter`: Allows for additional filters that cannot be expressed using django queryset field lookups \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 5914eb179..5e1aa688a 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -817,6 +817,28 @@ Example: `cf ssh getgov-za` |:-:|:-------------------------- |:-----------------------------------------------------------------------------------| | 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is | +## Update First Ready Values +This section outlines how to run the populate_first_ready script + +### 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: Running the script +```./manage.py update_first_ready``` + +### Running locally +```docker-compose exec app ./manage.py update_first_ready``` + ## Populate Domain Request Dates This section outlines how to run the populate_domain_request_dates script @@ -838,3 +860,55 @@ Example: `cf ssh getgov-za` ### Running locally ```docker-compose exec app ./manage.py populate_domain_request_dates``` + +## Create federal portfolio +This script takes the name of a `FederalAgency` (like 'AMTRAK') and does the following: +1. Creates the portfolio record based off of data on the federal agency object itself. +2. Creates suborganizations from existing DomainInformation records. +3. Associates the SeniorOfficial record (if it exists). +4. Adds this portfolio to DomainInformation / DomainRequests or both. + +Errors: +1. ValueError: Federal agency not found in database. +2. Logged Warning: No senior official found for portfolio +3. Logged Error: No suborganizations found for portfolio. +4. Logged Warning: No new suborganizations to add. +5. Logged Warning: No valid DomainRequest records to update. +6. Logged Warning: No valid DomainInformation records to update. + +### 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: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +```./manage.py create_federal_portfolio "{federal_agency_name}" --both``` + +Example (only requests): `./manage.py create_federal_portfolio "AMTRAK" --parse_requests` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py create_federal_portfolio "{federal_agency_name}" --both``` + +##### Parameters +| | Parameter | Description | +|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------| +| 1 | **federal_agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | +| 2 | **both** | If True, runs parse_requests and parse_domains. | +| 3 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | +| 4 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | + +Note: Regarding parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them, +you must specify at least one to run this script. diff --git a/src/epplibwrapper/cert.py b/src/epplibwrapper/cert.py index 15ff16c06..589736a04 100644 --- a/src/epplibwrapper/cert.py +++ b/src/epplibwrapper/cert.py @@ -1,7 +1,7 @@ import os import tempfile -from django.conf import settings +from django.conf import settings # type: ignore class Cert: @@ -12,7 +12,7 @@ class Cert: variable but Python's ssl library requires a file. """ - def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: + def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: # type: ignore self.filename = self._write(data) def __del__(self): @@ -31,4 +31,4 @@ class Key(Cert): """Location of private key as written to disk.""" def __init__(self) -> None: - super().__init__(data=settings.SECRET_REGISTRY_KEY) + super().__init__(data=settings.SECRET_REGISTRY_KEY) # type: ignore diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a048e83e9..ba42ac7e5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -535,7 +535,6 @@ class AdminSortFields: sort_mapping = { # == Contact == # "other_contacts": (Contact, _name_sort), - "submitter": (Contact, _name_sort), # == Senior Official == # "senior_official": (SeniorOfficial, _name_sort), # == User == # @@ -962,7 +961,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): domain_ids = user_domain_roles.values_list("domain_id", flat=True) domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED) - extra_context = {"domain_requests": domain_requests, "domains": domains} + portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True) + portfolios = models.Portfolio.objects.filter(id__in=portfolio_ids) + extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios} return super().change_view(request, object_id, form_url, extra_context) @@ -1440,13 +1441,9 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "domain", "generic_org_type", "created_at", - "submitter", ] - orderable_fk_fields = [ - ("domain", "name"), - ("submitter", ["first_name", "last_name"]), - ] + orderable_fk_fields = [("domain", "name")] # Filters list_filter = ["generic_org_type"] @@ -1458,7 +1455,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): search_help_text = "Search by domain." fieldsets = [ - (None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}), + (None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}), (".gov domain", {"fields": ["domain"]}), ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}), ("Background info", {"fields": ["anything_else"]}), @@ -1522,7 +1519,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "more_organization_information", "domain", "domain_request", - "submitter", "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", @@ -1537,7 +1533,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "domain_request", "senior_official", "domain", - "submitter", "portfolio", "sub_organization", ] @@ -1710,13 +1705,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "custom_election_board", "city", "state_territory", - "submitter", "investigator", ] orderable_fk_fields = [ ("requested_domain", "name"), - ("submitter", ["first_name", "last_name"]), ("investigator", ["first_name", "last_name"]), ] @@ -1746,11 +1739,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Search search_fields = [ "requested_domain__name", - "submitter__email", - "submitter__first_name", - "submitter__last_name", + "creator__email", + "creator__first_name", + "creator__last_name", ] - search_help_text = "Search by domain or submitter." + search_help_text = "Search by domain or creator." fieldsets = [ ( @@ -1766,7 +1759,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "action_needed_reason_email", "investigator", "creator", - "submitter", "approved_domain", "notes", ] @@ -1854,7 +1846,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "approved_domain", "alternative_domains", "purpose", - "submitter", "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", @@ -1865,7 +1856,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): autocomplete_fields = [ "approved_domain", "requested_domain", - "submitter", "creator", "senior_official", "investigator", @@ -1987,12 +1977,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): """ - # TODO 2574: remove lines 1977-1978 (refactor as needed) - profile_flag = flag_is_active(request, "profile_feature") - if profile_flag and hasattr(obj, "creator"): + if hasattr(obj, "creator"): recipient = obj.creator - elif not profile_flag and hasattr(obj, "submitter"): - recipient = obj.submitter else: recipient = None diff --git a/src/registrar/assets/js/get-gov-admin-extra.js b/src/registrar/assets/js/get-gov-admin-extra.js new file mode 100644 index 000000000..14059267b --- /dev/null +++ b/src/registrar/assets/js/get-gov-admin-extra.js @@ -0,0 +1,14 @@ +// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox +(function($) { + $(document).ready(function() { + if ($) { + $("#selected_user").select2({ + width: 'resolve', + placeholder: 'Select a user', + allowClear: true + }); + } else { + console.error('jQuery is not available'); + } + }); +})(window.jQuery); diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 042065d6d..174b463d7 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -172,40 +172,39 @@ function addOrRemoveSessionBoolean(name, add){ ** To perform data operations on this - we need to use jQuery rather than vanilla js. */ (function (){ - let selector = django.jQuery("#id_investigator") - let assignSelfButton = document.querySelector("#investigator__assign_self"); - if (!selector || !assignSelfButton) { - return; - } - - let currentUserId = assignSelfButton.getAttribute("data-user-id"); - let currentUserName = assignSelfButton.getAttribute("data-user-name"); - if (!currentUserId || !currentUserName){ - console.error("Could not assign current user: no values found.") - return; - } - - // Hook a click listener to the "Assign to me" button. - // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists - assignSelfButton.addEventListener("click", function() { - if (selector.find(`option[value='${currentUserId}']`).length) { - // Select the value that is associated with the current user. - selector.val(currentUserId).trigger("change"); - } else { - // Create a DOM Option that matches the desired user. Then append it and select it. - let userOption = new Option(currentUserName, currentUserId, true, true); - selector.append(userOption).trigger("change"); + if (document.getElementById("id_investigator") && django && django.jQuery) { + let selector = django.jQuery("#id_investigator") + let assignSelfButton = document.querySelector("#investigator__assign_self"); + if (!selector || !assignSelfButton) { + return; } - }); - // Listen to any change events, and hide the parent container if investigator has a value. - selector.on('change', function() { - // The parent container has display type flex. - assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; - }); - - + let currentUserId = assignSelfButton.getAttribute("data-user-id"); + let currentUserName = assignSelfButton.getAttribute("data-user-name"); + if (!currentUserId || !currentUserName){ + console.error("Could not assign current user: no values found.") + return; + } + // Hook a click listener to the "Assign to me" button. + // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists + assignSelfButton.addEventListener("click", function() { + if (selector.find(`option[value='${currentUserId}']`).length) { + // Select the value that is associated with the current user. + selector.val(currentUserId).trigger("change"); + } else { + // Create a DOM Option that matches the desired user. Then append it and select it. + let userOption = new Option(currentUserName, currentUserId, true, true); + selector.append(userOption).trigger("change"); + } + }); + + // Listen to any change events, and hide the parent container if investigator has a value. + selector.on('change', function() { + // The parent container has display type flex. + assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; + }); + } })(); /** An IIFE for pages in DjangoAdmin that use a clipboard button @@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){ function copyToClipboardAndChangeIcon(button) { // Assuming the input is the previous sibling of the button let input = button.previousElementSibling; - let userId = input.getAttribute("user-id") // Copy input value to clipboard if (input) { navigator.clipboard.writeText(input.value).then(function() { @@ -702,7 +700,10 @@ document.addEventListener('DOMContentLoaded', function() { //------ Requested Domains const requestedDomainElement = document.getElementById('id_requested_domain'); - const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text; + // We have to account for different superuser and analyst markups + const requestedDomain = requestedDomainElement.options + ? requestedDomainElement.options[requestedDomainElement.selectedIndex].text + : requestedDomainElement.text; //------ Submitter // Function to extract text by ID and handle missing elements @@ -716,7 +717,10 @@ document.addEventListener('DOMContentLoaded', function() { // Extract the submitter name, title, email, and phone number const submitterDiv = document.querySelector('.form-row.field-submitter'); const submitterNameElement = document.getElementById('id_submitter'); - const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text; + // We have to account for different superuser and analyst markups + const submitterName = submitterNameElement + ? submitterNameElement.options[submitterNameElement.selectedIndex].text + : submitterDiv.querySelector('a').text; const submitterTitle = extractTextById('contact_info_title', submitterDiv); const submitterEmail = extractTextById('contact_info_email', submitterDiv); const submitterPhone = extractTextById('contact_info_phone', submitterDiv); diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index e6b5a039f..cd42fd322 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1168,7 +1168,6 @@ document.addEventListener('DOMContentLoaded', function() { const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusToggle = document.querySelector('.usa-button--filter'); - const noPortfolioFlag = document.getElementById('no-portfolio-js-flag'); const portfolioElement = document.getElementById('portfolio-js-value'); const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null; @@ -1220,13 +1219,13 @@ document.addEventListener('DOMContentLoaded', function() { const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const actionUrl = domain.action_url; - const suborganization = domain.suborganization ? domain.suborganization : '⎯'; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; const row = document.createElement('tr'); let markupForSuborganizationRow = ''; - if (!noPortfolioFlag) { + if (portfolioValue) { markupForSuborganizationRow = ` ${suborganization} @@ -1427,9 +1426,9 @@ document.addEventListener('DOMContentLoaded', function() { // NOTE: We may need to evolve this as we add more filters. document.addEventListener('focusin', function(event) { const accordion = document.querySelector('.usa-accordion--select'); - const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); + const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); - if (accordionIsOpen && !accordion.contains(event.target)) { + if (accordionThatIsOpen && !accordion.contains(event.target)) { closeFilters(); } }); @@ -1438,9 +1437,9 @@ document.addEventListener('DOMContentLoaded', function() { // NOTE: We may need to evolve this as we add more filters. document.addEventListener('click', function(event) { const accordion = document.querySelector('.usa-accordion--select'); - const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); + const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); - if (accordionIsOpen && !accordion.contains(event.target)) { + if (accordionThatIsOpen && !accordion.contains(event.target)) { closeFilters(); } }); @@ -1485,6 +1484,8 @@ document.addEventListener('DOMContentLoaded', function() { const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]'); const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region'); const resetSearchButton = document.querySelector('.domain-requests__reset-search'); + const portfolioElement = document.getElementById('portfolio-js-value'); + const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null; /** * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input. @@ -1533,7 +1534,7 @@ document.addEventListener('DOMContentLoaded', function() { * @param {*} scroll - control for the scrollToElement functionality * @param {*} searchTerm - the search term */ - function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) { + function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) { // fetch json of page of domain requests, given params let baseUrl = document.getElementById("get_domain_requests_json_url"); if (!baseUrl) { @@ -1545,7 +1546,12 @@ document.addEventListener('DOMContentLoaded', function() { return; } - fetch(`${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) + // fetch json of page of requests, given params + let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}` + if (portfolio) + url += `&portfolio=${portfolio}` + + fetch(url) .then(response => response.json()) .then(data => { if (data.error) { @@ -1601,10 +1607,21 @@ document.addEventListener('DOMContentLoaded', function() { const actionLabel = request.action_label; const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; - // Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed + // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) + // Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed let modalTrigger = ''; - // If the request is deletable, create modal body and insert it + let markupCreatorRow = ''; + + if (portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } + + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages if (request.is_deletable) { let modalHeading = ''; let modalDescription = ''; @@ -1627,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function() { role="button" id="button-toggle-delete-domain-alert-${request.id}" href="#toggle-delete-domain-alert-${request.id}" - class="usa-button--unstyled text-no-underline late-loading-modal-trigger" + class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger line-height-sans-5" aria-controls="toggle-delete-domain-alert-${request.id}" data-open-modal > @@ -1692,8 +1709,57 @@ document.addEventListener('DOMContentLoaded', function() { ` domainRequestsSectionWrapper.appendChild(modal); + + // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly + if (portfolioValue) { + modalTrigger = ` + + Delete ${domainName} + + +
+
+ +
+ +
+ ` + } } + const row = document.createElement('tr'); row.innerHTML = ` @@ -1702,6 +1768,7 @@ document.addEventListener('DOMContentLoaded', function() { ${submissionDate} + ${markupCreatorRow} ${request.status} @@ -1817,6 +1884,32 @@ document.addEventListener('DOMContentLoaded', function() { }); } + function closeMoreActionMenu(accordionThatIsOpen) { + if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { + accordionThatIsOpen.click(); + } + } + + document.addEventListener('focusin', function(event) { + closeOpenAccordions(event); + }); + + document.addEventListener('click', function(event) { + closeOpenAccordions(event); + }); + + function closeOpenAccordions(event) { + const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); + openAccordions.forEach((openAccordionButton) => { + // Find the corresponding accordion + const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); + if (accordion && !accordion.contains(event.target)) { + // Close the accordion if the click is outside + closeMoreActionMenu(openAccordionButton); + } + }); + } + // Initial load loadDomainRequests(1); } diff --git a/src/registrar/assets/sass/_theme/_accordions.scss b/src/registrar/assets/sass/_theme/_accordions.scss index 839d7ac42..df4f686d8 100644 --- a/src/registrar/assets/sass/_theme/_accordions.scss +++ b/src/registrar/assets/sass/_theme/_accordions.scss @@ -1,6 +1,7 @@ @use "uswds-core" as *; -.usa-accordion--select { +.usa-accordion--select, +.usa-accordion--more-actions { display: inline-block; width: auto; position: relative; @@ -14,7 +15,6 @@ // Note, width is determined by a custom width class on one of the children position: absolute; z-index: 1; - top: 33.88px; left: 0; border-radius: 4px; border: solid 1px color('base-lighter'); @@ -31,3 +31,17 @@ margin-top: 0 !important; } } + +.usa-accordion--select .usa-accordion__content { + top: 33.88px; +} + +.usa-accordion--more-actions .usa-accordion__content { + top: 30px; +} + +tr:last-child .usa-accordion--more-actions .usa-accordion__content { + top: auto; + bottom: -10px; + right: 30px; +} diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 3028336ac..e760037f1 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -126,7 +126,7 @@ html[data-theme="light"] { body.dashboard, body.change-list, body.change-form, - .analytics { + .custom-admin-template, dt { color: var(--body-fg); } .usa-table td { @@ -155,7 +155,7 @@ html[data-theme="dark"] { body.dashboard, body.change-list, body.change-form, - .analytics { + .custom-admin-template, dt { color: var(--body-fg); } .usa-table td { @@ -166,7 +166,7 @@ html[data-theme="dark"] { // Remove when dark mode successfully applies to Django delete page. .delete-confirmation .content a:not(.button) { color: color('primary'); - } + } } @@ -370,14 +370,60 @@ input.admin-confirm-button { list-style-type: none; line-height: normal; } - .button { - display: inline-block; - padding: 10px 8px; - line-height: normal; - } - a.button:active, a.button:focus { - text-decoration: none; - } +} + +// This block resolves some of the issues we're seeing on buttons due to css +// conflicts between DJ and USWDS +a.button, +.usa-button--dja { + display: inline-block; + padding: 10px 15px; + font-size: 14px; + line-height: 16.1px; + font-kerning: auto; + font-family: inherit; + font-weight: normal; +} +.button svg, +.button span, +.usa-button--dja svg, +.usa-button--dja span { + vertical-align: middle; +} +.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { + background: var(--button-bg); +} +.usa-button--dja span { + font-size: 14px; +} +.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover { + background: var(--button-hover-bg); +} +a.button:active, a.button:focus { + text-decoration: none; +} +.usa-modal { + font-family: inherit; +} +input[type=submit].button--dja-toolbar { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} +input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { + border-color: var(--body-quiet-color); +} +// Targets the DJA buttom with a nested icon +button .usa-icon, +.button .usa-icon, +.button--clipboard .usa-icon { + vertical-align: middle; } .module--custom { @@ -471,13 +517,6 @@ address.dja-address-contact-list { color: var(--link-fg); } -// Targets the DJA buttom with a nested icon -button .usa-icon, -.button .usa-icon, -.button--clipboard .usa-icon { - vertical-align: middle; -} - .errors span.select2-selection { border: 1px solid var(--error-fg) !important; } @@ -738,7 +777,7 @@ div.dja__model-description{ li { list-style-type: disc; - font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif; + font-family: family('sans'); } a, a:link, a:visited { @@ -861,3 +900,16 @@ ul.add-list-reset { padding: 0 !important; margin: 0 !important; } + +// Fix the combobox when deployed outside admin (eg user transfer) +.submit-row .select2, +.submit-row .select2 span { + margin-top: 0; +} +.transfer-user-selector .select2-selection__placeholder { + color: #3d4551!important; +} + +.dl-dja dt { + font-size: 14px; +} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index e3ab4d538..9d2ed4177 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -159,6 +159,23 @@ abbr[title] { } } +.hidden-mobile-flex { + display: none!important; +} +.visible-mobile-flex { + display: flex!important; +} + +@include at-media(tablet) { + .hidden-mobile-flex { + display: flex!important; + } + .visible-mobile-flex { + display: none!important; + } +} + + .flex-end { align-items: flex-end; } @@ -200,6 +217,11 @@ abbr[title] { } } -.margin-right-neg-4px { - margin-right: -4px; +// Boost this USWDS utility class for the accordions in the portfolio requests table +.left-auto { + left: auto!important; +} + +.break-word { + word-break: break-word; } diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 12eee9926..d431bfa41 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -211,14 +211,7 @@ a.usa-button--unstyled:visited { align-items: center; } - -.dotgov-table a, -.usa-link--icon { - &:visited { - color: color('primary'); - } -} - +.dotgov-table a a .usa-icon, .usa-button--with-icon .usa-icon { height: 1.3em; @@ -230,3 +223,9 @@ a .usa-icon, height: 1.5em; width: 1.5em; } + +button.text-secondary, +button.text-secondary:hover, +.dotgov-table a.text-secondary { + color: $theme-color-error; +} diff --git a/src/registrar/assets/sass/_theme/_header.scss b/src/registrar/assets/sass/_theme/_header.scss index 3d72a09cf..d79774d98 100644 --- a/src/registrar/assets/sass/_theme/_header.scss +++ b/src/registrar/assets/sass/_theme/_header.scss @@ -89,16 +89,24 @@ .usa-nav__primary { .usa-nav-link, .usa-nav-link:hover, - .usa-nav-link:active { + .usa-nav-link:active, + button { color: color('primary'); font-weight: font-weight('normal'); font-size: 16px; } .usa-current, .usa-current:hover, - .usa-current:active { + .usa-current:active, + button.usa-current { font-weight: font-weight('bold'); } + button[aria-expanded="true"] { + color: color('white'); + } + button:not(.usa-current):hover::after { + display: none!important; + } } .usa-nav__secondary { // I don't know why USWDS has this at 2 rem, which puts it out of alignment diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 73aecad7a..96740a15c 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -23,6 +23,9 @@ from cfenv import AppEnv # type: ignore from pathlib import Path from typing import Final from botocore.config import Config +import json +import logging +from django.utils.log import ServerFormatter # # # ### # Setup code goes here # @@ -57,7 +60,7 @@ env_db_url = env.dj_db_url("DATABASE_URL") env_debug = env.bool("DJANGO_DEBUG", default=False) env_is_production = env.bool("IS_PRODUCTION", default=False) env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG") -env_base_url = env.str("DJANGO_BASE_URL") +env_base_url: str = env.str("DJANGO_BASE_URL") env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "") env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox") @@ -192,7 +195,7 @@ MIDDLEWARE = [ "registrar.registrar_middleware.CheckPortfolioMiddleware", ] -# application object used by Django’s built-in servers (e.g. `runserver`) +# application object used by Django's built-in servers (e.g. `runserver`) WSGI_APPLICATION = "registrar.config.wsgi.application" # endregion @@ -357,13 +360,18 @@ CSP_FORM_ACTION = allowed_sources # and inline with a nonce, as well as allowing connections back to their domain. # Note: If needed, we can embed chart.js instead of using the CDN CSP_DEFAULT_SRC = ("'self'",) -CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"] +CSP_STYLE_SRC = [ + "'self'", + "https://www.ssa.gov/accessibility/andi/andi.css", + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css", +] CSP_SCRIPT_SRC_ELEM = [ "'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js", "https://www.ssa.gov", "https://ajax.googleapis.com", + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js", ] CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"] CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"] @@ -410,7 +418,7 @@ LANGUAGE_COOKIE_SECURE = True # and to interpret datetimes entered in forms TIME_ZONE = "UTC" -# enable Django’s translation system +# enable Django's translation system USE_I18N = True # enable localized formatting of numbers and dates @@ -445,6 +453,40 @@ PHONENUMBER_DEFAULT_REGION = "US" # logger.error("Can't do this important task. Something is very wrong.") # logger.critical("Going to crash now.") + +class JsonFormatter(logging.Formatter): + """Formats logs into JSON for better parsing""" + + def __init__(self): + super().__init__(datefmt="%d/%b/%Y %H:%M:%S") + + def format(self, record): + log_record = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "name": record.name, + "lineno": record.lineno, + "message": record.getMessage(), + } + return json.dumps(log_record) + + +class JsonServerFormatter(ServerFormatter): + """Formats server logs into JSON for better parsing""" + + def format(self, record): + formatted_record = super().format(record) + log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record} + return json.dumps(log_entry) + + +# default to json formatted logs +server_formatter, console_formatter = "json.server", "json" + +# don't use json format locally, it makes logs hard to read in console +if "localhost" in env_base_url: + server_formatter, console_formatter = "django.server", "verbose" + LOGGING = { "version": 1, # Don't import Django's existing loggers @@ -464,6 +506,12 @@ LOGGING = { "format": "[{server_time}] {message}", "style": "{", }, + "json.server": { + "()": JsonServerFormatter, + }, + "json": { + "()": JsonFormatter, + }, }, # define where log messages will be sent; # each logger can have one or more handlers @@ -471,12 +519,12 @@ LOGGING = { "console": { "level": env_log_level, "class": "logging.StreamHandler", - "formatter": "verbose", + "formatter": console_formatter, }, "django.server": { "level": "INFO", "class": "logging.StreamHandler", - "formatter": "django.server", + "formatter": server_formatter, }, # No file logger is configured, # because containerized apps diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 50f0f99db..4c922ccbd 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -24,6 +24,7 @@ from registrar.views.report_views import ( from registrar.views.domain_request import Step from registrar.views.domain_requests_json import get_domain_requests_json +from registrar.views.transfer_user import TransferUserView from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json, @@ -53,7 +54,6 @@ for step, view in [ (Step.CURRENT_SITES, views.CurrentSites), (Step.DOTGOV_DOMAIN, views.DotgovDomain), (Step.PURPOSE, views.Purpose), - (Step.YOUR_CONTACT, views.YourContact), (Step.OTHER_CONTACTS, views.OtherContacts), (Step.ADDITIONAL_DETAILS, views.AdditionalDetails), (Step.REQUIREMENTS, views.Requirements), @@ -79,6 +79,11 @@ urlpatterns = [ views.PortfolioDomainRequestsView.as_view(), name="domain-requests", ), + path( + "no-organization-requests/", + views.PortfolioNoDomainRequestsView.as_view(), + name="no-portfolio-requests", + ), path( "organization/", views.PortfolioOrganizationView.as_view(), @@ -138,6 +143,7 @@ urlpatterns = [ AnalyticsView.as_view(), name="analytics", ), + path("admin/registrar/user//transfer/", TransferUserView.as_view(), name="transfer_user"), path( "admin/api/get-senior-official-from-federal-agency-json/", get_senior_official_from_federal_agency_json, @@ -212,11 +218,6 @@ urlpatterns = [ views.DomainDsDataView.as_view(), name="domain-dns-dnssec-dsdata", ), - path( - "domain//your-contact-information", - views.DomainYourContactInformationView.as_view(), - name="domain-your-contact-information", - ), path( "domain//org-name-address", views.DomainOrgNameAddressView.as_view(), diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index ea04dca80..41046ed1c 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -60,38 +60,42 @@ def add_has_profile_feature_flag_to_context(request): def portfolio_permissions(request): """Make portfolio permissions for the request user available in global context""" + portfolio_context = { + "has_base_portfolio_permission": False, + "has_any_domains_portfolio_permission": False, + "has_any_requests_portfolio_permission": False, + "has_edit_request_portfolio_permission": False, + "has_view_suborganization_portfolio_permission": False, + "has_edit_suborganization_portfolio_permission": False, + "has_view_members_portfolio_permission": False, + "has_edit_members_portfolio_permission": False, + "portfolio": None, + "has_organization_feature_flag": False, + "has_organization_requests_flag": False, + "has_organization_members_flag": False, + } try: portfolio = request.session.get("portfolio") + # Linting: line too long + view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio) + edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio) if portfolio: return { "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio), - "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio), - "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission( - portfolio - ), - "has_view_suborganization": request.user.has_view_suborganization(portfolio), - "has_edit_suborganization": request.user.has_edit_suborganization(portfolio), + "has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio), + "has_view_suborganization_portfolio_permission": view_suborg, + "has_edit_suborganization_portfolio_permission": edit_suborg, + "has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio), + "has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio), + "has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio), + "has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio), "portfolio": portfolio, "has_organization_feature_flag": True, + "has_organization_requests_flag": request.user.has_organization_requests_flag(), + "has_organization_members_flag": request.user.has_organization_members_flag(), } - return { - "has_base_portfolio_permission": False, - "has_domains_portfolio_permission": False, - "has_domain_requests_portfolio_permission": False, - "has_view_suborganization": False, - "has_edit_suborganization": False, - "portfolio": None, - "has_organization_feature_flag": False, - } + return portfolio_context except AttributeError: # Handles cases where request.user might not exist - return { - "has_base_portfolio_permission": False, - "has_domains_portfolio_permission": False, - "has_domain_requests_portfolio_permission": False, - "has_view_suborganization": False, - "has_edit_suborganization": False, - "portfolio": None, - "has_organization_feature_flag": False, - } + return portfolio_context diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py index a5ec3fc74..44dd13e4c 100644 --- a/src/registrar/fixtures_domain_requests.py +++ b/src/registrar/fixtures_domain_requests.py @@ -37,7 +37,6 @@ class DomainRequestFixture: # "anything_else": None, # "is_policy_acknowledged": None, # "senior_official": None, - # "submitter": None, # "other_contacts": [], # "current_websites": [], # "alternative_domains": [], @@ -123,12 +122,6 @@ class DomainRequestFixture: else: da.senior_official = Contact.objects.create(**cls.fake_contact()) - if not da.submitter: - if "submitter" in app and app["submitter"] is not None: - da.submitter, _ = Contact.objects.get_or_create(**app["submitter"]) - else: - da.submitter = Contact.objects.create(**cls.fake_contact()) - if not da.requested_domain: if "requested_domain" in app and app["requested_domain"] is not None: da.requested_domain, _ = DraftDomain.objects.get_or_create(name=app["requested_domain"]) diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 1b8eda9ab..7fbf41223 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -56,6 +56,7 @@ class UserFixture: "username": "8f8e7293-17f7-4716-889b-1990241cbd39", "first_name": "Katherine", "last_name": "Osos", + "email": "kosos@truss.works", }, { "username": "70488e0a-e937-4894-a28c-16f5949effd4", @@ -171,7 +172,7 @@ class UserFixture: "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44", "first_name": "Katherine-Analyst", "last_name": "Osos-Analyst", - "email": "kosos@truss.works", + "email": "kosos+1@truss.works", }, { "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8", diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index d97dd0de7..f2fdd32bc 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -386,64 +386,6 @@ class PurposeForm(RegistrarForm): ) -class YourContactForm(RegistrarForm): - JOIN = "submitter" - - def to_database(self, obj): - if not self.is_valid(): - return - contact = getattr(obj, "submitter", None) - if contact is not None and not contact.has_more_than_one_join("submitted_domain_requests"): - # if contact exists in the database and is not joined to other entities - super().to_database(contact) - else: - # no contact exists OR contact exists which is joined also to other entities; - # in either case, create a new contact and update it - contact = Contact() - super().to_database(contact) - obj.submitter = contact - obj.save() - - @classmethod - def from_database(cls, obj): - contact = getattr(obj, "submitter", None) - return super().from_database(contact) - - first_name = forms.CharField( - label="First name / given name", - error_messages={"required": "Enter your first name / given name."}, - ) - middle_name = forms.CharField( - required=False, - label="Middle name (optional)", - ) - last_name = forms.CharField( - label="Last name / family name", - error_messages={"required": "Enter your last name / family name."}, - ) - title = forms.CharField( - label="Title or role in your organization", - error_messages={ - "required": ("Enter your title or role in your organization (e.g., Chief Information Officer).") - }, - ) - email = forms.EmailField( - label="Email", - max_length=None, - error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], - ) - phone = PhoneNumberField( - label="Phone", - error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, - ) - - class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py index 5d4439d95..66b3e772f 100644 --- a/src/registrar/management/commands/clean_tables.py +++ b/src/registrar/management/commands/clean_tables.py @@ -21,7 +21,7 @@ class Command(BaseCommand): TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=""" + prompt_message=""" This script will delete all rows from the following tables: * Contact * Domain diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py new file mode 100644 index 000000000..d05a2911b --- /dev/null +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -0,0 +1,255 @@ +"""Loads files from /tmp into our sandboxes""" + +import argparse +import logging +from django.core.management import BaseCommand, CommandError +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper +from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Creates a federal portfolio given a FederalAgency name" + + def add_arguments(self, parser): + """Add three arguments: + 1. agency_name => the value of FederalAgency.agency + 2. --parse_requests => if true, adds the given portfolio to each related DomainRequest + 3. --parse_domains => if true, adds the given portfolio to each related DomainInformation + """ + parser.add_argument( + "agency_name", + help="The name of the FederalAgency to add", + ) + parser.add_argument( + "--parse_requests", + action=argparse.BooleanOptionalAction, + help="Adds portfolio to DomainRequests", + ) + parser.add_argument( + "--parse_domains", + action=argparse.BooleanOptionalAction, + help="Adds portfolio to DomainInformation", + ) + parser.add_argument( + "--both", + action=argparse.BooleanOptionalAction, + help="Adds portfolio to both requests and domains", + ) + + def handle(self, agency_name, **options): + parse_requests = options.get("parse_requests") + parse_domains = options.get("parse_domains") + both = options.get("both") + + if not both: + if not parse_requests and not parse_domains: + raise CommandError("You must specify at least one of --parse_requests or --parse_domains.") + else: + if parse_requests or parse_domains: + raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.") + + federal_agency = FederalAgency.objects.filter(agency__iexact=agency_name).first() + if not federal_agency: + raise ValueError( + f"Cannot find the federal agency '{agency_name}' in our database. " + "The value you enter for `agency_name` must be " + "prepopulated in the FederalAgency table before proceeding." + ) + + portfolio = self.create_or_modify_portfolio(federal_agency) + self.create_suborganizations(portfolio, federal_agency) + + if parse_requests or both: + self.handle_portfolio_requests(portfolio, federal_agency) + + if parse_domains or both: + self.handle_portfolio_domains(portfolio, federal_agency) + + def create_or_modify_portfolio(self, federal_agency): + """Creates or modifies a portfolio record based on a federal agency.""" + portfolio_args = { + "federal_agency": federal_agency, + "organization_name": federal_agency.agency, + "organization_type": DomainRequest.OrganizationChoices.FEDERAL, + "creator": User.get_default_user(), + "notes": "Auto-generated record", + } + + if federal_agency.so_federal_agency.exists(): + portfolio_args["senior_official"] = federal_agency.so_federal_agency.first() + + portfolio, created = Portfolio.objects.get_or_create( + organization_name=portfolio_args.get("organization_name"), defaults=portfolio_args + ) + + if created: + message = f"Created portfolio '{portfolio}'" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) + + if portfolio_args.get("senior_official"): + message = f"Added senior official '{portfolio_args['senior_official']}'" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) + else: + message = ( + f"No senior official added to portfolio '{portfolio}'. " + "None was returned for the reverse relation `FederalAgency.so_federal_agency.first()`" + ) + TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) + else: + proceed = TerminalHelper.prompt_for_execution( + system_exit_on_terminate=False, + prompt_message=f""" + The given portfolio '{federal_agency.agency}' already exists in our DB. + If you cancel, the rest of the script will still execute but this record will not update. + """, + prompt_title="Do you wish to modify this record?", + ) + if proceed: + + # Don't override the creator and notes fields + if portfolio.creator: + portfolio_args.pop("creator") + + if portfolio.notes: + portfolio_args.pop("notes") + + # Update everything else + for key, value in portfolio_args.items(): + setattr(portfolio, key, value) + + portfolio.save() + message = f"Modified portfolio '{portfolio}'" + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) + + if portfolio_args.get("senior_official"): + message = f"Added/modified senior official '{portfolio_args['senior_official']}'" + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) + + return portfolio + + def create_suborganizations(self, portfolio: Portfolio, federal_agency: FederalAgency): + """Create Suborganizations tied to the given portfolio based on DomainInformation objects""" + valid_agencies = DomainInformation.objects.filter( + federal_agency=federal_agency, organization_name__isnull=False + ) + org_names = set(valid_agencies.values_list("organization_name", flat=True)) + + if not org_names: + message = ( + "Could not add any suborganizations." + f"\nNo suborganizations were found for '{federal_agency}' when filtering on this name, " + "and excluding null organization_name records." + ) + TerminalHelper.colorful_logger(logger.warning, TerminalColors.FAIL, message) + return + + # Check if we need to update any existing suborgs first. This step is optional. + existing_suborgs = Suborganization.objects.filter(name__in=org_names) + if existing_suborgs.exists(): + self._update_existing_suborganizations(portfolio, existing_suborgs) + + # Create new suborgs, as long as they don't exist in the db already + new_suborgs = [] + for name in org_names - set(existing_suborgs.values_list("name", flat=True)): + # Stored in variables due to linter wanting type information here. + portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else "" + if name is not None and name.lower() == portfolio_name.lower(): + # You can use this to populate location information, when this occurs. + # However, this isn't needed for now so we can skip it. + message = ( + f"Skipping suborganization create on record '{name}'. " + "The federal agency name is the same as the portfolio name." + ) + TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message) + else: + new_suborgs.append(Suborganization(name=name, portfolio=portfolio)) # type: ignore + + if new_suborgs: + Suborganization.objects.bulk_create(new_suborgs) + TerminalHelper.colorful_logger( + logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations" + ) + else: + TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, "No suborganizations added") + + def _update_existing_suborganizations(self, portfolio, orgs_to_update): + """ + Update existing suborganizations with new portfolio. + Prompts for user confirmation before proceeding. + """ + proceed = TerminalHelper.prompt_for_execution( + system_exit_on_terminate=False, + prompt_message=f"""Some suborganizations already exist in our DB. + If you cancel, the rest of the script will still execute but these records will not update. + + ==Proposed Changes== + The following suborgs will be updated: {[org.name for org in orgs_to_update]} + """, + prompt_title="Do you wish to modify existing suborganizations?", + ) + if proceed: + for org in orgs_to_update: + org.portfolio = portfolio + + Suborganization.objects.bulk_update(orgs_to_update, ["portfolio"]) + message = f"Updated {len(orgs_to_update)} suborganizations." + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) + + def handle_portfolio_requests(self, portfolio: Portfolio, federal_agency: FederalAgency): + """ + Associate portfolio with domain requests for a federal agency. + Updates all relevant domain request records. + """ + invalid_states = [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.INELIGIBLE, + DomainRequest.DomainRequestStatus.REJECTED, + ] + domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states) + if not domain_requests.exists(): + message = f""" + Portfolios not added to domain requests: no valid records found. + This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. + Excluded statuses: STARTED, INELIGIBLE, REJECTED. + """ + TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) + return None + + # Get all suborg information and store it in a dict to avoid doing a db call + suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") + for domain_request in domain_requests: + domain_request.portfolio = portfolio + if domain_request.organization_name in suborgs: + domain_request.sub_organization = suborgs.get(domain_request.organization_name) + + DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"]) + message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests." + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) + + def handle_portfolio_domains(self, portfolio: Portfolio, federal_agency: FederalAgency): + """ + Associate portfolio with domains for a federal agency. + Updates all relevant domain information records. + """ + domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency) + if not domain_infos.exists(): + message = f""" + Portfolios not added to domains: no valid records found. + This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. + """ + TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) + return None + + # Get all suborg information and store it in a dict to avoid doing a db call + suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") + for domain_info in domain_infos: + domain_info.portfolio = portfolio + if domain_info.organization_name in suborgs: + domain_info.sub_organization = suborgs.get(domain_info.organization_name) + + DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"]) + message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) diff --git a/src/registrar/management/commands/extend_expiration_dates.py b/src/registrar/management/commands/extend_expiration_dates.py index cefc38b9e..ac083da1d 100644 --- a/src/registrar/management/commands/extend_expiration_dates.py +++ b/src/registrar/management/commands/extend_expiration_dates.py @@ -130,7 +130,7 @@ class Command(BaseCommand): """Asks if the user wants to proceed with this action""" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Extension Amount== Period: {extension_amount} year(s) diff --git a/src/registrar/management/commands/load_organization_data.py b/src/registrar/management/commands/load_organization_data.py index 122795400..35cc248ee 100644 --- a/src/registrar/management/commands/load_organization_data.py +++ b/src/registrar/management/commands/load_organization_data.py @@ -64,7 +64,7 @@ class Command(BaseCommand): # Will sys.exit() when prompt is "n" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Master data file== domain_additional_filename: {org_args.domain_additional_filename} @@ -84,7 +84,7 @@ class Command(BaseCommand): # Will sys.exit() when prompt is "n" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Master data file== domain_additional_filename: {org_args.domain_additional_filename} diff --git a/src/registrar/management/commands/load_senior_official_table.py b/src/registrar/management/commands/load_senior_official_table.py index 43f61d57a..cdbc607bf 100644 --- a/src/registrar/management/commands/load_senior_official_table.py +++ b/src/registrar/management/commands/load_senior_official_table.py @@ -27,7 +27,7 @@ class Command(BaseCommand): TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Proposed Changes== CSV: {federal_cio_csv_path} diff --git a/src/registrar/management/commands/load_transition_domain.py b/src/registrar/management/commands/load_transition_domain.py index 4132096c8..c2dd66f55 100644 --- a/src/registrar/management/commands/load_transition_domain.py +++ b/src/registrar/management/commands/load_transition_domain.py @@ -651,7 +651,7 @@ class Command(BaseCommand): title = "Do you wish to load additional data for TransitionDomains?" proceed = TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" !!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING ==Master data file== domain_additional_filename: {domain_additional_filename} diff --git a/src/registrar/management/commands/patch_federal_agency_info.py b/src/registrar/management/commands/patch_federal_agency_info.py index b286f1516..51a98ffaa 100644 --- a/src/registrar/management/commands/patch_federal_agency_info.py +++ b/src/registrar/management/commands/patch_federal_agency_info.py @@ -91,7 +91,7 @@ class Command(BaseCommand): # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Proposed Changes== Number of DomainInformation objects to change: {len(human_readable_domain_names)} The following DomainInformation objects will be modified: {human_readable_domain_names} @@ -148,7 +148,7 @@ class Command(BaseCommand): # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==File location== current-full.csv filepath: {file_path} diff --git a/src/registrar/management/commands/populate_first_ready.py b/src/registrar/management/commands/populate_first_ready.py index 9636476c2..04468029a 100644 --- a/src/registrar/management/commands/populate_first_ready.py +++ b/src/registrar/management/commands/populate_first_ready.py @@ -31,7 +31,7 @@ class Command(BaseCommand): # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Proposed Changes== Number of Domain objects to change: {len(domains)} """, diff --git a/src/registrar/management/commands/populate_organization_type.py b/src/registrar/management/commands/populate_organization_type.py index a7dd98b24..60d179cb8 100644 --- a/src/registrar/management/commands/populate_organization_type.py +++ b/src/registrar/management/commands/populate_organization_type.py @@ -54,7 +54,7 @@ class Command(BaseCommand): # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Proposed Changes== Number of DomainRequest objects to change: {len(domain_requests)} @@ -72,7 +72,7 @@ class Command(BaseCommand): # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" + prompt_message=f""" ==Proposed Changes== Number of DomainInformation objects to change: {len(domain_infos)} diff --git a/src/registrar/management/commands/transfer_transition_domains_to_domains.py b/src/registrar/management/commands/transfer_transition_domains_to_domains.py index 615df50a5..727db6dab 100644 --- a/src/registrar/management/commands/transfer_transition_domains_to_domains.py +++ b/src/registrar/management/commands/transfer_transition_domains_to_domains.py @@ -423,7 +423,7 @@ class Command(BaseCommand): valid_fed_type = fed_type in fed_choices valid_fed_agency = fed_agency in agency_choices - default_creator, _ = User.objects.get_or_create(username="System") + default_creator = User.get_default_user() new_domain_info_data = { "domain": domain, diff --git a/src/registrar/management/commands/update_first_ready.py b/src/registrar/management/commands/update_first_ready.py new file mode 100644 index 000000000..0a4ea10a7 --- /dev/null +++ b/src/registrar/management/commands/update_first_ready.py @@ -0,0 +1,38 @@ +import logging +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors +from registrar.models import Domain, TransitionDomain + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each domain object and populates the last_status_update and first_submitted_date" + + def handle(self, **kwargs): + """Loops through each valid Domain object and updates it's first_ready value if it is out of sync""" + filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]} + self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True) + + def update_record(self, record: Domain): + """Defines how we update the first_ready field""" + # update the first_ready value based on the creation date. + record.first_ready = record.created_at.date() + + logger.info( + f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}" + ) + + # check if a transition domain object for this domain name exists, + # or if so whether its first_ready value matches its created_at date + def custom_filter(self, records): + to_include_pks = [] + for record in records: + if ( + TransitionDomain.objects.filter(domain_name=record.name).exists() + and record.first_ready != record.created_at.date() + ): # noqa + to_include_pks.append(record.pk) + + return records.filter(pk__in=to_include_pks) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index b9e11be5d..fa7cde683 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -2,9 +2,12 @@ import logging import sys from abc import ABC, abstractmethod from django.core.paginator import Paginator +from django.db.models import Model +from django.db.models.manager import BaseManager from typing import List from registrar.utility.enums import LogCode + logger = logging.getLogger(__name__) @@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC): @abstractmethod def update_record(self, record): - """Defines how we update each field. Must be defined before using mass_update_records.""" + """Defines how we update each field. + + raises: + NotImplementedError: If not defined before calling mass_update_records. + """ raise NotImplementedError - def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True): + def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False): """Loops through each valid "object_class" object - specified by filter_conditions - and updates fields defined by fields_to_update using update_record. - You must define update_record before you can use this function. + Parameters: + object_class: The Django model class that you want to perform the bulk update on. + This should be the actual class, not a string of the class name. + + filter_conditions: dictionary of valid Django Queryset filter conditions + (e.g. {'verification_type__isnull'=True}). + + fields_to_update: List of strings specifying which fields to update. + (e.g. ["first_ready_date", "last_submitted_date"]) + + debug: Whether to log script run summary in debug mode. + Default: True. + + verbose: Whether to print a detailed run summary *before* run confirmation. + Default: False. + + Raises: + NotImplementedError: If you do not define update_record before using this function. + TypeError: If custom_filter is not Callable. """ records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all() + + # apply custom filter + records = self.custom_filter(records) + readable_class_name = self.get_class_name(object_class) + # for use in the execution prompt. + proposed_changes = f"""==Proposed Changes== + Number of {readable_class_name} objects to change: {len(records)} + These fields will be updated on each record: {fields_to_update} + """ + + if verbose: + proposed_changes = f"""{proposed_changes} + These records will be updated: {list(records.all())} + """ + # Code execution will stop here if the user prompts "N" TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - info_to_inspect=f""" - ==Proposed Changes== - Number of {readable_class_name} objects to change: {len(records)} - These fields will be updated on each record: {fields_to_update} - """, + prompt_message=proposed_changes, prompt_title=self.prompt_title, ) logger.info("Updating...") @@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC): return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}" def should_skip_record(self, record) -> bool: # noqa - """Defines the condition in which we should skip updating a record. Override as needed.""" + """Defines the condition in which we should skip updating a record. Override as needed. + The difference between this and custom_filter is that records matching these conditions + *will* be included in the run but will be skipped (and logged as such).""" # By default - don't skip return False + def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]: + """Override to define filters that can't be represented by django queryset field lookups. + Applied to individual records *after* filter_conditions. True means""" + return records + class TerminalHelper: @staticmethod @@ -220,6 +263,9 @@ class TerminalHelper: an answer is required of the user). The "answer" return value is True for "yes" or False for "no". + + Raises: + ValueError: When "default" is not "yes", "no", or None. """ valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False} if default is None: @@ -244,6 +290,7 @@ class TerminalHelper: @staticmethod def query_yes_no_exit(question: str, default="yes"): """Ask a yes/no question via raw_input() and return their answer. + Allows for answer "e" to exit. "question" is a string that is presented to the user. "default" is the presumed answer if the user just hits . @@ -251,6 +298,9 @@ class TerminalHelper: an answer is required of the user). The "answer" return value is True for "yes" or False for "no". + + Raises: + ValueError: When "default" is not "yes", "no", or None. """ valid = { "yes": True, @@ -317,9 +367,8 @@ class TerminalHelper: case _: logger.info(print_statement) - # TODO - "info_to_inspect" should be refactored to "prompt_message" @staticmethod - def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool: + def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool: """Create to reduce code complexity. Prompts the user to inspect the given string and asks if they wish to proceed. @@ -340,7 +389,7 @@ class TerminalHelper: ===================================================== *** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT *** - {info_to_inspect} + {prompt_message} {TerminalColors.FAIL} Proceed? (Y = proceed, N = {action_description_for_selecting_no}) {TerminalColors.ENDC}""" diff --git a/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py new file mode 100644 index 000000000..c14a70ab0 --- /dev/null +++ b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.10 on 2024-09-04 21:29 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0122_create_groups_v16"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="portfolio_additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_members", "View members"), + ("edit_members", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ("view_suborganization", "View suborganization"), + ("edit_suborganization", "Edit suborganization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_members", "View members"), + ("edit_members", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ("view_suborganization", "View suborganization"), + ("edit_suborganization", "Edit suborganization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/migrations/0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py b/src/registrar/migrations/0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py new file mode 100644 index 000000000..aab162de9 --- /dev/null +++ b/src/registrar/migrations/0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.10 on 2024-09-09 14:48 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="portfolio_additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_members", "View members"), + ("edit_members", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ("view_suborganization", "View suborganization"), + ("edit_suborganization", "Edit suborganization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_members", "View members"), + ("edit_members", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ("view_suborganization", "View suborganization"), + ("edit_suborganization", "Edit suborganization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/migrations/0125_alter_domaininformation_submitter_and_more.py b/src/registrar/migrations/0125_alter_domaininformation_submitter_and_more.py new file mode 100644 index 000000000..ea95cbc05 --- /dev/null +++ b/src/registrar/migrations/0125_alter_domaininformation_submitter_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.10 on 2024-08-29 23:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="domaininformation", + name="submitter", + field=models.ForeignKey( + blank=True, + help_text='Person listed under "your contact information" in the request form', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="submitted_domain_requests_information", + to="registrar.contact", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="submitter", + field=models.ForeignKey( + blank=True, + help_text='Person listed under "your contact information" in the request form; will receive email updates', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="submitted_domain_requests", + to="registrar.contact", + ), + ), + ] diff --git a/src/registrar/migrations/0126_delete_cascade_submitter_contacts.py b/src/registrar/migrations/0126_delete_cascade_submitter_contacts.py new file mode 100644 index 000000000..d798ae283 --- /dev/null +++ b/src/registrar/migrations/0126_delete_cascade_submitter_contacts.py @@ -0,0 +1,27 @@ +from django.db import migrations +from django.db.models import Q +from typing import Any + + +# Deletes Contact objects associated with a submitter which we are deprecating +def cascade_delete_submitter_contacts(apps, schema_editor) -> Any: + contacts_model = apps.get_model("registrar", "Contact") + submitter_contacts = contacts_model.objects.filter( + Q(submitted_domain_requests__isnull=False) | Q(submitted_domain_requests_information__isnull=False) + ).filter( + information_senior_official__isnull=True, + senior_official__isnull=True, + contact_domain_requests_information__isnull=True, + contact_domain_requests__isnull=True, + ) + submitter_contacts.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0125_alter_domaininformation_submitter_and_more"), + ] + + operations = [ + migrations.RunPython(cascade_delete_submitter_contacts, reverse_code=migrations.RunPython.noop, atomic=True), + ] diff --git a/src/registrar/migrations/0127_remove_domaininformation_submitter_and_more.py b/src/registrar/migrations/0127_remove_domaininformation_submitter_and_more.py new file mode 100644 index 000000000..78c764e0f --- /dev/null +++ b/src/registrar/migrations/0127_remove_domaininformation_submitter_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.10 on 2024-08-29 24:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0126_delete_cascade_submitter_contacts"), + ] + + operations = [ + migrations.RemoveField( + model_name="domaininformation", + name="submitter", + ), + migrations.RemoveField( + model_name="domainrequest", + name="submitter", + ), + migrations.AlterField( + model_name="domainrequest", + name="creator", + field=models.ForeignKey( + help_text="Person who submitted the domain request. Will receive email updates.", + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_requests_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 774dba897..03b8cc047 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -48,8 +48,7 @@ class DomainInformation(TimeStampedModel): null=True, ) - # This is the domain request user who created this domain request. The contact - # information that they gave is in the `submitter` field + # This is the domain request user who created this domain request. creator = models.ForeignKey( "registrar.User", on_delete=models.PROTECT, @@ -197,17 +196,6 @@ class DomainInformation(TimeStampedModel): related_name="domain_info", ) - # This is the contact information provided by the domain requestor. The - # user who created the domain request is in the `creator` field. - submitter = models.ForeignKey( - "registrar.Contact", - null=True, - blank=True, - related_name="submitted_domain_requests_information", - on_delete=models.PROTECT, - help_text='Person listed under "your contact information" in the request form', - ) - purpose = models.TextField( null=True, blank=True, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index b80e063cd..babc955aa 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -6,7 +6,6 @@ from django.conf import settings from django.db import models from django_fsm import FSMField, transition # type: ignore from django.utils import timezone -from waffle import flag_is_active from registrar.models.domain import Domain from registrar.models.federal_agency import FederalAgency from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper @@ -339,13 +338,12 @@ class DomainRequest(TimeStampedModel): help_text="The suborganization that this domain request is included under", ) - # This is the domain request user who created this domain request. The contact - # information that they gave is in the `submitter` field + # This is the domain request user who created this domain request. creator = models.ForeignKey( "registrar.User", on_delete=models.PROTECT, related_name="domain_requests_created", - help_text="Person who submitted the domain request; will not receive email updates", + help_text="Person who submitted the domain request. Will receive email updates.", ) investigator = models.ForeignKey( @@ -483,17 +481,6 @@ class DomainRequest(TimeStampedModel): help_text="Other domain names the creator provided for consideration", ) - # This is the contact information provided by the domain requestor. The - # user who created the domain request is in the `creator` field. - submitter = models.ForeignKey( - "registrar.Contact", - null=True, - blank=True, - related_name="submitted_domain_requests", - on_delete=models.PROTECT, - help_text='Person listed under "your contact information" in the request form; will receive email updates', - ) - purpose = models.TextField( null=True, blank=True, @@ -741,9 +728,6 @@ class DomainRequest(TimeStampedModel): contact information. If there is not creator information, then do nothing. - If the waffle flag "profile_feature" is active, then this email will be sent to the - domain request creator rather than the submitter - Optional args: bcc_address: str -> the address to bcc to @@ -758,7 +742,7 @@ class DomainRequest(TimeStampedModel): custom_email_content: str -> Renders an email with the content of this string as its body text. """ - recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter + recipient = self.creator if recipient is None or recipient.email is None: logger.warning( f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}." @@ -1182,6 +1166,10 @@ class DomainRequest(TimeStampedModel): # Special District -> "Election office" and "About your organization" page can't be empty return self.is_election_board is not None and self.about_your_organization is not None + # Do we still want to test this after creator is autogenerated? Currently it went back to being selectable + def _is_creator_complete(self): + return self.creator is not None + def _is_organization_name_and_address_complete(self): return not ( self.organization_name is None @@ -1200,9 +1188,6 @@ class DomainRequest(TimeStampedModel): def _is_purpose_complete(self): return self.purpose is not None - def _is_submitter_complete(self): - return self.submitter is not None - def _has_other_contacts_and_filled(self): # Other Contacts Radio button is Yes and if all required fields are filled return ( @@ -1251,14 +1236,12 @@ class DomainRequest(TimeStampedModel): return self.is_policy_acknowledged is not None def _is_general_form_complete(self, request): - has_profile_feature_flag = flag_is_active(request, "profile_feature") return ( - self._is_organization_name_and_address_complete() + self._is_creator_complete() + and self._is_organization_name_and_address_complete() and self._is_senior_official_complete() and self._is_requested_domain_complete() and self._is_purpose_complete() - # NOTE: This flag leaves submitter as empty (request wont submit) hence set to True - and (self._is_submitter_complete() if not has_profile_feature_flag else True) and self._is_other_contacts_complete() and self._is_additional_details_complete() and self._is_policy_acknowledgement_complete() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 8d91c2a8c..929a63525 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -6,7 +6,7 @@ from django.db.models import Q from django.http import HttpRequest from registrar.models import DomainInformation, UserDomainRole -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices from .domain_invitation import DomainInvitation from .portfolio_invitation import PortfolioInvitation @@ -64,32 +64,6 @@ class User(AbstractUser): # after they login. FIXTURE_USER = "fixture_user", "Created by fixtures" - PORTFOLIO_ROLE_PERMISSIONS = { - UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBER, - UserPortfolioPermissionChoices.EDIT_MEMBER, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, - UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - UserPortfolioPermissionChoices.EDIT_PORTFOLIO, - # Domain: field specific permissions - UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, - UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, - ], - UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBER, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - # Domain: field specific permissions - UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, - ], - UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - ], - } - # #### Constants for choice fields #### RESTRICTED = "restricted" STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) @@ -157,6 +131,12 @@ class User(AbstractUser): else: return self.username + @classmethod + def get_default_user(cls): + """Returns the default "system" user""" + default_creator, _ = User.objects.get_or_create(username="System") + return default_creator + def restrict_user(self): self.status = self.RESTRICTED self.save() @@ -218,25 +198,63 @@ class User(AbstractUser): def has_edit_org_portfolio_permission(self, portfolio): return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO) - def has_domains_portfolio_permission(self, portfolio): + def has_any_domains_portfolio_permission(self, portfolio): return self._has_portfolio_permission( portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) - def has_domain_requests_portfolio_permission(self, portfolio): - return self._has_portfolio_permission( - portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS - ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + def has_organization_requests_flag(self): + request = HttpRequest() + request.user = self + return flag_is_active(request, "organization_requests") - def has_view_all_domains_permission(self, portfolio): + def has_organization_members_flag(self): + request = HttpRequest() + request.user = self + return flag_is_active(request, "organization_members") + + def has_view_members_portfolio_permission(self, portfolio): + # BEGIN + # Note code below is to add organization_request feature + if not self.has_organization_members_flag(): + return False + # END + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS) + + def has_edit_members_portfolio_permission(self, portfolio): + # BEGIN + # Note code below is to add organization_request feature + if not self.has_organization_members_flag(): + return False + # END + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS) + + def has_view_all_domains_portfolio_permission(self, portfolio): """Determines if the current user can view all available domains in a given portfolio""" return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + def has_any_requests_portfolio_permission(self, portfolio): + # BEGIN + # Note code below is to add organization_request feature + if not self.has_organization_requests_flag(): + return False + # END + return self._has_portfolio_permission( + portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) + + def has_view_all_requests_portfolio_permission(self, portfolio): + """Determines if the current user can view all available domain requests in a given portfolio""" + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + + def has_edit_request_portfolio_permission(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) + # Field specific permission checks - def has_view_suborganization(self, portfolio): + def has_view_suborganization_portfolio_permission(self, portfolio): return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) - def has_edit_suborganization(self, portfolio): + def has_edit_suborganization_portfolio_permission(self, portfolio): return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) def get_first_portfolio(self): @@ -245,36 +263,36 @@ class User(AbstractUser): return permission.portfolio return None - def has_edit_requests(self, portfolio): - return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) - def portfolio_role_summary(self, portfolio): """Returns a list of roles based on the user's permissions.""" roles = [] # Define the conditions and their corresponding roles conditions_roles = [ - (self.has_edit_suborganization(portfolio), ["Admin"]), + (self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]), ( - self.has_view_all_domains_permission(portfolio) - and self.has_domain_requests_portfolio_permission(portfolio) - and self.has_edit_requests(portfolio), + self.has_view_all_domains_portfolio_permission(portfolio) + and self.has_any_requests_portfolio_permission(portfolio) + and self.has_edit_request_portfolio_permission(portfolio), ["View-only admin", "Domain requestor"], ), ( - self.has_view_all_domains_permission(portfolio) - and self.has_domain_requests_portfolio_permission(portfolio), + self.has_view_all_domains_portfolio_permission(portfolio) + and self.has_any_requests_portfolio_permission(portfolio), ["View-only admin"], ), ( self.has_base_portfolio_permission(portfolio) - and self.has_edit_requests(portfolio) - and self.has_domains_portfolio_permission(portfolio), + and self.has_edit_request_portfolio_permission(portfolio) + and self.has_any_domains_portfolio_permission(portfolio), ["Domain requestor", "Domain manager"], ), - (self.has_base_portfolio_permission(portfolio) and self.has_edit_requests(portfolio), ["Domain requestor"]), ( - self.has_base_portfolio_permission(portfolio) and self.has_domains_portfolio_permission(portfolio), + self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio), + ["Domain requestor"], + ), + ( + self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio), ["Domain manager"], ), (self.has_base_portfolio_permission(portfolio), ["Member"]), @@ -288,6 +306,9 @@ class User(AbstractUser): return roles + def get_portfolios(self): + return self.portfolio_permissions.all() + @classmethod def needs_identity_verification(cls, email, uuid): """A method used by our oidc classes to test whether a user needs email/uuid verification @@ -433,8 +454,6 @@ class User(AbstractUser): self.check_domain_invitations_on_login() self.check_portfolio_invitations_on_login() - # NOTE TO DAVE: I'd simply suggest that we move these functions outside of the user object, - # and move them to some sort of utility file. That way we aren't calling request inside here. def is_org_user(self, request): has_organization_feature_flag = flag_is_active(request, "organization_feature") portfolio = request.session.get("portfolio") @@ -443,7 +462,7 @@ class User(AbstractUser): def get_user_domain_ids(self, request): """Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" portfolio = request.session.get("portfolio") - if self.is_org_user(request) and self.has_view_all_domains_permission(portfolio): + if self.is_org_user(request) and self.has_view_all_domains_portfolio_permission(portfolio): return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) else: return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index bf1c3e566..0c2487df3 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -16,8 +16,8 @@ class UserPortfolioPermission(TimeStampedModel): PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBER, - UserPortfolioPermissionChoices.EDIT_MEMBER, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, @@ -28,7 +28,7 @@ class UserPortfolioPermission(TimeStampedModel): ], UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBER, + UserPortfolioPermissionChoices.VIEW_MEMBERS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, # Domain: field specific permissions diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 86aaa5e16..7f34221fd 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -17,11 +17,10 @@ class UserPortfolioPermissionChoices(models.TextChoices): VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" - VIEW_MEMBER = "view_member", "View members" - EDIT_MEMBER = "edit_member", "Create and edit members" + VIEW_MEMBERS = "view_members", "View members" + EDIT_MEMBERS = "edit_members", "Create and edit members" VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" EDIT_REQUESTS = "edit_requests", "Create and edit requests" VIEW_PORTFOLIO = "view_portfolio", "View organization" diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 4b590db1e..6207591ba 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -6,7 +6,7 @@ import logging from urllib.parse import parse_qs from django.urls import reverse from django.http import HttpResponseRedirect -from registrar.models.user import User +from registrar.models import User from waffle.decorators import flag_is_active from registrar.models.utility.generic_helper import replace_url_queryparams @@ -144,25 +144,30 @@ class CheckPortfolioMiddleware: if not request.user.is_authenticated: return None - # set the portfolio in the session if it is not set - if "portfolio" not in request.session or request.session["portfolio"] is None: - # if multiple portfolios are allowed for this user - if flag_is_active(request, "multiple_portfolios"): - # NOTE: we will want to change later to have a workflow for selecting - # portfolio and another for switching portfolio; for now, select first - request.session["portfolio"] = request.user.get_first_portfolio() - elif flag_is_active(request, "organization_feature"): - request.session["portfolio"] = request.user.get_first_portfolio() - else: - request.session["portfolio"] = None + # if multiple portfolios are allowed for this user + if flag_is_active(request, "organization_feature"): + self.set_portfolio_in_session(request) + elif request.session.get("portfolio"): + # Edge case: User disables flag while already logged in + request.session["portfolio"] = None + elif "portfolio" not in request.session: + # Set the portfolio in the session if its not already in it + request.session["portfolio"] = None - if request.session["portfolio"] is not None and current_path == self.home: - if request.user.is_org_user(request): - if request.user.has_domains_portfolio_permission(request.session["portfolio"]): + if request.user.is_org_user(request): + if current_path == self.home: + if request.user.has_any_domains_portfolio_permission(request.session["portfolio"]): portfolio_redirect = reverse("domains") else: portfolio_redirect = reverse("no-portfolio-domains") - return HttpResponseRedirect(portfolio_redirect) return None + + def set_portfolio_in_session(self, request): + # NOTE: we will want to change later to have a workflow for selecting + # portfolio and another for switching portfolio; for now, select first + if flag_is_active(request, "multiple_portfolios"): + request.session["portfolio"] = request.user.get_first_portfolio() + else: + request.session["portfolio"] = request.user.get_first_portfolio() diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index 13db3b60a..7c1a09c78 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -5,7 +5,7 @@ {% block content %} -
+
@@ -29,28 +29,28 @@ +
+
+ +
+
+{% endblock %} diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 327110b60..d9c800feb 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -89,7 +89,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endif %} {% elif field.field.name == "requested_domain" %} {% with current_path=request.get_full_path %} - {{ original.requested_domain }} + {{ original.requested_domain }} {% endwith%} {% elif field.field.name == "current_websites" %} {% comment %} @@ -242,11 +242,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if not skip_additional_contact_info %} {% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %} {% endif%} - {% elif field.field.name == "submitter" %} -
- - {% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %} -
{% elif field.field.name == "senior_official" %}
diff --git a/src/registrar/templates/django/admin/user_change_form.html b/src/registrar/templates/django/admin/user_change_form.html index 005d67aec..736f12ba4 100644 --- a/src/registrar/templates/django/admin/user_change_form.html +++ b/src/registrar/templates/django/admin/user_change_form.html @@ -1,7 +1,42 @@ {% extends 'django/admin/email_clipboard_change_form.html' %} {% load i18n static %} + +{% block field_sets %} + + + {% for fieldset in adminform %} + {% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %} + {% endfor %} +{% endblock %} + {% block after_related_objects %} + {% if portfolios %} +
+

Portfolio information

+
+
+

Portfolios

+ +
+
+
+ {% endif %} +

Associated requests and domains

diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index d7bc277b3..dd08004a3 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -72,9 +72,9 @@ {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} {% endif %} - {% if portfolio and has_domains_portfolio_permission and has_view_suborganization %} + {% if portfolio and has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %} {% url 'domain-suborganization' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization %} + {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %} {% else %} {% url 'domain-org-name-address' pk=domain.id as url %} {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %} diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 1dd1e1abe..6e18bce13 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -63,7 +63,7 @@
-
- - - -{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index f73f8079f..0c123948e 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -5,10 +5,13 @@
- {% if not has_domain_requests_portfolio_permission %} + {% if not portfolio %}

Domain requests

+ {% else %} + + {% endif %}
@@ -45,7 +48,10 @@ Domain name - Date submitted + Submitted + {% if portfolio %} + Created by + {% endif %} Status Action diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index c00409ff8..414325e89 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -9,7 +9,6 @@
{% if not portfolio %}

Domains

- {% else %} @@ -157,8 +156,8 @@ Domain name Expires Status - {% if portfolio and has_view_suborganization %} - Suborganization + {% if portfolio and has_view_suborganization_portfolio_permission %} + Suborganization {% endif %}
  • - {% if has_domains_portfolio_permission %} + {% if has_any_domains_portfolio_permission %} {% url 'domains' as url %} - {%else %} + {% else %} {% url 'no-portfolio-domains' as url %} {% endif %} Domains
  • -
  • + + + {% if has_organization_requests_flag %} +
  • + + {% if has_edit_request_portfolio_permission %} {% url 'domain-requests' as url %} + + + + {% elif has_any_requests_portfolio_permission %} + {% url 'domain-requests' as url %} + + Domain requests + + + {% else %} + {% url 'no-portfolio-requests' as url %} Domain requests -
  • + {% endif %} + {% endif %} + + {% if has_organization_members_flag %}
  • Members
  • + {% endif %} +
  • {% url 'organization' as url %} diff --git a/src/registrar/templates/no_portfolio_domains.html b/src/registrar/templates/portfolio_no_domains.html similarity index 100% rename from src/registrar/templates/no_portfolio_domains.html rename to src/registrar/templates/portfolio_no_domains.html diff --git a/src/registrar/templates/portfolio_no_requests.html b/src/registrar/templates/portfolio_no_requests.html new file mode 100644 index 000000000..c8eb3fe6e --- /dev/null +++ b/src/registrar/templates/portfolio_no_requests.html @@ -0,0 +1,30 @@ +{% extends 'portfolio_base.html' %} + +{% load static %} + +{% block title %} Domain Requests | {% endblock %} + +{% block portfolio_content %} +

    Current domain requests

    +
    +
    +

    You don’t have access to domain requests.

    + {% if portfolio_administrators %} +

    If you believe you should have access to a request, reach out to your organization’s administrators.

    +

    Your organizations administrators:

    +
      + {% for administrator in portfolio_administrators %} + {% if administrator.email %} +
    • {{ administrator.email }}
    • + {% else %} +
    • {{ administrator }}
    • + {% endif %} + {% endfor %} +
    + {% else %} +

    No administrators were found on your organization.

    +

    If you believe you should have access to a request, email help@get.gov.

    + {% endif %} +
    +
    +{% endblock %} diff --git a/src/registrar/templates/portfolio_requests.html b/src/registrar/templates/portfolio_requests.html index 9f97a25aa..70d17feae 100644 --- a/src/registrar/templates/portfolio_requests.html +++ b/src/registrar/templates/portfolio_requests.html @@ -11,18 +11,30 @@ {% block portfolio_content %}

    Domain requests

    +
    - {% comment %} - IMPORTANT: - If this button is added on any other page, make sure to update the - relevant view to reset request.session["new_request"] = True - {% endcomment %} -

    - - Start a new domain request - -

    + {% if has_edit_request_portfolio_permission %} +
    +

    Domain requests can only be modified by the person who created the request.

    +
    +
    + {% comment %} + IMPORTANT: + If this button is added on any other page, make sure to update the + relevant view to reset request.session["new_request"] = True + {% endcomment %} +

    + + Start a new domain request + +

    +
    + {% else %} +

    Domain requests can only be modified by the person who created the request.

    + {% endif %} +
    + {% include "includes/domain_requests_table.html" with portfolio=portfolio %}
    diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index bcd45f103..c75fa1940 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -394,7 +394,6 @@ class AuditedAdminMockData: about_your_organization: str = "e-Government", anything_else: str = "There is more", senior_official: Contact = self.dummy_contact(item_name, "senior_official"), - submitter: Contact = self.dummy_contact(item_name, "submitter"), creator: User = self.dummy_user(item_name, "creator"), } """ # noqa @@ -412,7 +411,6 @@ class AuditedAdminMockData: about_your_organization="e-Government", anything_else="There is more", senior_official=self.dummy_contact(item_name, "senior_official"), - submitter=self.dummy_contact(item_name, "submitter"), creator=creator, ) return common_args @@ -901,7 +899,6 @@ def completed_domain_request( # noqa has_cisa_representative=True, status=DomainRequest.DomainRequestStatus.STARTED, user=False, - submitter=False, name="city.gov", investigator=None, generic_org_type="federal", @@ -911,6 +908,7 @@ def completed_domain_request( # noqa federal_type=None, action_needed_reason=None, portfolio=None, + organization_name=None, ): """A completed domain request.""" if not user: @@ -925,14 +923,6 @@ def completed_domain_request( # noqa domain, _ = DraftDomain.objects.get_or_create(name=name) alt, _ = Website.objects.get_or_create(website="city1.gov") current, _ = Website.objects.get_or_create(website="city.com") - if not submitter: - submitter, _ = Contact.objects.get_or_create( - first_name="Testy2", - last_name="Tester2", - title="Admin Tester", - email="mayor@igorville.gov", - phone="(555) 555 5556", - ) other, _ = Contact.objects.get_or_create( first_name="Testy", last_name="Tester", @@ -954,14 +944,13 @@ def completed_domain_request( # noqa federal_type="executive", purpose="Purpose of the site", is_policy_acknowledged=True, - organization_name="Testorg", + organization_name=organization_name if organization_name else "Testorg", address_line1="address 1", address_line2="address 2", state_territory="NY", zipcode="10002", senior_official=so, requested_domain=domain, - submitter=submitter, creator=user, status=status, investigator=investigator, diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 93e611c1a..88482e4db 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2,6 +2,7 @@ from datetime import datetime from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite +from django_webtest import WebTest # type: ignore from api.tests.common import less_console_noise_decorator from django.urls import reverse from registrar.admin import ( @@ -41,13 +42,12 @@ from registrar.models import ( TransitionDomain, Portfolio, Suborganization, + UserPortfolioPermission, + UserDomainRole, + SeniorOfficial, + PortfolioInvitation, + VerifiedByStaff, ) -from registrar.models.portfolio_invitation import PortfolioInvitation -from registrar.models.senior_official import SeniorOfficial -from registrar.models.user_domain_role import UserDomainRole -from registrar.models.user_portfolio_permission import UserPortfolioPermission -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from registrar.models.verified_by_staff import VerifiedByStaff from .common import ( MockDbForSharedTests, AuditedAdminMockData, @@ -60,9 +60,12 @@ from .common import ( multiple_unalphabetical_domain_objects, GenericTestHelper, ) +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model -from unittest.mock import patch, Mock + +from unittest.mock import ANY, patch, Mock + import logging @@ -382,7 +385,7 @@ class TestDomainInformationAdmin(TestCase): contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson") domain_request = completed_domain_request( - submitter=contact, name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW + name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW ) domain_request.approve() @@ -507,7 +510,6 @@ class TestDomainInformationAdmin(TestCase): # These should exist in the response expected_values = [ ("creator", "Person who submitted the domain request"), - ("submitter", 'Person listed under "your contact information" in the request form'), ("domain_request", "Request associated with this domain"), ("no_other_contacts_rationale", "Required if creator does not list other employees"), ("urbanization", "Required for Puerto Rico only"), @@ -631,16 +633,6 @@ class TestDomainInformationAdmin(TestCase): # Check for the field itself self.assertContains(response, "Meoward Jones") - # == Check for the submitter == # - self.assertContains(response, "mayor@igorville.gov", count=2) - expected_submitter_fields = [ - # Field, expected value - ("title", "Admin Tester"), - ("phone", "(555) 555 5556"), - ] - self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) - self.assertContains(response, "Testy2 Tester2") - # == Check for the senior_official == # self.assertContains(response, "testy@town.com", count=2) expected_so_fields = [ @@ -662,7 +654,7 @@ class TestDomainInformationAdmin(TestCase): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "button--clipboard", count=4) + self.assertContains(response, "button--clipboard", count=3) # cleanup this test domain_info.delete() @@ -686,7 +678,6 @@ class TestDomainInformationAdmin(TestCase): "more_organization_information", "domain", "domain_request", - "submitter", "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", @@ -705,19 +696,19 @@ class TestDomainInformationAdmin(TestCase): # Assert that sorting in reverse works correctly self.test_helper.assert_table_sorted("-1", ("-domain__name",)) - def test_submitter_sortable(self): - """Tests if DomainInformation sorts by submitter correctly""" + def test_creator_sortable(self): + """Tests if DomainInformation sorts by creator correctly""" with less_console_noise(): self.client.force_login(self.superuser) # Assert that our sort works correctly self.test_helper.assert_table_sorted( "4", - ("submitter__first_name", "submitter__last_name"), + ("creator__first_name", "creator__last_name"), ) # Assert that sorting in reverse works correctly - self.test_helper.assert_table_sorted("-4", ("-submitter__first_name", "-submitter__last_name")) + self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name")) class TestUserDomainRoleAdmin(TestCase): @@ -972,7 +963,7 @@ class TestListHeaderAdmin(TestCase): ) -class TestMyUserAdmin(MockDbForSharedTests): +class TestMyUserAdmin(MockDbForSharedTests, WebTest): """Tests for the MyUserAdmin class as super or staff user Notes: @@ -992,6 +983,7 @@ class TestMyUserAdmin(MockDbForSharedTests): def setUp(self): super().setUp() + self.app.set_user(self.superuser.username) self.client = Client(HTTP_HOST="localhost:8080") def tearDown(self): @@ -1226,6 +1218,20 @@ class TestMyUserAdmin(MockDbForSharedTests): self.assertNotContains(response, "Portfolio roles:") self.assertNotContains(response, "Portfolio additional permissions:") + @less_console_noise_decorator + def test_user_can_see_related_portfolios(self): + """Tests if a user can see the portfolios they are associated with on the user page""" + portfolio, _ = Portfolio.objects.get_or_create(organization_name="test", creator=self.superuser) + permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + response = self.app.get(reverse("admin:registrar_user_change", args=[self.superuser.pk])) + expected_href = reverse("admin:registrar_portfolio_change", args=[portfolio.pk]) + self.assertContains(response, expected_href) + self.assertContains(response, str(portfolio)) + permission.delete() + portfolio.delete() + class AuditedAdminTest(TestCase): @@ -1300,7 +1306,6 @@ class AuditedAdminTest(TestCase): # Senior offical is commented out for now - this is alphabetized # and this test does not accurately reflect that. # DomainRequest.senior_official.field, - DomainRequest.submitter.field, # DomainRequest.investigator.field, DomainRequest.creator.field, DomainRequest.requested_domain.field, @@ -1360,7 +1365,6 @@ class AuditedAdminTest(TestCase): # Senior offical is commented out for now - this is alphabetized # and this test does not accurately reflect that. # DomainInformation.senior_official.field, - DomainInformation.submitter.field, # DomainInformation.creator.field, (DomainInformation.domain.field, ["name"]), (DomainInformation.domain_request.field, ["requested_domain__name"]), @@ -1669,91 +1673,6 @@ class TestContactAdmin(TestCase): self.assertEqual(readonly_fields, expected_fields) - def test_change_view_for_joined_contact_five_or_less(self): - """Create a contact, join it to 4 domain requests. - Assert that the warning on the contact form lists 4 joins.""" - with less_console_noise(): - self.client.force_login(self.superuser) - - # Create an instance of the model - contact, _ = Contact.objects.get_or_create( - first_name="Henry", - last_name="McFakerson", - ) - - # join it to 4 domain requests. - domain_request1 = completed_domain_request(submitter=contact, name="city1.gov") - domain_request2 = completed_domain_request(submitter=contact, name="city2.gov") - domain_request3 = completed_domain_request(submitter=contact, name="city3.gov") - domain_request4 = completed_domain_request(submitter=contact, name="city4.gov") - - with patch("django.contrib.messages.warning") as mock_warning: - # Use the test client to simulate the request - response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk])) - - # Assert that the error message was called with the correct argument - # Note: The 5th join will be a user. - mock_warning.assert_called_once_with( - response.wsgi_request, - "", - ) - - # cleanup this test - DomainRequest.objects.all().delete() - contact.delete() - - def test_change_view_for_joined_contact_five_or_more(self): - """Create a contact, join it to 6 domain requests. - Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis.""" - with less_console_noise(): - self.client.force_login(self.superuser) - # Create an instance of the model - # join it to 6 domain requests. - contact, _ = Contact.objects.get_or_create( - first_name="Henry", - last_name="McFakerson", - ) - domain_request1 = completed_domain_request(submitter=contact, name="city1.gov") - domain_request2 = completed_domain_request(submitter=contact, name="city2.gov") - domain_request3 = completed_domain_request(submitter=contact, name="city3.gov") - domain_request4 = completed_domain_request(submitter=contact, name="city4.gov") - domain_request5 = completed_domain_request(submitter=contact, name="city5.gov") - completed_domain_request(submitter=contact, name="city6.gov") - with patch("django.contrib.messages.warning") as mock_warning: - # Use the test client to simulate the request - response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk])) - logger.debug(mock_warning) - # Assert that the error message was called with the correct argument - # Note: The 6th join will be a user. - mock_warning.assert_called_once_with( - response.wsgi_request, - "" - "

    And 1 more...

    ", - ) - # cleanup this test - DomainRequest.objects.all().delete() - contact.delete() - class TestVerifiedByStaffAdmin(TestCase): @@ -2208,3 +2127,222 @@ class TestPortfolioAdmin(TestCase): self.assertIn("Agent Smith", display_members) self.assertIn("Domain requestor", display_members) self.assertIn("Program", display_members) + + +class TestTransferUser(WebTest): + """User transfer custom admin page""" + + # csrf checks do not work well with WebTest. + # We disable them here. + csrf_checks = False + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.superuser = create_superuser() + cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) + cls.factory = RequestFactory() + + def setUp(self): + self.app.set_user(self.superuser) + self.user1, _ = User.objects.get_or_create( + username="madmax", first_name="Max", last_name="Rokatanski", title="Road warrior" + ) + self.user2, _ = User.objects.get_or_create( + username="furiosa", first_name="Furiosa", last_name="Jabassa", title="Imperator" + ) + + def tearDown(self): + Suborganization.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + Portfolio.objects.all().delete() + UserDomainRole.objects.all().delete() + + @less_console_noise_decorator + def test_transfer_user_shows_current_and_selected_user_information(self): + """Assert we pull the current user info and display it on the transfer page""" + completed_domain_request(user=self.user1, name="wasteland.gov") + domain_request = completed_domain_request( + user=self.user1, name="citadel.gov", status=DomainRequest.DomainRequestStatus.SUBMITTED + ) + domain_request.status = DomainRequest.DomainRequestStatus.APPROVED + domain_request.save() + portfolio1 = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2) + UserPortfolioPermission.objects.create( + user=self.user1, portfolio=portfolio1, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + portfolio2 = Portfolio.objects.create(organization_name="Tokyo Hotel", creator=self.user2) + UserPortfolioPermission.objects.create( + user=self.user2, portfolio=portfolio2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + + self.assertContains(user_transfer_page, "madmax") + self.assertContains(user_transfer_page, "Max") + self.assertContains(user_transfer_page, "Rokatanski") + self.assertContains(user_transfer_page, "Road warrior") + self.assertContains(user_transfer_page, "wasteland.gov") + self.assertContains(user_transfer_page, "citadel.gov") + self.assertContains(user_transfer_page, "Hotel California") + + select_form = user_transfer_page.forms[0] + select_form["selected_user"] = str(self.user2.id) + preview_result = select_form.submit() + + self.assertContains(preview_result, "furiosa") + self.assertContains(preview_result, "Furiosa") + self.assertContains(preview_result, "Jabassa") + self.assertContains(preview_result, "Imperator") + self.assertContains(preview_result, "Tokyo Hotel") + + @less_console_noise_decorator + def test_transfer_user_transfers_user_portfolio_roles(self): + """Assert that a portfolio user role gets transferred""" + portfolio = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2) + user_portfolio_permission = UserPortfolioPermission.objects.create( + user=self.user2, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + + user_portfolio_permission.refresh_from_db() + + self.assertEquals(user_portfolio_permission.user, self.user1) + + @less_console_noise_decorator + def test_transfer_user_transfers_domain_request_creator_and_investigator(self): + """Assert that domain request fields get transferred""" + domain_request = completed_domain_request(user=self.user2, name="wasteland.gov", investigator=self.user2) + + self.assertEquals(domain_request.creator, self.user2) + self.assertEquals(domain_request.investigator, self.user2) + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + domain_request.refresh_from_db() + + self.assertEquals(domain_request.creator, self.user1) + self.assertEquals(domain_request.investigator, self.user1) + + @less_console_noise_decorator + def test_transfer_user_transfers_domain_information_creator(self): + """Assert that domain fields get transferred""" + domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user2) + + self.assertEquals(domain_information.creator, self.user2) + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + domain_information.refresh_from_db() + + self.assertEquals(domain_information.creator, self.user1) + + @less_console_noise_decorator + def test_transfer_user_transfers_domain_role(self): + """Assert that user domain role get transferred""" + domain_1, _ = Domain.objects.get_or_create(name="chrome.gov", state=Domain.State.READY) + domain_2, _ = Domain.objects.get_or_create(name="v8.gov", state=Domain.State.READY) + user_domain_role1, _ = UserDomainRole.objects.get_or_create( + user=self.user2, domain=domain_1, role=UserDomainRole.Roles.MANAGER + ) + user_domain_role2, _ = UserDomainRole.objects.get_or_create( + user=self.user2, domain=domain_2, role=UserDomainRole.Roles.MANAGER + ) + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + user_domain_role1.refresh_from_db() + user_domain_role2.refresh_from_db() + + self.assertEquals(user_domain_role1.user, self.user1) + self.assertEquals(user_domain_role2.user, self.user1) + + @less_console_noise_decorator + def test_transfer_user_transfers_verified_by_staff_requestor(self): + """Assert that verified by staff creator gets transferred""" + vip, _ = VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com") + + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + vip.refresh_from_db() + + self.assertEquals(vip.requestor, self.user1) + + @less_console_noise_decorator + def test_transfer_user_deletes_old_user(self): + """Assert that the slected user gets deleted""" + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit() + # Refresh user2 from the database and check if it still exists + with self.assertRaises(User.DoesNotExist): + self.user2.refresh_from_db() + + @less_console_noise_decorator + def test_transfer_user_throws_transfer_and_delete_success_messages(self): + """Test that success messages for data transfer and user deletion are displayed.""" + # Ensure the setup for VerifiedByStaff + VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com") + + # Access the transfer user page + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + + with patch("django.contrib.messages.success") as mock_success_message: + + # Fill the form with the selected user and submit + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + after_submit = submit_form.submit().follow() + + self.assertContains(after_submit, "

    Change user

    ") + + mock_success_message.assert_any_call( + ANY, + ( + "Data transferred successfully for the following objects: ['Changed requestor " + + 'from "Furiosa Jabassa " to "Max Rokatanski " on immortan.joe@citadel.com\']' + ), + ) + + mock_success_message.assert_any_call(ANY, f"Deleted {self.user2} {self.user2.username}") + + @less_console_noise_decorator + def test_transfer_user_throws_error_message(self): + """Test that an error message is thrown if the transfer fails.""" + with patch( + "registrar.views.TransferUserView.transfer_user_fields_and_log", side_effect=Exception("Simulated Error") + ): + with patch("django.contrib.messages.error") as mock_error: + # Access the transfer user page + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + + # Fill the form with the selected user and submit + submit_form = user_transfer_page.forms[1] + submit_form["selected_user"] = self.user2.pk + submit_form.submit().follow() + + # Assert that the error message was called with the correct argument + mock_error.assert_called_once_with(ANY, "An error occurred during the transfer: Simulated Error") + + @less_console_noise_decorator + def test_transfer_user_modal(self): + """Assert modal on page""" + user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk])) + self.assertContains(user_transfer_page, "This action cannot be undone.") diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 385a00800..49f095a25 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -427,13 +427,6 @@ class TestDomainAdminWithClient(TestCase): # Check for the field itself self.assertContains(response, "Meoward Jones") - # == Check for the submitter == # - self.assertContains(response, "mayor@igorville.gov") - - self.assertContains(response, "Admin Tester") - self.assertContains(response, "(555) 555 5556") - self.assertContains(response, "Testy2 Tester2") - # == Check for the senior_official == # self.assertContains(response, "testy@town.com") self.assertContains(response, "Chief Tester") diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 1aec3ad5d..d4e10db6b 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -37,6 +37,7 @@ from .common import ( GenericTestHelper, ) from unittest.mock import patch +from waffle.testutils import override_flag from django.conf import settings import boto3_mocking # type: ignore @@ -100,7 +101,7 @@ class TestDomainRequestAdmin(MockEppLib): SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title") contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson") - domain_request = completed_domain_request(submitter=contact, name="city1.gov") + domain_request = completed_domain_request(name="city1.gov") request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) model_admin = AuditedAdmin(DomainRequest, self.site) @@ -155,11 +156,7 @@ class TestDomainRequestAdmin(MockEppLib): # These should exist in the response expected_values = [ - ("creator", "Person who submitted the domain request; will not receive email updates"), - ( - "submitter", - 'Person listed under "your contact information" in the request form; will receive email updates', - ), + ("creator", "Person who submitted the domain request. Will receive email updates"), ("approved_domain", "Domain associated with this request; will be blank until request is approved"), ("no_other_contacts_rationale", "Required if creator does not list other employees"), ("alternative_domains", "Other domain names the creator provided for consideration"), @@ -442,8 +439,8 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",)) @less_console_noise_decorator - def test_submitter_sortable(self): - """Tests if the DomainRequest sorts by submitter correctly""" + def test_creator_sortable(self): + """Tests if the DomainRequest sorts by creator correctly""" self.client.force_login(self.superuser) multiple_unalphabetical_domain_objects("domain_request") @@ -457,8 +454,8 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_table_sorted( "13", ( - "submitter__first_name", - "submitter__last_name", + "creator__first_name", + "creator__last_name", ), ) @@ -466,8 +463,8 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_table_sorted( "-13", ( - "-submitter__first_name", - "-submitter__last_name", + "-creator__first_name", + "-creator__last_name", ), ) @@ -665,15 +662,24 @@ class TestDomainRequestAdmin(MockEppLib): def test_action_needed_sends_reason_email_prod_bcc(self): """When an action needed reason is set, an email is sent out and help@get.gov is BCC'd in production""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" + # Create fake creator + EMAIL = "meoward.jones@igorville.gov" + + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email=EMAIL, + phone="(555) 123 12345", + title="Treat inspector", + ) + BCC_EMAIL = settings.DEFAULT_FROM_EMAIL - User.objects.filter(email=EMAIL).delete() in_review = DomainRequest.DomainRequestStatus.IN_REVIEW action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED # Create a sample domain request - domain_request = completed_domain_request(status=in_review) + domain_request = completed_domain_request(status=in_review, user=_creator) # Test the email sent out for already_has_domains already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS @@ -702,7 +708,7 @@ class TestDomainRequestAdmin(MockEppLib): questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) self.assert_email_is_accurate( - "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL + "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) @@ -723,7 +729,7 @@ class TestDomainRequestAdmin(MockEppLib): ) domain_request.refresh_from_db() - self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL) + self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) # Tests if a new email gets sent when just the email is changed. @@ -747,7 +753,9 @@ class TestDomainRequestAdmin(MockEppLib): action_needed_reason=eligibility_unclear, action_needed_reason_email="custom content when starting anew", ) - self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL) + self.assert_email_is_accurate( + "custom content when starting anew", 5, _creator.email, bcc_email_address=BCC_EMAIL + ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) # def test_action_needed_sends_reason_email_prod_bcc(self): @@ -811,22 +819,31 @@ class TestDomainRequestAdmin(MockEppLib): Also test that the default email set in settings is NOT BCCd on non-prod whenever an email does go out.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + EMAIL = "meoward.jones@igorville.gov" - # Create a sample domain request - domain_request = completed_domain_request() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email=EMAIL, + phone="(555) 123 12345", + title="Treat inspector", + ) + + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Test Submitted Status from started self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, True) + self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, True) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Test Withdrawn Status self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL, True + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, _creator.email, True ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @@ -868,30 +885,37 @@ class TestDomainRequestAdmin(MockEppLib): Also test that the default email set in settings IS BCCd on prod whenever an email does go out.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) BCC_EMAIL = settings.DEFAULT_FROM_EMAIL - # Create a sample domain request - domain_request = completed_domain_request() + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Test Submitted Status from started self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) + self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, False, BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Test Withdrawn Status self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, _creator.email ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) # Test Submitted Status Again (from withdrawn) self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) + self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, False, BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) # Move it to IN_REVIEW @@ -916,21 +940,29 @@ class TestDomainRequestAdmin(MockEppLib): self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + @override_flag("profile_feature", True) @less_console_noise_decorator def test_save_model_sends_approved_email(self): """When transitioning to approved on a domain request, an email is sent out every time.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Test Submitted Status self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Test Withdrawn Status @@ -939,7 +971,7 @@ class TestDomainRequestAdmin(MockEppLib): DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.DOMAIN_PURPOSE, ) - self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL) + self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) # Test Submitted Status Again (No new email should be sent) @@ -951,12 +983,19 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is domain purpose.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason DOMAIN_PURPOSE and test email self.transition_state_and_send_email( @@ -967,13 +1006,13 @@ class TestDomainRequestAdmin(MockEppLib): self.assert_email_is_accurate( "Your domain request was rejected because the purpose you provided did not meet our \nrequirements.", 0, - EMAIL, + _creator.email, ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator @@ -981,12 +1020,19 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is requestor.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason REQUESTOR and test email including dynamic organization name self.transition_state_and_send_email( @@ -996,13 +1042,13 @@ class TestDomainRequestAdmin(MockEppLib): "Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov " "domain on behalf of Testorg", 0, - EMAIL, + _creator.email, ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator @@ -1010,12 +1056,19 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is second domain.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason SECOND_DOMAIN_REASONING and test email including dynamic organization name self.transition_state_and_send_email( @@ -1023,25 +1076,35 @@ class TestDomainRequestAdmin(MockEppLib): DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, ) - self.assert_email_is_accurate("Your domain request was rejected because Testorg has a .gov domain.", 0, EMAIL) + self.assert_email_is_accurate( + "Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email + ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator def test_save_model_sends_rejected_email_contacts_or_org_legitimacy(self): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is contacts or org legitimacy.""" + # Create fake creator - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + EMAIL = "meoward.jones@igorville.gov" + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email=EMAIL, + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason CONTACTS_OR_ORGANIZATION_LEGITIMACY and test email including dynamic organization name self.transition_state_and_send_email( @@ -1053,13 +1116,13 @@ class TestDomainRequestAdmin(MockEppLib): "Your domain request was rejected because we could not verify the organizational \n" "contacts you provided. If you have questions or comments, reply to this email.", 0, - EMAIL, + _creator.email, ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator @@ -1067,12 +1130,19 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is org eligibility.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason ORGANIZATION_ELIGIBILITY and test email including dynamic organization name self.transition_state_and_send_email( @@ -1084,26 +1154,32 @@ class TestDomainRequestAdmin(MockEppLib): "Your domain request was rejected because we determined that Testorg is not \neligible for " "a .gov domain.", 0, - EMAIL, + _creator.email, ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator def test_save_model_sends_rejected_email_naming(self): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is naming.""" + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name self.transition_state_and_send_email( @@ -1112,13 +1188,13 @@ class TestDomainRequestAdmin(MockEppLib): DomainRequest.RejectionReasons.NAMING_REQUIREMENTS, ) self.assert_email_is_accurate( - "Your domain request was rejected because it does not meet our naming requirements.", 0, EMAIL + "Your domain request was rejected because it does not meet our naming requirements.", 0, _creator.email ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator @@ -1126,12 +1202,19 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to rejected on a domain request, an email is sent explaining why when the reason is other.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelist user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name self.transition_state_and_send_email( @@ -1139,12 +1222,12 @@ class TestDomainRequestAdmin(MockEppLib): DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.OTHER, ) - self.assert_email_is_accurate("Choosing a .gov domain name", 0, EMAIL) + self.assert_email_is_accurate("Choosing a .gov domain name", 0, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Approve self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) @less_console_noise_decorator @@ -1208,23 +1291,30 @@ class TestDomainRequestAdmin(MockEppLib): """When transitioning to withdrawn on a domain request, an email is sent out every time.""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Create a sample domain request and whitelists user email + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + AllowedEmail.objects.get_or_create(email=_creator.email) # Test Submitted Status self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, _creator.email ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) # Test Withdrawn Status self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 1, EMAIL) + self.assert_email_is_accurate("We received your .gov domain request.", 1, _creator.email) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) # Test Submitted Status Again (No new email should be sent) @@ -1403,16 +1493,6 @@ class TestDomainRequestAdmin(MockEppLib): # Check for the field itself self.assertContains(response, "Meoward Jones") - # == Check for the submitter == # - self.assertContains(response, "mayor@igorville.gov", count=2) - expected_submitter_fields = [ - # Field, expected value - ("title", "Admin Tester"), - ("phone", "(555) 555 5556"), - ] - self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) - self.assertContains(response, "Testy2 Tester2") - # == Check for the senior_official == # self.assertContains(response, "testy@town.com", count=2) expected_so_fields = [ @@ -1433,7 +1513,7 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "button--clipboard", count=5) + self.assertContains(response, "button--clipboard", count=4) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") @@ -1568,7 +1648,6 @@ class TestDomainRequestAdmin(MockEppLib): "senior_official", "approved_domain", "requested_domain", - "submitter", "purpose", "no_other_contacts_rationale", "anything_else", @@ -1607,7 +1686,6 @@ class TestDomainRequestAdmin(MockEppLib): "approved_domain", "alternative_domains", "purpose", - "submitter", "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index e699d9b75..55b9267e4 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -7,7 +7,7 @@ from waffle.testutils import override_flag from registrar.utility import email from registrar.utility.email import send_templated_email from .common import completed_domain_request -from registrar.models import AllowedEmail +from registrar.models import AllowedEmail, User from api.tests.common import less_console_noise_decorator from datetime import datetime @@ -93,7 +93,6 @@ class TestEmails(TestCase): # check for optional things self.assertIn("Other employees from your organization:", body) - self.assertIn("Testy2 Tester2", body) self.assertIn("Current websites:", body) self.assertIn("city.com", body) self.assertIn("About your organization:", body) @@ -130,14 +129,20 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_other_contacts_spacing(self): """Test line spacing with other contacts.""" - domain_request = completed_domain_request(has_other_contacts=True) + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", first_name="Meoward", last_name="Jones", phone="(888) 888 8888" + ) + + # Create a fake domain request + domain_request = completed_domain_request(has_other_contacts=True, user=_creator) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn("Other employees from your organization:", body) - # spacing should be right between adjacent elements - self.assertRegex(body, r"5556\n\nOther employees") + self.assertRegex(body, r"8888\n\nOther employees") self.assertRegex(body, r"5557\n\nAnything else") @boto3_mocking.patching @@ -150,7 +155,6 @@ class TestEmails(TestCase): _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] # spacing should be right between adjacent elements - self.assertRegex(body, r"5556\n\nOther employees") self.assertRegex(body, r"None\n\nAnything else") @boto3_mocking.patching diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index a8d85597b..eb4cad040 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -10,7 +10,6 @@ from registrar.forms.domain_request_wizard import ( DotGovDomainForm, SeniorOfficialForm, OrganizationContactForm, - YourContactForm, OtherContactsForm, RequirementsForm, TribalGovernmentForm, @@ -366,19 +365,6 @@ class TestFormValidation(MockEppLib): ["Response must be less than 2000 characters."], ) - def test_your_contact_email_invalid(self): - """must be a valid email address.""" - form = YourContactForm(data={"email": "boss@boss"}) - self.assertEqual( - form.errors["email"], - ["Enter your email address in the required format, like name@example.com."], - ) - - def test_your_contact_phone_invalid(self): - """Must be a valid phone number.""" - form = YourContactForm(data={"phone": "boss@boss"}) - self.assertTrue(form.errors["phone"][0].startswith("Enter a valid 10-digit phone number.")) - def test_other_contact_email_invalid(self): """must be a valid email address.""" form = OtherContactsForm(data={"email": "splendid@boss"}) diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 1958454f5..cbdc2c034 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -1,4 +1,5 @@ import copy +import boto3_mocking # type: ignore from datetime import date, datetime, time from django.core.management import call_command from django.test import TestCase, override_settings @@ -8,6 +9,7 @@ from django.utils import timezone from django.utils.module_loading import import_string import logging import pyzipper +from django.core.management.base import CommandError from registrar.management.commands.clean_tables import Command as CleanTablesCommand from registrar.management.commands.export_tables import Command as ExportTablesCommand from registrar.models import ( @@ -23,14 +25,17 @@ from registrar.models import ( VerifiedByStaff, PublicContact, FederalAgency, + Portfolio, + Suborganization, ) import tablib from unittest.mock import patch, call, MagicMock, mock_open from epplibwrapper import commands, common -from .common import MockEppLib, less_console_noise, completed_domain_request +from .common import MockEppLib, less_console_noise, completed_domain_request, MockSESClient from api.tests.common import less_console_noise_decorator + logger = logging.getLogger(__name__) @@ -1408,3 +1413,137 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase): missing_agency.refresh_from_db() self.assertIsNone(missing_agency.initials) self.assertIsNone(missing_agency.is_fceb) + + +class TestCreateFederalPortfolio(TestCase): + + @less_console_noise_decorator + def setUp(self): + self.mock_client = MockSESClient() + self.user = User.objects.create(username="testuser") + self.federal_agency = FederalAgency.objects.create(agency="Test Federal Agency") + self.senior_official = SeniorOfficial.objects.create( + first_name="first", last_name="last", email="testuser@igorville.gov", federal_agency=self.federal_agency + ) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + self.domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.CITY, + federal_agency=self.federal_agency, + user=self.user, + ) + self.domain_request.approve() + self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get() + + self.domain_request_2 = completed_domain_request( + name="sock@igorville.org", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.CITY, + federal_agency=self.federal_agency, + user=self.user, + organization_name="Test Federal Agency", + ) + self.domain_request_2.approve() + self.domain_info_2 = DomainInformation.objects.filter(domain_request=self.domain_request_2).get() + + def tearDown(self): + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() + SeniorOfficial.objects.all().delete() + FederalAgency.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + def run_create_federal_portfolio(self, agency_name, parse_requests=False, parse_domains=False): + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", + return_value=True, + ): + call_command( + "create_federal_portfolio", agency_name, parse_requests=parse_requests, parse_domains=parse_domains + ) + + def test_create_or_modify_portfolio(self): + """Test portfolio creation and modification with suborg and senior official.""" + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + portfolio = Portfolio.objects.get(federal_agency=self.federal_agency) + self.assertEqual(portfolio.organization_name, self.federal_agency.agency) + self.assertEqual(portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL) + self.assertEqual(portfolio.creator, User.get_default_user()) + self.assertEqual(portfolio.notes, "Auto-generated record") + + # Test the suborgs + suborganizations = Suborganization.objects.filter(portfolio__federal_agency=self.federal_agency) + self.assertEqual(suborganizations.count(), 1) + self.assertEqual(suborganizations.first().name, "Testorg") + + # Test the senior official + self.assertEqual(portfolio.senior_official, self.senior_official) + + def test_handle_portfolio_requests(self): + """Verify portfolio association with domain requests.""" + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + self.domain_request.refresh_from_db() + self.assertIsNotNone(self.domain_request.portfolio) + self.assertEqual(self.domain_request.portfolio.federal_agency, self.federal_agency) + self.assertEqual(self.domain_request.sub_organization.name, "Testorg") + + def test_handle_portfolio_domains(self): + """Check portfolio association with domain information.""" + self.run_create_federal_portfolio("Test Federal Agency", parse_domains=True) + + self.domain_info.refresh_from_db() + self.assertIsNotNone(self.domain_info.portfolio) + self.assertEqual(self.domain_info.portfolio.federal_agency, self.federal_agency) + self.assertEqual(self.domain_info.sub_organization.name, "Testorg") + + def test_handle_parse_both(self): + """Ensure correct parsing of both requests and domains.""" + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True, parse_domains=True) + + self.domain_request.refresh_from_db() + self.domain_info.refresh_from_db() + self.assertIsNotNone(self.domain_request.portfolio) + self.assertIsNotNone(self.domain_info.portfolio) + self.assertEqual(self.domain_request.portfolio, self.domain_info.portfolio) + + def test_command_error_no_parse_options(self): + """Verify error when no parse options are provided.""" + with self.assertRaisesRegex( + CommandError, "You must specify at least one of --parse_requests or --parse_domains." + ): + self.run_create_federal_portfolio("Test Federal Agency") + + def test_command_error_agency_not_found(self): + """Check error handling for non-existent agency.""" + expected_message = ( + "Cannot find the federal agency 'Non-existent Agency' in our database. " + "The value you enter for `agency_name` must be prepopulated in the FederalAgency table before proceeding." + ) + with self.assertRaisesRegex(ValueError, expected_message): + self.run_create_federal_portfolio("Non-existent Agency", parse_requests=True) + + def test_update_existing_portfolio(self): + """Test updating an existing portfolio.""" + # Create an existing portfolio + existing_portfolio = Portfolio.objects.create( + federal_agency=self.federal_agency, + organization_name="Test Federal Agency", + organization_type=DomainRequest.OrganizationChoices.CITY, + creator=self.user, + notes="Old notes", + ) + + self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True) + + existing_portfolio.refresh_from_db() + self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency) + self.assertEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL) + + # Notes and creator should be untouched + self.assertEqual(existing_portfolio.notes, "Old notes") + self.assertEqual(existing_portfolio.creator, self.user) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index f2e9a4bfa..23eff1edc 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -170,7 +170,6 @@ class TestDomainRequest(TestCase): zipcode="12345-6789", senior_official=contact, requested_domain=domain, - submitter=contact, purpose="Igorville rules!", anything_else="All of Igorville loves the dotgov program.", is_policy_acknowledged=True, @@ -197,7 +196,6 @@ class TestDomainRequest(TestCase): state_territory="CA", zipcode="12345-6789", senior_official=contact, - submitter=contact, purpose="Igorville rules!", anything_else="All of Igorville loves the dotgov program.", is_policy_acknowledged=True, @@ -225,7 +223,7 @@ class TestDomainRequest(TestCase): site = DraftDomain.objects.create(name="igorville.gov") domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) - # no submitter email so this emits a log warning + # no email sent to creator so this emits a log warning with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with less_console_noise(): @@ -262,15 +260,17 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_submit_from_started_sends_email(self): msg = "Create a domain request and submit it and see if email was sent." - domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2) - self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello") + domain_request = completed_domain_request(user=self.dummy_user_2) + self.check_email_sent( + domain_request, msg, "submit", 1, expected_content="Lava", expected_email=self.dummy_user_2.email + ) @override_flag("profile_feature", active=True) @less_console_noise_decorator def test_submit_from_started_sends_email_to_creator(self): """Tests if, when the profile feature flag is on, we send an email to the creator""" msg = "Create a domain request and submit it and see if email was sent when the feature flag is on." - domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2) + domain_request = completed_domain_request(user=self.dummy_user_2) self.check_email_sent( domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com" ) @@ -278,10 +278,9 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_submit_from_withdrawn_sends_email(self): msg = "Create a withdrawn domain request and submit it and see if email was sent." - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.WITHDRAWN, submitter=self.dummy_user - ) - self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello") + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user) + self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email) @less_console_noise_decorator def test_submit_from_action_needed_does_not_send_email(self): @@ -298,26 +297,25 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_approve_sends_email(self): msg = "Create a domain request and approve it and see if email was sent." - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user - ) - self.check_email_sent(domain_request, msg, "approve", 1, expected_content="Hello") + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) + self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email) @less_console_noise_decorator def test_withdraw_sends_email(self): msg = "Create a domain request and withdraw it and see if email was sent." - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) + self.check_email_sent( + domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email ) - self.check_email_sent(domain_request, msg, "withdraw", 1, expected_content="Hello") @less_console_noise_decorator def test_reject_sends_email(self): msg = "Create a domain request and reject it and see if email was sent." - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.APPROVED, submitter=self.dummy_user - ) - self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hello") + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) + self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) @less_console_noise_decorator def test_reject_with_prejudice_does_not_send_email(self): @@ -1135,7 +1133,7 @@ class TestPortfolioInvitations(TestCase): self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California") self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN - self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS + self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS self.invitation, _ = PortfolioInvitation.objects.get_or_create( email=self.email, @@ -1326,16 +1324,16 @@ class TestUser(TestCase): User.objects.all().delete() UserDomainRole.objects.all().delete() - @patch.object(User, "has_edit_suborganization", return_value=True) + @patch.object(User, "has_edit_suborganization_portfolio_permission", return_value=True) def test_portfolio_role_summary_admin(self, mock_edit_suborganization): # Test if the user is recognized as an Admin self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"]) @patch.multiple( User, - has_view_all_domains_permission=lambda self, portfolio: True, - has_domain_requests_portfolio_permission=lambda self, portfolio: True, - has_edit_requests=lambda self, portfolio: True, + has_view_all_domains_portfolio_permission=lambda self, portfolio: True, + has_any_requests_portfolio_permission=lambda self, portfolio: True, + has_edit_request_portfolio_permission=lambda self, portfolio: True, ) def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self): # Test if the user has both 'View-only admin' and 'Domain requestor' roles @@ -1343,8 +1341,8 @@ class TestUser(TestCase): @patch.multiple( User, - has_view_all_domains_permission=lambda self, portfolio: True, - has_domain_requests_portfolio_permission=lambda self, portfolio: True, + has_view_all_domains_portfolio_permission=lambda self, portfolio: True, + has_any_requests_portfolio_permission=lambda self, portfolio: True, ) def test_portfolio_role_summary_view_only_admin(self): # Test if the user is recognized as a View-only admin @@ -1353,15 +1351,17 @@ class TestUser(TestCase): @patch.multiple( User, has_base_portfolio_permission=lambda self, portfolio: True, - has_edit_requests=lambda self, portfolio: True, - has_domains_portfolio_permission=lambda self, portfolio: True, + has_edit_request_portfolio_permission=lambda self, portfolio: True, + has_any_domains_portfolio_permission=lambda self, portfolio: True, ) def test_portfolio_role_summary_member_domain_requestor_domain_manager(self): # Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"]) @patch.multiple( - User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True + User, + has_base_portfolio_permission=lambda self, portfolio: True, + has_edit_request_portfolio_permission=lambda self, portfolio: True, ) def test_portfolio_role_summary_member_domain_requestor(self): # Test if the user has 'Member' and 'Domain requestor' roles @@ -1370,7 +1370,7 @@ class TestUser(TestCase): @patch.multiple( User, has_base_portfolio_permission=lambda self, portfolio: True, - has_domains_portfolio_permission=lambda self, portfolio: True, + has_any_domains_portfolio_permission=lambda self, portfolio: True, ) def test_portfolio_role_summary_member_domain_manager(self): # Test if the user has 'Member' and 'Domain manager' roles @@ -1385,6 +1385,74 @@ class TestUser(TestCase): # Test if the user has no roles self.assertEqual(self.user.portfolio_role_summary(self.portfolio), []) + @patch("registrar.models.User._has_portfolio_permission") + def test_has_base_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_base_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_edit_org_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_edit_org_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_any_domains_portfolio_permission(self, mock_has_permission): + mock_has_permission.side_effect = [False, True] # First permission false, second permission true + + self.assertTrue(self.user.has_any_domains_portfolio_permission(self.portfolio)) + self.assertEqual(mock_has_permission.call_count, 2) + mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_view_all_domains_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_view_all_domains_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + + @patch("registrar.models.User._has_portfolio_permission") + @override_flag("organization_requests", active=True) + def test_has_any_requests_portfolio_permission(self, mock_has_permission): + mock_has_permission.side_effect = [False, True] # First permission false, second permission true + + self.assertTrue(self.user.has_any_requests_portfolio_permission(self.portfolio)) + self.assertEqual(mock_has_permission.call_count, 2) + mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_view_all_requests_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_view_all_requests_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_edit_request_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_edit_request_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_view_suborganization_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_view_suborganization_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) + + @patch("registrar.models.User._has_portfolio_permission") + def test_has_edit_suborganization_portfolio_permission(self, mock_has_permission): + mock_has_permission.return_value = True + + self.assertTrue(self.user.has_edit_suborganization_portfolio_permission(self.portfolio)) + mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) + @less_console_noise_decorator def test_check_transition_domains_without_domains_on_login(self): """A user's on_each_login callback does not check transition domains. @@ -1534,6 +1602,7 @@ class TestUser(TestCase): self.assertFalse(self.user.has_contact_info()) @less_console_noise_decorator + @override_flag("organization_requests", active=True) def test_has_portfolio_permission(self): """ 0. Returns False when user does not have a permission @@ -1546,8 +1615,8 @@ class TestUser(TestCase): portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") - user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) + user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio) self.assertFalse(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) @@ -1555,11 +1624,14 @@ class TestUser(TestCase): portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( portfolio=portfolio, user=self.user, - additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ], ) - user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) + user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) @@ -1568,16 +1640,16 @@ class TestUser(TestCase): portfolio_permission.save() portfolio_permission.refresh_from_db() - user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) + user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) UserDomainRole.objects.get_or_create(user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER) - user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) + user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) @@ -2065,7 +2137,6 @@ class TestDomainRequestIncomplete(TestCase): senior_official=so, requested_domain=draft_domain, purpose="Some purpose", - submitter=you, no_other_contacts_rationale=None, has_cisa_representative=True, cisa_representative_email="somerep@cisa.com", @@ -2194,13 +2265,6 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.save() self.assertFalse(self.domain_request._is_purpose_complete()) - @less_console_noise_decorator - def test_is_submitter_complete(self): - self.assertTrue(self.domain_request._is_submitter_complete()) - self.domain_request.submitter = None - self.domain_request.save() - self.assertFalse(self.domain_request._is_submitter_complete()) - @less_console_noise_decorator def test_is_other_contacts_complete_missing_one_field(self): self.assertTrue(self.domain_request._is_other_contacts_complete()) diff --git a/src/registrar/tests/test_url_auth.py b/src/registrar/tests/test_url_auth.py index 3a045498a..284ec7638 100644 --- a/src/registrar/tests/test_url_auth.py +++ b/src/registrar/tests/test_url_auth.py @@ -25,6 +25,7 @@ SAMPLE_KWARGS = { "domain": "whitehouse.gov", "user_pk": "1", "portfolio_id": "1", + "user_id": "1", } # Our test suite will ignore some namespaces. diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 6c2ad6b5e..2ae582b0e 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -369,19 +369,12 @@ class HomeTests(TestWithUser): last_name="Mars", ) - # Attach a user object to a contact (should not be deleted) - contact_user, _ = Contact.objects.get_or_create( - first_name="Hank", - last_name="McFakey", - ) - site = DraftDomain.objects.create(name="igorville.gov") domain_request = DomainRequest.objects.create( creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN, senior_official=contact, - submitter=contact_user, ) domain_request.other_contacts.set([contact_2]) @@ -392,7 +385,6 @@ class HomeTests(TestWithUser): requested_domain=site_2, status=DomainRequest.DomainRequestStatus.STARTED, senior_official=contact_2, - submitter=contact_shared, ) domain_request_2.other_contacts.set([contact_shared]) @@ -409,8 +401,6 @@ class HomeTests(TestWithUser): # Check if the orphaned contacts were deleted orphan = Contact.objects.filter(id=contact.id) self.assertFalse(orphan.exists()) - orphan = Contact.objects.filter(id=contact_user.id) - self.assertFalse(orphan.exists()) try: edge_case = Contact.objects.filter(id=contact_2.id).get() @@ -455,7 +445,6 @@ class HomeTests(TestWithUser): requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN, senior_official=contact, - submitter=contact_user, ) domain_request.other_contacts.set([contact_2]) @@ -466,7 +455,6 @@ class HomeTests(TestWithUser): requested_domain=site_2, status=DomainRequest.DomainRequestStatus.STARTED, senior_official=contact_2, - submitter=contact_shared, ) domain_request_2.other_contacts.set([contact_shared]) @@ -1010,20 +998,6 @@ class UserProfileTests(TestWithUser, WebTest): self.assertContains(response, "Your profile") self.assertNotContains(response, "Your contact information") - @less_console_noise_decorator - def test_domain_your_contact_information_when_profile_feature_off(self): - """test that Your contact information is accessible when profile_feature is off""" - with override_flag("profile_feature", active=False): - response = self.client.get(f"/domain/{self.domain.id}/your-contact-information", follow=True) - self.assertContains(response, "Your contact information") - - @less_console_noise_decorator - def test_domain_your_contact_information_when_profile_feature_on(self): - """test that Your contact information is not accessible when profile feature is on""" - with override_flag("profile_feature", active=True): - response = self.client.get(f"/domain/{self.domain.id}/your-contact-information", follow=True) - self.assertEqual(response.status_code, 404) - @less_console_noise_decorator def test_request_when_profile_feature_on(self): """test that Your profile is in request page when profile feature is on""" @@ -1038,7 +1012,6 @@ class UserProfileTests(TestWithUser, WebTest): requested_domain=site, status=DomainRequest.DomainRequestStatus.SUBMITTED, senior_official=contact_user, - submitter=contact_user, ) with override_flag("profile_feature", active=True): response = self.client.get(f"/domain-request/{domain_request.id}", follow=True) @@ -1060,7 +1033,6 @@ class UserProfileTests(TestWithUser, WebTest): requested_domain=site, status=DomainRequest.DomainRequestStatus.SUBMITTED, senior_official=contact_user, - submitter=contact_user, ) with override_flag("profile_feature", active=False): response = self.client.get(f"/domain-request/{domain_request.id}", follow=True) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index b096527f9..c57cbe483 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -161,7 +161,6 @@ class TestDomainPermissions(TestWithDomainPermissions): "domain-dns-nameservers", "domain-org-name-address", "domain-senior-official", - "domain-your-contact-information", "domain-security-email", ]: with self.subTest(view_name=view_name): @@ -181,7 +180,6 @@ class TestDomainPermissions(TestWithDomainPermissions): "domain-dns-nameservers", "domain-org-name-address", "domain-senior-official", - "domain-your-contact-information", "domain-security-email", ]: with self.subTest(view_name=view_name): @@ -202,7 +200,6 @@ class TestDomainPermissions(TestWithDomainPermissions): "domain-dns-dnssec-dsdata", "domain-org-name-address", "domain-senior-official", - "domain-your-contact-information", "domain-security-email", ]: for domain in [ @@ -1624,22 +1621,6 @@ class TestDomainSuborganization(TestDomainOverview): portfolio.delete() -class TestDomainContactInformation(TestDomainOverview): - @less_console_noise_decorator - def test_domain_your_contact_information(self): - """Can load domain's your contact information page.""" - page = self.client.get(reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})) - self.assertContains(page, "Your contact information") - - @less_console_noise_decorator - def test_domain_your_contact_information_content(self): - """Logged-in user's contact information appears on the page.""" - self.user.first_name = "Testy" - self.user.save() - page = self.app.get(reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})) - self.assertContains(page, "Testy") - - class TestDomainSecurityEmail(TestDomainOverview): def test_domain_security_email_existing_security_contact(self): """Can load domain's security email page.""" diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py index 70ae23b43..07799104b 100644 --- a/src/registrar/tests/test_views_domains_json.py +++ b/src/registrar/tests/test_views_domains_json.py @@ -1,9 +1,13 @@ from registrar.models import UserDomainRole, Domain, DomainInformation, Portfolio from django.urls import reverse + +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .test_views import TestWithUser from django_webtest import WebTest # type: ignore from django.utils.dateparse import parse_date from api.tests.common import less_console_noise_decorator +from waffle.testutils import override_flag class GetDomainsJsonTest(TestWithUser, WebTest): @@ -31,6 +35,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest): def tearDown(self): UserDomainRole.objects.all().delete() + UserPortfolioPermission.objects.all().delete() DomainInformation.objects.all().delete() Portfolio.objects.all().delete() super().tearDown() @@ -115,8 +120,104 @@ class GetDomainsJsonTest(TestWithUser, WebTest): self.assertEqual(svg_icon_expected, svg_icons[i]) @less_console_noise_decorator - def test_get_domains_json_with_portfolio(self): - """Test that an authenticated user gets the list of 2 domains for portfolio.""" + @override_flag("organization_feature", active=True) + def test_get_domains_json_with_portfolio_view_managed_domains(self): + """Test that an authenticated user gets the list of 1 domain for portfolio. The 1 domain + is the domain that they manage within the portfolio.""" + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS], + ) + + response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertFalse(data["has_next"]) + self.assertFalse(data["has_previous"]) + self.assertEqual(data["num_pages"], 1) + + # Check the number of domains + self.assertEqual(len(data["domains"]), 1) + + # Expected domains + expected_domains = [self.domain3] + + # Extract fields from response + domain_ids = [domain["id"] for domain in data["domains"]] + names = [domain["name"] for domain in data["domains"]] + expiration_dates = [domain["expiration_date"] for domain in data["domains"]] + states = [domain["state"] for domain in data["domains"]] + state_displays = [domain["state_display"] for domain in data["domains"]] + get_state_help_texts = [domain["get_state_help_text"] for domain in data["domains"]] + action_urls = [domain["action_url"] for domain in data["domains"]] + action_labels = [domain["action_label"] for domain in data["domains"]] + svg_icons = [domain["svg_icon"] for domain in data["domains"]] + + # Check fields for each domain + for i, expected_domain in enumerate(expected_domains): + self.assertEqual(expected_domain.id, domain_ids[i]) + self.assertEqual(expected_domain.name, names[i]) + self.assertEqual(expected_domain.expiration_date, expiration_dates[i]) + self.assertEqual(expected_domain.state, states[i]) + + # Parsing the expiration date from string to date + parsed_expiration_date = parse_date(expiration_dates[i]) + expected_domain.expiration_date = parsed_expiration_date + + # Check state_display and get_state_help_text + self.assertEqual(expected_domain.state_display(), state_displays[i]) + self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i]) + + self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i]) + + # Check action_label + user_domain_role_exists = UserDomainRole.objects.filter( + domain_id=expected_domains[i].id, user=self.user + ).exists() + action_label_expected = ( + "View" + if not user_domain_role_exists + or expected_domains[i].state + in [ + Domain.State.DELETED, + Domain.State.ON_HOLD, + ] + else "Manage" + ) + self.assertEqual(action_label_expected, action_labels[i]) + + # Check svg_icon + svg_icon_expected = ( + "visibility" + if not user_domain_role_exists + or expected_domains[i].state + in [ + Domain.State.DELETED, + Domain.State.ON_HOLD, + ] + else "settings" + ) + self.assertEqual(svg_icon_expected, svg_icons[i]) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_get_domains_json_with_portfolio_view_all_domains(self): + """Test that an authenticated user gets the list of 2 domains for portfolio. One is a domain which + they manage within the portfolio. The other is a domain which they don't manage within the + portfolio.""" + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS], + ) response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id}) self.assertEqual(response.status_code, 200) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index c5d1a9830..b8392a370 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -12,10 +12,11 @@ from registrar.models import ( ) from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from .common import create_test_user +from .common import MockSESClient, completed_domain_request, create_test_user from waffle.testutils import override_flag from django.contrib.sessions.middleware import SessionMiddleware - +import boto3_mocking # type: ignore +from django.test import Client import logging logger = logging.getLogger(__name__) @@ -24,6 +25,7 @@ logger = logging.getLogger(__name__) class TestPortfolio(WebTest): def setUp(self): super().setUp() + self.client = Client() self.user = create_test_user() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") @@ -76,7 +78,7 @@ class TestPortfolio(WebTest): def test_middleware_does_not_redirect_if_no_permission(self): """Test that user with no portfolio permission is not redirected when attempting to access home""" self.app.set_user(self.user.username) - portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + UserPortfolioPermission.objects.get_or_create( user=self.user, portfolio=self.portfolio, additional_permissions=[] ) self.user.portfolio = self.portfolio @@ -230,6 +232,7 @@ class TestPortfolio(WebTest): self.assertContains(response, 'for="id_city"') @less_console_noise_decorator + @override_flag("organization_requests", active=True) def test_accessible_pages_when_user_does_not_have_permission(self): """Tests which pages are accessible when user does not have portfolio permissions""" self.app.set_user(self.user.username) @@ -280,6 +283,7 @@ class TestPortfolio(WebTest): self.assertEquals(domain_request_page.status_code, 403) @less_console_noise_decorator + @override_flag("organization_requests", active=True) def test_accessible_pages_when_user_does_not_have_role(self): """Test that admin / memmber roles are associated with the right access""" self.app.set_user(self.user.username) @@ -502,7 +506,7 @@ class TestPortfolio(WebTest): self.client.force_login(self.user) response = self.client.get(reverse("home"), follow=True) - self.assertFalse(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) + self.assertFalse(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) self.assertContains(response, "You aren") @@ -517,7 +521,7 @@ class TestPortfolio(WebTest): # Test the domains page - this user should have access response = self.client.get(reverse("domains")) - self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) + self.assertTrue(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) self.assertContains(response, "Domain name") @@ -528,7 +532,411 @@ class TestPortfolio(WebTest): # Test the domains page - this user should have access response = self.client.get(reverse("domains")) - self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) + self.assertTrue(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) self.assertContains(response, "Domain name") permission.delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=False) + def test_organization_requests_waffle_flag_off_hides_nav_link_and_restricts_permission(self): + """Setting the organization_requests waffle off hides the nav link and restricts access to the requests page""" + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ], + ) + + home = self.app.get(reverse("home")).follow() + + self.assertContains(home, "Hotel California") + self.assertNotContains(home, "Domain requests") + + domain_requests = self.app.get(reverse("domain-requests"), expect_errors=True) + self.assertEqual(domain_requests.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_organization_requests_waffle_flag_on_shows_nav_link_and_allows_permission(self): + """Setting the organization_requests waffle on shows the nav link and allows access to the requests page""" + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ], + ) + + home = self.app.get(reverse("home")).follow() + + self.assertContains(home, "Hotel California") + self.assertContains(home, "Domain requests") + + domain_requests = self.app.get(reverse("domain-requests")) + self.assertEqual(domain_requests.status_code, 200) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=False) + def test_organization_members_waffle_flag_off_hides_nav_link(self): + """Setting the organization_members waffle off hides the nav link""" + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ], + ) + + home = self.app.get(reverse("home")).follow() + + self.assertContains(home, "Hotel California") + self.assertNotContains(home, "Members") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_organization_members_waffle_flag_on_shows_nav_link(self): + """Setting the organization_members waffle on shows the nav link""" + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + home = self.app.get(reverse("home")).follow() + + self.assertContains(home, "Hotel California") + self.assertContains(home, "Members") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_portfolio_domain_requests_page_when_user_has_no_permissions(self): + """Test the no requests page""" + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + self.client.force_login(self.user) + # create and submit a domain request + domain_request = completed_domain_request(user=self.user) + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + domain_request.submit() + domain_request.save() + + requests_page = self.client.get(reverse("no-portfolio-requests"), follow=True) + + self.assertContains(requests_page, "You don’t have access to domain requests.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_main_nav_when_user_has_no_permissions(self): + """Test the nav contains a link to the no requests page""" + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + self.client.force_login(self.user) + # create and submit a domain request + domain_request = completed_domain_request(user=self.user) + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + domain_request.submit() + domain_request.save() + + portfolio_landing_page = self.client.get(reverse("home"), follow=True) + + # link to no requests + self.assertContains(portfolio_landing_page, "no-organization-requests/") + # dropdown + self.assertNotContains(portfolio_landing_page, "basic-nav-section-two") + # link to requests + self.assertNotContains(portfolio_landing_page, 'href="/requests/') + # link to create + self.assertNotContains(portfolio_landing_page, 'href="/request/') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_main_nav_when_user_has_all_permissions(self): + """Test the nav contains a dropdown with a link to create and another link to view requests + Also test for the existence of the Create a new request btn on the requests page""" + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.client.force_login(self.user) + # create and submit a domain request + domain_request = completed_domain_request(user=self.user) + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + domain_request.submit() + domain_request.save() + + portfolio_landing_page = self.client.get(reverse("home"), follow=True) + + # link to no requests + self.assertNotContains(portfolio_landing_page, "no-organization-requests/") + # dropdown + self.assertContains(portfolio_landing_page, "basic-nav-section-two") + # link to requests + self.assertContains(portfolio_landing_page, 'href="/requests/') + # link to create + self.assertContains(portfolio_landing_page, 'href="/request/') + + requests_page = self.client.get(reverse("domain-requests")) + + # create new request btn + self.assertContains(requests_page, "Start a new domain request") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_main_nav_when_user_has_view_but_not_edit_permissions(self): + """Test the nav contains a simple link to view requests + Also test for the existence of the Create a new request btn on the requests page""" + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + ], + ) + self.client.force_login(self.user) + # create and submit a domain request + domain_request = completed_domain_request(user=self.user) + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + domain_request.submit() + domain_request.save() + + portfolio_landing_page = self.client.get(reverse("home"), follow=True) + + # link to no requests + self.assertNotContains(portfolio_landing_page, "no-organization-requests/") + # dropdown + self.assertNotContains(portfolio_landing_page, "basic-nav-section-two") + # link to requests + self.assertContains(portfolio_landing_page, 'href="/requests/') + # link to create + self.assertNotContains(portfolio_landing_page, 'href="/request/') + + requests_page = self.client.get(reverse("domain-requests")) + + # create new request btn + self.assertNotContains(requests_page, "Start a new domain request") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_organization_requests_additional_column(self): + """The requests table has a column for created at""" + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ], + ) + + home = self.app.get(reverse("home")).follow() + + self.assertContains(home, "Hotel California") + self.assertContains(home, "Domain requests") + + domain_requests = self.app.get(reverse("domain-requests")) + self.assertEqual(domain_requests.status_code, 200) + + self.assertContains(domain_requests, "Created by") + + @less_console_noise_decorator + def test_no_org_requests_no_additional_column(self): + """The requests table does not have a column for created at""" + self.app.set_user(self.user.username) + + home = self.app.get(reverse("home")) + + self.assertContains(home, "Domain requests") + self.assertNotContains(home, "Created by") + + @less_console_noise_decorator + def test_portfolio_cache_updates_when_modified(self): + """Test that the portfolio in session updates when the portfolio is modified""" + self.client.force_login(self.user) + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) + + with override_flag("organization_feature", active=True): + # Initial request to set the portfolio in session + response = self.client.get(reverse("home"), follow=True) + + portfolio = self.client.session.get("portfolio") + self.assertEqual(portfolio.organization_name, "Hotel California") + self.assertContains(response, "Hotel California") + + # Modify the portfolio + self.portfolio.organization_name = "Updated Hotel California" + self.portfolio.save() + + # Make another request + response = self.client.get(reverse("home"), follow=True) + + # Check if the updated portfolio name is in the response + self.assertContains(response, "Updated Hotel California") + + # Verify that the session contains the updated portfolio + portfolio = self.client.session.get("portfolio") + self.assertEqual(portfolio.organization_name, "Updated Hotel California") + + @less_console_noise_decorator + def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self): + """Test that the portfolio in session is set to None when the organization_feature flag is disabled""" + self.client.force_login(self.user) + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) + + with override_flag("organization_feature", active=True): + # Initial request to set the portfolio in session + response = self.client.get(reverse("home"), follow=True) + portfolio = self.client.session.get("portfolio") + self.assertEqual(portfolio.organization_name, "Hotel California") + self.assertContains(response, "Hotel California") + + # Disable the organization_feature flag + with override_flag("organization_feature", active=False): + # Make another request + response = self.client.get(reverse("home")) + self.assertIsNone(self.client.session.get("portfolio")) + self.assertNotContains(response, "Hotel California") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_org_user_can_delete_own_domain_request_with_permission(self): + """Test that an org user with edit permission can delete their own DomainRequest with a deletable status.""" + + # Assign the user to a portfolio with edit permission + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS], + ) + + # Create a domain request with status WITHDRAWN + domain_request = completed_domain_request( + name="test-domain.gov", + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + portfolio=self.portfolio, + ) + domain_request.creator = self.user + domain_request.save() + + self.client.force_login(self.user) + # Perform delete + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + # Check that the response is 200 + self.assertEqual(response.status_code, 200) + + # Check that the domain request no longer exists + self.assertFalse(DomainRequest.objects.filter(pk=domain_request.pk).exists()) + domain_request.delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_delete_domain_request_as_org_user_without_permission_with_deletable_status(self): + """Test that an org user without edit permission cant delete their DomainRequest even if status is deletable.""" + + # Assign the user to a portfolio without edit permission + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[], + ) + + # Create a domain request with status STARTED + domain_request = completed_domain_request( + name="test-domain.gov", + status=DomainRequest.DomainRequestStatus.STARTED, + portfolio=self.portfolio, + ) + domain_request.creator = self.user + domain_request.save() + + self.client.force_login(self.user) + # Attempt to delete + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + # Check response is 403 Forbidden + self.assertEqual(response.status_code, 403) + + # Check that the domain request still exists + self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists()) + domain_request.delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_org_user_cannot_delete_others_domain_requests(self): + """Test that an org user with edit permission cannot delete DomainRequests they did not create.""" + + # Assign the user to a portfolio with edit permission + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS], + ) + + # Create another user and a domain request + other_user = User.objects.create(username="other_user") + domain_request = completed_domain_request( + name="test-domain.gov", + status=DomainRequest.DomainRequestStatus.STARTED, + portfolio=self.portfolio, + ) + domain_request.creator = other_user + domain_request.save() + + self.client.force_login(self.user) + # Perform delete as self.user + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + # Check response is 403 Forbidden + self.assertEqual(response.status_code, 403) + + # Check that the domain request still exists + self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists()) + domain_request.delete() diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 6642b6471..6dde70f1a 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -7,6 +7,7 @@ from api.tests.common import less_console_noise_decorator from .common import MockSESClient, completed_domain_request # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore +from waffle.testutils import override_flag from registrar.models import ( DomainRequest, @@ -17,12 +18,14 @@ from registrar.models import ( User, Website, FederalAgency, + Portfolio, + UserPortfolioPermission, ) from registrar.views.domain_request import DomainRequestWizard, Step from .common import less_console_noise from .test_views import TestWithUser - +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices import logging logger = logging.getLogger(__name__) @@ -348,41 +351,13 @@ class DomainRequestTests(TestWithUser, WebTest): # the post request should return a redirect to the next form in # the domain request page self.assertEqual(purpose_result.status_code, 302) - self.assertEqual(purpose_result["Location"], "/request/your_contact/") - num_pages_tested += 1 - - # ---- YOUR CONTACT INFO PAGE ---- - # 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.forms[0] - - your_contact_form["your_contact-first_name"] = "Testy you" - your_contact_form["your_contact-last_name"] = "Tester you" - your_contact_form["your_contact-title"] = "Admin Tester" - your_contact_form["your_contact-email"] = "testy-admin@town.com" - your_contact_form["your_contact-phone"] = "(201) 555 5556" - - # test next button - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - your_contact_result = your_contact_form.submit() - # validate that data from this step are being saved - domain_request = DomainRequest.objects.get() # there's only one - self.assertEqual(domain_request.submitter.first_name, "Testy you") - self.assertEqual(domain_request.submitter.last_name, "Tester you") - self.assertEqual(domain_request.submitter.title, "Admin Tester") - self.assertEqual(domain_request.submitter.email, "testy-admin@town.com") - self.assertEqual(domain_request.submitter.phone, "(201) 555 5556") - # the post request should return a redirect to the next form in - # the domain request page - self.assertEqual(your_contact_result.status_code, 302) - self.assertEqual(your_contact_result["Location"], "/request/other_contacts/") + self.assertEqual(purpose_result["Location"], "/request/other_contacts/") num_pages_tested += 1 # ---- OTHER CONTACTS PAGE ---- # 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_page = purpose_result.follow() # This page has 3 forms in 1. # Let's set the yes/no radios to enable the other contacts fieldsets @@ -492,11 +467,6 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(review_page, "city.gov") self.assertContains(review_page, "city1.gov") self.assertContains(review_page, "For all kinds of things.") - self.assertContains(review_page, "Testy you") - self.assertContains(review_page, "Tester you") - self.assertContains(review_page, "Admin Tester") - self.assertContains(review_page, "testy-admin@town.com") - self.assertContains(review_page, "(201) 555-5556") self.assertContains(review_page, "Testy2") self.assertContains(review_page, "Tester2") self.assertContains(review_page, "Another Tester") @@ -704,41 +674,13 @@ class DomainRequestTests(TestWithUser, WebTest): # the post request should return a redirect to the next form in # the domain request page self.assertEqual(purpose_result.status_code, 302) - self.assertEqual(purpose_result["Location"], "/request/your_contact/") - num_pages_tested += 1 - - # ---- YOUR CONTACT INFO PAGE ---- - # 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.forms[0] - - your_contact_form["your_contact-first_name"] = "Testy you" - your_contact_form["your_contact-last_name"] = "Tester you" - your_contact_form["your_contact-title"] = "Admin Tester" - your_contact_form["your_contact-email"] = "testy-admin@town.com" - your_contact_form["your_contact-phone"] = "(201) 555 5556" - - # test next button - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - your_contact_result = your_contact_form.submit() - # validate that data from this step are being saved - domain_request = DomainRequest.objects.get() # there's only one - self.assertEqual(domain_request.submitter.first_name, "Testy you") - self.assertEqual(domain_request.submitter.last_name, "Tester you") - self.assertEqual(domain_request.submitter.title, "Admin Tester") - self.assertEqual(domain_request.submitter.email, "testy-admin@town.com") - self.assertEqual(domain_request.submitter.phone, "(201) 555 5556") - # the post request should return a redirect to the next form in - # the domain request page - self.assertEqual(your_contact_result.status_code, 302) - self.assertEqual(your_contact_result["Location"], "/request/other_contacts/") + self.assertEqual(purpose_result["Location"], "/request/other_contacts/") num_pages_tested += 1 # ---- OTHER CONTACTS PAGE ---- # 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_page = purpose_result.follow() # This page has 3 forms in 1. # Let's set the yes/no radios to enable the other contacts fieldsets @@ -1643,7 +1585,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -1780,7 +1721,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -1855,7 +1795,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -1933,7 +1872,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -2010,7 +1948,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -2086,7 +2023,6 @@ class DomainRequestTests(TestWithUser, WebTest): state_territory="NY", zipcode="10002", senior_official=so, - submitter=you, creator=self.user, status="started", ) @@ -2270,23 +2206,15 @@ class DomainRequestTests(TestWithUser, WebTest): senior_official = domain_request.senior_official self.assertEquals("Testy2", senior_official.first_name) + @override_flag("profile_feature", active=True) @less_console_noise_decorator - def test_edit_submitter_in_place(self): + def test_edit_creator_in_place(self): """When you: - 1. edit a submitter (your contact) which is not joined to another model, + 1. edit a your user profile information, 2. then submit, - the domain request is linked to the existing submitter, and the submitter updated.""" + the domain request also updates its creator data to reflect user profile changes.""" - # Populate the database with a domain request that - # has a submitter - # We'll do it from scratch - you, _ = Contact.objects.get_or_create( - first_name="Testy", - last_name="Tester", - title="Chief Tester", - email="testy@town.com", - phone="(201) 555 5555", - ) + # Populate the database with a domain request domain_request, _ = DomainRequest.objects.get_or_create( generic_org_type="federal", federal_type="executive", @@ -2297,14 +2225,11 @@ class DomainRequestTests(TestWithUser, WebTest): address_line1="address 1", state_territory="NY", zipcode="10002", - submitter=you, creator=self.user, status="started", ) - # submitter_pk is the initial pk of the submitter. set it before update - # to be able to verify after update that the same contact object is in place - submitter_pk = you.id + creator_pk = self.user.id # prime the form by visiting /edit self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) @@ -2315,98 +2240,25 @@ class DomainRequestTests(TestWithUser, WebTest): session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - your_contact_page = self.app.get(reverse("domain-request:your_contact")) + profile_page = self.app.get("/user-profile") self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - your_contact_form = your_contact_page.forms[0] + profile_form = profile_page.forms[0] # Minimal check to ensure the form is loaded - self.assertEqual(your_contact_form["your_contact-first_name"].value, "Testy") + self.assertEqual(profile_form["first_name"].value, self.user.first_name) # update the first name of the contact - your_contact_form["your_contact-first_name"] = "Testy2" + profile_form["first_name"] = "Testy2" # Submit the updated form - your_contact_form.submit() + profile_form.submit() domain_request.refresh_from_db() - updated_submitter = domain_request.submitter - self.assertEquals(submitter_pk, updated_submitter.id) - self.assertEquals("Testy2", updated_submitter.first_name) - - @less_console_noise_decorator - def test_edit_submitter_creates_new(self): - """When you: - 1. edit an existing your contact which IS joined to another model, - 2. then submit, - the domain request is linked to a new Contact, and the new Contact is updated.""" - - # Populate the database with a domain request that - # has submitter assigned to it, the submitter is also - # an other contact initially - # We'll do it from scratch - submitter, _ = Contact.objects.get_or_create( - first_name="Testy", - last_name="Tester", - title="Chief Tester", - email="testy@town.com", - phone="(201) 555 5555", - ) - domain_request, _ = DomainRequest.objects.get_or_create( - generic_org_type="federal", - federal_type="executive", - purpose="Purpose of the site", - anything_else="No", - is_policy_acknowledged=True, - organization_name="Testorg", - address_line1="address 1", - state_territory="NY", - zipcode="10002", - submitter=submitter, - creator=self.user, - status="started", - ) - domain_request.other_contacts.add(submitter) - - # submitter_pk is the initial pk of the your contact. set it before update - # to be able to verify after update that the other contact is still in place - # and not updated, and that the new submitter has a new id - submitter_pk = submitter.id - - # prime the form by visiting /edit - self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) - # django-webtest does not handle cookie-based sessions well because it keeps - # resetting the session key on each new request, thus destroying the concept - # 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] - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - - your_contact_page = self.app.get(reverse("domain-request:your_contact")) - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - - your_contact_form = your_contact_page.forms[0] - - # Minimal check to ensure the form is loaded - self.assertEqual(your_contact_form["your_contact-first_name"].value, "Testy") - - # update the first name of the contact - your_contact_form["your_contact-first_name"] = "Testy2" - - # Submit the updated form - your_contact_form.submit() - - domain_request.refresh_from_db() - - # assert that the other contact is not updated - other_contacts = domain_request.other_contacts.all() - other_contact = other_contacts[0] - self.assertEquals(submitter_pk, other_contact.id) - self.assertEquals("Testy", other_contact.first_name) - # assert that the submitter is updated - submitter = domain_request.submitter - self.assertEquals("Testy2", submitter.first_name) + updated_creator = domain_request.creator + self.assertEquals(creator_pk, updated_creator.id) + self.assertEquals("Testy2", updated_creator.first_name) @less_console_noise_decorator def test_domain_request_about_your_organiztion_interstate(self): @@ -2729,7 +2581,6 @@ class DomainRequestTests(TestWithUser, WebTest): zipcode="10002", senior_official=so, requested_domain=domain, - submitter=you, creator=self.user, ) domain_request.other_contacts.add(other) @@ -2874,7 +2725,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "city1.gov") self.assertContains(detail_page, "Chief Tester") self.assertContains(detail_page, "testy@town.com") - self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") @less_console_noise_decorator @@ -2891,7 +2741,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "city.gov") self.assertContains(detail_page, "Chief Tester") self.assertContains(detail_page, "testy@town.com") - self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") @less_console_noise_decorator @@ -2905,7 +2754,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "city1.gov") self.assertContains(detail_page, "Chief Tester") self.assertContains(detail_page, "testy@town.com") - self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") # click the "Withdraw request" button mock_client = MockSESClient() @@ -2925,6 +2773,38 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): response = self.client.get("/get-domain-requests-json/") self.assertContains(response, "Withdrawn") + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_domain_request_withdraw_portfolio_redirects_correctly(self): + """Tests that the withdraw button on portfolio redirects to the portfolio domain requests page""" + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio") + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user) + domain_request.save() + + detail_page = self.app.get(f"/domain-request/{domain_request.id}") + self.assertContains(detail_page, "city.gov") + self.assertContains(detail_page, "city1.gov") + self.assertContains(detail_page, "Chief Tester") + self.assertContains(detail_page, "testy@town.com") + self.assertContains(detail_page, "Status:") + # click the "Withdraw request" button + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + with less_console_noise(): + withdraw_page = detail_page.click("Withdraw request") + self.assertContains(withdraw_page, "Withdraw request for") + home_page = withdraw_page.click("Withdraw request") + + # Assert that it redirects to the portfolio requests page and the status has been updated to withdrawn + self.assertEqual(home_page.status_code, 302) + self.assertEqual(home_page.location, reverse("domain-requests")) + + response = self.client.get("/get-domain-requests-json/") + self.assertContains(response, "Withdrawn") + @less_console_noise_decorator def test_domain_request_withdraw_no_permissions(self): """Can't withdraw domain requests as a restricted user.""" @@ -2938,7 +2818,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "city1.gov") self.assertContains(detail_page, "Chief Tester") self.assertContains(detail_page, "testy@town.com") - self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") # Restricted user trying to withdraw results in 403 error with less_console_noise(): @@ -3037,10 +2916,10 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): self.assertEqual(detail_page.status_code, 200) # 10 unlocked steps, one active step, the review step will have link_usa but not check_circle - self.assertContains(detail_page, "#check_circle", count=10) + self.assertContains(detail_page, "#check_circle", count=9) # Type of organization self.assertContains(detail_page, "usa-current", count=1) - self.assertContains(detail_page, "link_usa-checked", count=11) + self.assertContains(detail_page, "link_usa-checked", count=10) else: self.fail(f"Expected a redirect, but got a different response: {response}") @@ -3072,7 +2951,6 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN, senior_official=contact, - submitter=contact_user, ) domain_request.other_contacts.set([contact_2]) @@ -3098,12 +2976,12 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): # Now 'detail_page' contains the response after following the redirect self.assertEqual(detail_page.status_code, 200) - # 5 unlocked steps (so, domain, submitter, other contacts, and current sites + # 5 unlocked steps (so, domain, other contacts, and current sites # which unlocks if domain exists), one active step, the review step is locked - self.assertContains(detail_page, "#check_circle", count=5) + self.assertContains(detail_page, "#check_circle", count=4) # Type of organization self.assertContains(detail_page, "usa-current", count=1) - self.assertContains(detail_page, "link_usa-checked", count=5) + self.assertContains(detail_page, "link_usa-checked", count=4) else: self.fail(f"Expected a redirect, but got a different response: {response}") diff --git a/src/registrar/tests/test_views_requests_json.py b/src/registrar/tests/test_views_requests_json.py index 20a4069f7..cef608567 100644 --- a/src/registrar/tests/test_views_requests_json.py +++ b/src/registrar/tests/test_views_requests_json.py @@ -2,9 +2,14 @@ from registrar.models import DomainRequest from django.urls import reverse from registrar.models.draft_domain import DraftDomain +from registrar.models.portfolio import Portfolio +from registrar.models.user import User +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .test_views import TestWithUser from django_webtest import WebTest # type: ignore from django.utils.dateparse import parse_datetime +from waffle.testutils import override_flag class GetRequestsJsonTest(TestWithUser, WebTest): @@ -20,6 +25,19 @@ class GetRequestsJsonTest(TestWithUser, WebTest): beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov") stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov") + # Create Portfolio + cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Example org") + + # create a second user to assign requests to + cls.user2 = User.objects.create( + username="test_user2", + first_name="Second", + last_name="last", + email="info2@example.com", + phone="8003111234", + title="title", + ) + # Create domain requests for the user cls.domain_requests = [ DomainRequest.objects.create( @@ -28,6 +46,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): last_submitted_date="2024-01-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-01-01", + portfolio=cls.portfolio, ), DomainRequest.objects.create( creator=cls.user, @@ -42,6 +61,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): last_submitted_date="2024-03-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-03-01", + portfolio=cls.portfolio, ), DomainRequest.objects.create( creator=cls.user, @@ -113,6 +133,14 @@ class GetRequestsJsonTest(TestWithUser, WebTest): status=DomainRequest.DomainRequestStatus.APPROVED, created_at="2024-12-01", ), + DomainRequest.objects.create( + creator=cls.user2, + requested_domain=None, + last_submitted_date="2024-12-01", + status=DomainRequest.DomainRequestStatus.STARTED, + created_at="2024-12-01", + portfolio=cls.portfolio, + ), ] @classmethod @@ -120,6 +148,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): super().tearDownClass() DomainRequest.objects.all().delete() DraftDomain.objects.all().delete() + Portfolio.objects.all().delete() def test_get_domain_requests_json_authenticated(self): """Test that domain requests are returned properly for an authenticated user.""" @@ -262,6 +291,118 @@ class GetRequestsJsonTest(TestWithUser, WebTest): for expected_value, actual_value in zip(expected_domain_values, requested_domains): self.assertEqual(expected_value, actual_value) + @override_flag("organization_feature", active=True) + def test_get_domain_requests_json_with_portfolio_view_all_requests(self): + """Test that an authenticated user gets the list of 3 requests for portfolio. The 3 requests + are the requests that are associated with the portfolio.""" + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + + response = self.app.get(reverse("get_domain_requests_json"), {"portfolio": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertFalse(data["has_next"]) + self.assertFalse(data["has_previous"]) + self.assertEqual(data["num_pages"], 1) + + # Check the number of requests + self.assertEqual(len(data["domain_requests"]), 3) + + # Expected domain requests + expected_domain_requests = [self.domain_requests[0], self.domain_requests[2], self.domain_requests[13]] + + # Extract fields from response + domain_request_ids = [domain_request["id"] for domain_request in data["domain_requests"]] + requested_domain = [domain_request["requested_domain"] for domain_request in data["domain_requests"]] + creator = [domain_request["creator"] for domain_request in data["domain_requests"]] + status = [domain_request["status"] for domain_request in data["domain_requests"]] + action_urls = [domain_request["action_url"] for domain_request in data["domain_requests"]] + action_labels = [domain_request["action_label"] for domain_request in data["domain_requests"]] + svg_icons = [domain_request["svg_icon"] for domain_request in data["domain_requests"]] + + # Check fields for each domain_request + for i, expected_domain_request in enumerate(expected_domain_requests): + self.assertEqual(expected_domain_request.id, domain_request_ids[i]) + if expected_domain_request.requested_domain: + self.assertEqual(expected_domain_request.requested_domain.name, requested_domain[i]) + else: + self.assertIsNone(requested_domain[i]) + self.assertEqual(expected_domain_request.creator.email, creator[i]) + # Check action url, action label and svg icon + # Example domain requests will test each of below three scenarios + if creator[i] != self.user.email: + # Test case where action is View + self.assertEqual("View", action_labels[i]) + self.assertEqual("#", action_urls[i]) + self.assertEqual("visibility", svg_icons[i]) + elif status[i] in [ + DomainRequest.DomainRequestStatus.STARTED.label, + DomainRequest.DomainRequestStatus.ACTION_NEEDED.label, + DomainRequest.DomainRequestStatus.WITHDRAWN.label, + ]: + # Test case where action is Edit + self.assertEqual("Edit", action_labels[i]) + self.assertEqual( + reverse("edit-domain-request", kwargs={"id": expected_domain_request.id}), action_urls[i] + ) + self.assertEqual("edit", svg_icons[i]) + else: + # Test case where action is Manage + self.assertEqual("Manage", action_labels[i]) + self.assertEqual( + reverse("domain-request-status", kwargs={"pk": expected_domain_request.id}), action_urls[i] + ) + self.assertEqual("settings", svg_icons[i]) + + @override_flag("organization_feature", active=True) + def test_get_domain_requests_json_with_portfolio_edit_requests(self): + """Test that an authenticated user gets the list of 2 requests for portfolio. The 2 requests + are the requests that are associated with the portfolio and owned by self.user.""" + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS], + ) + + response = self.app.get(reverse("get_domain_requests_json"), {"portfolio": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertFalse(data["has_next"]) + self.assertFalse(data["has_previous"]) + self.assertEqual(data["num_pages"], 1) + + # Check the number of requests + self.assertEqual(len(data["domain_requests"]), 2) + + # Expected domain requests + expected_domain_requests = [self.domain_requests[0], self.domain_requests[2]] + + # Extract fields from response, since other tests test all fields, only ids and requested + # domains tested in this test + domain_request_ids = [domain_request["id"] for domain_request in data["domain_requests"]] + requested_domain = [domain_request["requested_domain"] for domain_request in data["domain_requests"]] + + # Check fields for each domain_request + for i, expected_domain_request in enumerate(expected_domain_requests): + self.assertEqual(expected_domain_request.id, domain_request_ids[i]) + if expected_domain_request.requested_domain: + self.assertEqual(expected_domain_request.requested_domain.name, requested_domain[i]) + else: + self.assertIsNone(requested_domain[i]) + def test_pagination(self): """Test that pagination works properly. There are 11 total non-approved requests and a page size of 10""" diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 47d3feff5..8cb83c0ee 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -77,17 +77,15 @@ class FSMErrorCodes(IntEnum): - 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 - - 5 NO_REJECTION_REASON No rejection reason is specified - - 6 NO_ACTION_NEEDED_REASON No action needed reason is specified + - 4 NO_REJECTION_REASON No rejection reason is specified + - 5 NO_ACTION_NEEDED_REASON No action needed reason is specified """ APPROVE_DOMAIN_IN_USE = 1 NO_INVESTIGATOR = 2 INVESTIGATOR_NOT_STAFF = 3 - INVESTIGATOR_NOT_SUBMITTER = 4 - NO_REJECTION_REASON = 5 - NO_ACTION_NEEDED_REASON = 6 + NO_REJECTION_REASON = 4 + NO_ACTION_NEEDED_REASON = 5 class FSMDomainRequestError(Exception): @@ -100,7 +98,6 @@ class FSMDomainRequestError(Exception): 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."), FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."), FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."), } diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index f6e87dd07..c4cb03192 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -8,7 +8,6 @@ from .domain import ( DomainNameserversView, DomainDNSSECView, DomainDsDataView, - DomainYourContactInformationView, DomainSecurityEmailView, DomainUsersView, DomainAddUserView, @@ -19,3 +18,4 @@ from .user_profile import UserProfileView, FinishProfileSetupView from .health import * from .index import * from .portfolios import * +from .transfer_user import TransferUserView diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 003f8dd0d..174f01ecc 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -40,7 +40,6 @@ from registrar.models.utility.contact_error import ContactError from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView from ..forms import ( - UserForm, SeniorOfficialContactForm, DomainOrgNameAddressForm, DomainAddUserForm, @@ -59,7 +58,6 @@ from epplibwrapper import ( from ..utility.email import send_templated_email, EmailSendingError from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView -from waffle.decorators import waffle_flag logger = logging.getLogger(__name__) @@ -175,7 +173,7 @@ class DomainView(DomainBaseView): If particular views allow permissions, they will need to override this function.""" portfolio = self.request.session.get("portfolio") - if self.request.user.has_domains_portfolio_permission(portfolio): + if self.request.user.has_any_domains_portfolio_permission(portfolio): if Domain.objects.filter(id=pk).exists(): domain = Domain.objects.get(id=pk) if domain.domain_info.portfolio == portfolio: @@ -641,38 +639,6 @@ class DomainDsDataView(DomainFormBaseView): return super().form_valid(formset) -class DomainYourContactInformationView(DomainFormBaseView): - """Domain your contact information editing view.""" - - template_name = "domain_your_contact_information.html" - form_class = UserForm - - @waffle_flag("!profile_feature") # type: ignore - def dispatch(self, request, *args, **kwargs): # type: ignore - return super().dispatch(request, *args, **kwargs) - - def get_form_kwargs(self, *args, **kwargs): - """Add domain_info.submitter instance to make a bound form.""" - form_kwargs = super().get_form_kwargs(*args, **kwargs) - form_kwargs["instance"] = self.request.user - return form_kwargs - - def get_success_url(self): - """Redirect to the your contact information for the domain.""" - return reverse("domain-your-contact-information", kwargs={"pk": self.object.pk}) - - def form_valid(self, form): - """The form is valid, call setter in model.""" - - # Post to DB using values from the form - form.save() - - messages.success(self.request, "Your contact information for all your domains has been updated.") - - # superclass has the redirect - return super().form_valid(form) - - class DomainSecurityEmailView(DomainFormBaseView): """Domain security email editing view.""" @@ -837,6 +803,23 @@ class DomainAddUserView(DomainFormBaseView): ) return None + # Check to see if an invite has already been sent + try: + invite = DomainInvitation.objects.get(email=email, domain=self.object) + # check if the invite has already been accepted + if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: + add_success = False + messages.warning( + self.request, + f"{email} is already a manager for this domain.", + ) + else: + add_success = False + # else if it has been sent but not accepted + messages.warning(self.request, f"{email} has already been invited to this domain") + except Exception: + logger.error("An error occured") + try: send_templated_email( "emails/domain_invitation.txt", @@ -862,24 +845,13 @@ class DomainAddUserView(DomainFormBaseView): def _make_invitation(self, email_address: str, requestor: User): """Make a Domain invitation for this email and redirect with a message.""" - # Check to see if an invite has already been sent (NOTE: we do not want to create an invite just yet.) try: - invite = DomainInvitation.objects.get(email=email_address, domain=self.object) - # that invitation already existed - if invite is not None: - messages.warning( - self.request, - f"{email_address} has already been invited to this domain.", - ) - except DomainInvitation.DoesNotExist: - # Try to send the invitation. If it succeeds, add it to the DomainInvitation table. - try: - self._send_domain_invitation_email(email=email_address, requestor=requestor) - except EmailSendingError: - messages.warning(self.request, "Could not send email invitation.") - else: - # (NOTE: only create a domainInvitation if the e-mail sends correctly) - DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) + self._send_domain_invitation_email(email=email_address, requestor=requestor) + except EmailSendingError: + messages.warning(self.request, "Could not send email invitation.") + else: + # (NOTE: only create a domainInvitation if the e-mail sends correctly) + DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) return redirect(self.get_success_url()) def form_valid(self, form): @@ -919,11 +891,9 @@ class DomainAddUserView(DomainFormBaseView): role=UserDomainRole.Roles.MANAGER, ) except IntegrityError: - # User already has the desired role! Do nothing?? - pass - - messages.success(self.request, f"Added user {requested_email}.") - + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + else: + messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index b691549cd..5fed89215 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -22,8 +22,6 @@ from .utility import ( DomainRequestWizardPermissionView, ) -from waffle.decorators import flag_is_active, waffle_flag - logger = logging.getLogger(__name__) @@ -45,7 +43,6 @@ class Step(StrEnum): CURRENT_SITES = "current_sites" DOTGOV_DOMAIN = "dotgov_domain" PURPOSE = "purpose" - YOUR_CONTACT = "your_contact" OTHER_CONTACTS = "other_contacts" ADDITIONAL_DETAILS = "additional_details" REQUIREMENTS = "requirements" @@ -91,7 +88,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): Step.CURRENT_SITES: _("Current websites"), Step.DOTGOV_DOMAIN: _(".gov domain"), Step.PURPOSE: _("Purpose of your domain"), - Step.YOUR_CONTACT: _("Your contact information"), Step.OTHER_CONTACTS: _("Other employees from your organization"), Step.ADDITIONAL_DETAILS: _("Additional details"), Step.REQUIREMENTS: _("Requirements for operating a .gov domain"), @@ -152,7 +148,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): except DomainRequest.DoesNotExist: logger.debug("DomainRequest id %s did not have a DomainRequest" % id) - self._domain_request = DomainRequest.objects.create(creator=self.request.user) + # If a user is creating a request, we assume that perms are handled upstream + if self.request.user.is_org_user(self.request): + self._domain_request = DomainRequest.objects.create( + creator=self.request.user, + portfolio=self.request.session.get("portfolio"), + ) + else: + self._domain_request = DomainRequest.objects.create(creator=self.request.user) self.storage["domain_request_id"] = self._domain_request.id return self._domain_request @@ -375,7 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): ), "dotgov_domain": self.domain_request.requested_domain is not None, "purpose": self.domain_request.purpose is not None, - "your_contact": self.domain_request.submitter is not None, "other_contacts": ( self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None @@ -395,6 +397,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): def get_context_data(self): """Define context for access on all wizard pages.""" + requested_domain_name = None + if self.domain_request.requested_domain is not None: + requested_domain_name = self.domain_request.requested_domain.name + context_stuff = {} if DomainRequest._form_complete(self.domain_request, self.request): modal_button = '" @@ -411,6 +417,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): You’ll only be able to withdraw your request.", "review_form_is_complete": True, "user": self.request.user, + "requested_domain__name": requested_domain_name, } else: # form is not complete modal_button = '' @@ -426,6 +433,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): Return to the request and visit the steps that are marked as "incomplete."', "review_form_is_complete": False, "user": self.request.user, + "requested_domain__name": requested_domain_name, } return context_stuff @@ -439,9 +447,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): if condition: step_list.append(step) - if flag_is_active(self.request, "profile_feature"): - step_list.remove(Step.YOUR_CONTACT) - return step_list def goto(self, step): @@ -505,7 +510,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): # if user opted to save progress and return, # return them to the home page if button == "save_and_return": - return HttpResponseRedirect(reverse("home")) + if request.user.is_org_user(request): + return HttpResponseRedirect(reverse("domain-requests")) + else: + return HttpResponseRedirect(reverse("home")) + # otherwise, proceed as normal return self.goto_next_step() @@ -582,15 +591,6 @@ class Purpose(DomainRequestWizard): forms = [forms.PurposeForm] -class YourContact(DomainRequestWizard): - template_name = "domain_request_your_contact.html" - forms = [forms.YourContactForm] - - @waffle_flag("!profile_feature") # type: ignore - def dispatch(self, request, *args, **kwargs): # type: ignore - return super().dispatch(request, *args, **kwargs) - - class OtherContacts(DomainRequestWizard): template_name = "domain_request_other_contacts.html" forms = [forms.OtherContactsYesNoForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm] @@ -774,7 +774,10 @@ class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView): domain_request = DomainRequest.objects.get(id=self.kwargs["pk"]) domain_request.withdraw() domain_request.save() - return HttpResponseRedirect(reverse("home")) + if self.request.user.is_org_user(self.request): + return HttpResponseRedirect(reverse("domain-requests")) + else: + return HttpResponseRedirect(reverse("home")) class DomainRequestDeleteView(DomainRequestPermissionDeleteView): @@ -793,6 +796,12 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): if status not in valid_statuses: return False + # Portfolio users cannot delete their requests if they aren't permissioned to do so + if self.request.user.is_org_user(self.request): + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_edit_request_portfolio_permission(portfolio): + return False + return True def get_success_url(self): @@ -813,7 +822,7 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): # After a delete occurs, do a second sweep on any returned duplicates. # This determines if any of these three fields share a contact, which is used for - # the edge case where the same user may be an SO, and a submitter, for example. + # the edge case where the same user may be an SO, and a creator, for example. if len(duplicates) > 0: duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True) Contact.objects.filter(id__in=duplicates_to_delete).delete() @@ -826,7 +835,7 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): Collects all orphaned contacts associated with a given DomainRequest object. An orphaned contact is defined as a contact that is associated with the domain request, - but not with any other domain_request. This includes the senior official, the submitter, + but not with any other domain_request. This includes the senior official, the creator, and any other contacts linked to the domain_request. Parameters: @@ -842,18 +851,16 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): # Get each contact object on the DomainRequest object so = domain_request.senior_official - submitter = domain_request.submitter other_contacts = list(domain_request.other_contacts.all()) other_contact_ids = domain_request.other_contacts.all().values_list("id", flat=True) # Check if the desired item still exists in the DB if check_db: so = self._get_contacts_by_id([so.id]).first() if so is not None else None - submitter = self._get_contacts_by_id([submitter.id]).first() if submitter is not None else None other_contacts = self._get_contacts_by_id(other_contact_ids) # Pair each contact with its db related name for use in checking if it has joins - checked_contacts = [(so, "senior_official"), (submitter, "submitted_domain_requests")] + checked_contacts = [(so, "senior_official")] checked_contacts.extend((contact, "contact_domain_requests") for contact in other_contacts) for contact, related_name in checked_contacts: diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py index 6b0b346f8..bc880cdaf 100644 --- a/src/registrar/views/domain_requests_json.py +++ b/src/registrar/views/domain_requests_json.py @@ -10,16 +10,58 @@ from django.db.models import Q @login_required def get_domain_requests_json(request): """Given the current request, - get all domain requests that are associated with the request user and exclude the APPROVED ones""" + get all domain requests that are associated with the request user and exclude the APPROVED ones. + If we are on the portfolio requests page, limit the response to only those requests associated with + the given portfolio.""" - domain_requests = DomainRequest.objects.filter(creator=request.user).exclude( + domain_request_ids = get_domain_request_ids_from_request(request) + + objects = DomainRequest.objects.filter(id__in=domain_request_ids) + unfiltered_total = objects.count() + + objects = apply_search(objects, request) + objects = apply_sorting(objects, request) + + paginator = Paginator(objects, 10) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + domain_requests = [ + serialize_domain_request(request, domain_request, request.user) for domain_request in page_obj.object_list + ] + + return JsonResponse( + { + "domain_requests": domain_requests, + "has_next": page_obj.has_next(), + "has_previous": page_obj.has_previous(), + "page": page_obj.number, + "num_pages": paginator.num_pages, + "total": paginator.count, + "unfiltered_total": unfiltered_total, + } + ) + + +def get_domain_request_ids_from_request(request): + """Get domain request ids from request. + + If portfolio specified, return domain request ids associated with portfolio. + Otherwise, return domain request ids associated with request.user. + """ + portfolio = request.GET.get("portfolio") + filter_condition = Q(creator=request.user) + if portfolio: + if request.user.is_org_user(request) and request.user.has_view_all_requests_portfolio_permission(portfolio): + filter_condition = Q(portfolio=portfolio) + else: + filter_condition = Q(portfolio=portfolio, creator=request.user) + domain_requests = DomainRequest.objects.filter(filter_condition).exclude( status=DomainRequest.DomainRequestStatus.APPROVED ) - unfiltered_total = domain_requests.count() + return domain_requests.values_list("id", flat=True) - # Handle sorting - sort_by = request.GET.get("sort_by", "id") # Default to 'id' - order = request.GET.get("order", "asc") # Default to 'asc' + +def apply_search(queryset, request): search_term = request.GET.get("search_term") if search_term: @@ -30,70 +72,69 @@ def get_domain_requests_json(request): # If yes, we should return domain requests that do not have a # requested_domain (those display as New domain request in the UI) if search_term_lower in new_domain_request_text: - domain_requests = domain_requests.filter( + queryset = queryset.filter( Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True) ) else: - domain_requests = domain_requests.filter(Q(requested_domain__name__icontains=search_term)) + queryset = queryset.filter(Q(requested_domain__name__icontains=search_term)) + return queryset + + +def apply_sorting(queryset, request): + sort_by = request.GET.get("sort_by", "id") # Default to 'id' + order = request.GET.get("order", "asc") # Default to 'asc' if order == "desc": sort_by = f"-{sort_by}" - domain_requests = domain_requests.order_by(sort_by) - page_number = request.GET.get("page", 1) - paginator = Paginator(domain_requests, 10) - page_obj = paginator.get_page(page_number) + return queryset.order_by(sort_by) - domain_requests_data = [ - { - "requested_domain": domain_request.requested_domain.name if domain_request.requested_domain else None, - "last_submitted_date": domain_request.last_submitted_date, - "status": domain_request.get_status_display(), - "created_at": format(domain_request.created_at, "c"), # Serialize to ISO 8601 - "id": domain_request.id, - "is_deletable": domain_request.status - in [DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.WITHDRAWN], - "action_url": ( - reverse("edit-domain-request", kwargs={"id": domain_request.id}) - if domain_request.status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else reverse("domain-request-status", kwargs={"pk": domain_request.id}) - ), - "action_label": ( - "Edit" - if domain_request.status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else "Manage" - ), - "svg_icon": ( - "edit" - if domain_request.status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else "settings" - ), - } - for domain_request in page_obj + +def serialize_domain_request(request, domain_request, user): + + deletable_statuses = [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.WITHDRAWN, ] - return JsonResponse( - { - "domain_requests": domain_requests_data, - "has_next": page_obj.has_next(), - "has_previous": page_obj.has_previous(), - "page": page_obj.number, - "num_pages": paginator.num_pages, - "total": paginator.count, - "unfiltered_total": unfiltered_total, - } - ) + # Determine if the request is deletable + if not user.is_org_user(request): + is_deletable = domain_request.status in deletable_statuses + else: + portfolio = request.session.get("portfolio") + is_deletable = ( + domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio) + ) and domain_request.creator == user + + # Determine action label based on user permissions and request status + editable_statuses = [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ] + + if user.has_edit_request_portfolio_permission and domain_request.creator == user: + action_label = "Edit" if domain_request.status in editable_statuses else "Manage" + else: + action_label = "View" + + # Map the action label to corresponding URLs and icons + action_url_map = { + "Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}), + "Manage": reverse("domain-request-status", kwargs={"pk": domain_request.id}), + "View": "#", + } + + svg_icon_map = {"Edit": "edit", "Manage": "settings", "View": "visibility"} + + return { + "requested_domain": domain_request.requested_domain.name if domain_request.requested_domain else None, + "last_submitted_date": domain_request.last_submitted_date, + "status": domain_request.get_status_display(), + "created_at": format(domain_request.created_at, "c"), # Serialize to ISO 8601 + "creator": domain_request.creator.email, + "id": domain_request.id, + "is_deletable": is_deletable, + "action_url": action_url_map.get(action_label), + "action_label": action_label, + "svg_icon": svg_icon_map.get(action_label), + } diff --git a/src/registrar/views/domains_json.py b/src/registrar/views/domains_json.py index 06c211227..f7c8b4637 100644 --- a/src/registrar/views/domains_json.py +++ b/src/registrar/views/domains_json.py @@ -1,7 +1,7 @@ import logging from django.http import JsonResponse from django.core.paginator import Paginator -from registrar.models import UserDomainRole, Domain, DomainInformation +from registrar.models import UserDomainRole, Domain, DomainInformation, User from django.contrib.auth.decorators import login_required from django.urls import reverse from django.db.models import Q @@ -50,11 +50,16 @@ def get_domain_ids_from_request(request): """ portfolio = request.GET.get("portfolio") if portfolio: - domain_infos = DomainInformation.objects.filter(portfolio=portfolio) - return domain_infos.values_list("domain_id", flat=True) - else: - user_domain_roles = UserDomainRole.objects.filter(user=request.user) - return user_domain_roles.values_list("domain_id", flat=True) + current_user: User = request.user + if current_user.is_org_user(request) and current_user.has_view_all_domains_portfolio_permission(portfolio): + domain_infos = DomainInformation.objects.filter(portfolio=portfolio) + return domain_infos.values_list("domain_id", flat=True) + else: + domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) + user_domain_roles = UserDomainRole.objects.filter(user=request.user).values_list("domain_id", flat=True) + return domain_info_ids.intersection(user_domain_roles) + user_domain_roles = UserDomainRole.objects.filter(user=request.user) + return user_domain_roles.values_list("domain_id", flat=True) def apply_search(queryset, request): @@ -133,5 +138,5 @@ def serialize_domain(domain, user): "action_url": reverse("domain", kwargs={"pk": domain.id}), "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"), - "suborganization": suborganization_name, + "domain_info__sub_organization": suborganization_name, } diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 0232b50d7..885dca636 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -42,12 +42,41 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View): class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): - """Some users have access to the underlying portfolio, but not any domains. + """Some users have access to the underlying portfolio, but not any domains. This is a custom view which explains that to the user - and denotes who to contact. """ model = Portfolio - template_name = "no_portfolio_domains.html" + template_name = "portfolio_no_domains.html" + + def get(self, request): + return render(request, self.template_name, context=self.get_context_data()) + + def get_context_data(self, **kwargs): + """Add additional context data to the template.""" + # We can override the base class. This view only needs this item. + context = {} + portfolio = self.request.session.get("portfolio") + if portfolio: + admin_ids = UserPortfolioPermission.objects.filter( + portfolio=portfolio, + roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ], + ).values_list("user__id", flat=True) + + admin_users = User.objects.filter(id__in=admin_ids) + context["portfolio_administrators"] = admin_users + return context + + +class PortfolioNoDomainRequestsView(NoPortfolioDomainsPermissionView, View): + """Some users have access to the underlying portfolio, but not any domain requests. + This is a custom view which explains that to the user - and denotes who to contact. + """ + + model = Portfolio + template_name = "portfolio_no_requests.html" def get(self, request): return render(request, self.template_name, context=self.get_context_data()) diff --git a/src/registrar/views/transfer_user.py b/src/registrar/views/transfer_user.py new file mode 100644 index 000000000..ac51cd20b --- /dev/null +++ b/src/registrar/views/transfer_user.py @@ -0,0 +1,172 @@ +import logging + +from django.shortcuts import render, get_object_or_404, redirect +from django.views import View +from registrar.models.domain import Domain +from registrar.models.domain_information import DomainInformation +from registrar.models.domain_request import DomainRequest +from registrar.models.portfolio import Portfolio +from registrar.models.user import User +from django.contrib.admin import site +from django.contrib import messages + +from registrar.models.user_domain_role import UserDomainRole +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.verified_by_staff import VerifiedByStaff +from typing import Any, List + +logger = logging.getLogger(__name__) + + +class TransferUserView(View): + """Transfer user methods that set up the transfer_user template and handle the forms on it.""" + + JOINS = [ + (DomainRequest, "creator"), + (DomainInformation, "creator"), + (Portfolio, "creator"), + (DomainRequest, "investigator"), + (UserDomainRole, "user"), + (VerifiedByStaff, "requestor"), + (UserPortfolioPermission, "user"), + ] + + # Future-proofing in case joined fields get added on the user model side + # This was tested in the first portfolio model iteration and works + USER_FIELDS: List[Any] = [] + + def get(self, request, user_id): + """current_user referes to the 'source' user where the button that redirects to this view was clicked. + other_users exclude current_user and populate a dropdown, selected_user is the selection in the dropdown. + + This also querries the relevant domains and domain requests, and the admin context needed for the sidenav.""" + + current_user = get_object_or_404(User, pk=user_id) + other_users = User.objects.exclude(pk=user_id).order_by( + "first_name", "last_name" + ) # Exclude the current user from the dropdown + + # Get the default admin site context, needed for the sidenav + admin_context = site.each_context(request) + + context = { + "current_user": current_user, + "other_users": other_users, + "logged_in_user": request.user, + **admin_context, # Include the admin context + "current_user_domains": self.get_domains(current_user), + "current_user_domain_requests": self.get_domain_requests(current_user), + "current_user_portfolios": self.get_portfolios(current_user), + } + + selected_user_id = request.GET.get("selected_user") + if selected_user_id: + selected_user = get_object_or_404(User, pk=selected_user_id) + context["selected_user"] = selected_user + context["selected_user_domains"] = self.get_domains(selected_user) + context["selected_user_domain_requests"] = self.get_domain_requests(selected_user) + context["selected_user_portfolios"] = self.get_portfolios(selected_user) + + return render(request, "admin/transfer_user.html", context) + + def post(self, request, user_id): + """This handles the transfer from selected_user to current_user then deletes selected_user. + + NOTE: We have a ticket to refactor this into a more solid lookup for related fields in #2645""" + + current_user = get_object_or_404(User, pk=user_id) + selected_user_id = request.POST.get("selected_user") + selected_user = get_object_or_404(User, pk=selected_user_id) + + try: + change_logs = [] + + # Transfer specific fields + self.transfer_user_fields_and_log(selected_user, current_user, change_logs) + + # Perform the updates and log the changes + for model_class, field_name in self.JOINS: + self.update_joins_and_log(model_class, field_name, selected_user, current_user, change_logs) + + # Success message if any related objects were updated + if change_logs: + success_message = f"Data transferred successfully for the following objects: {change_logs}" + messages.success(request, success_message) + + selected_user.delete() + messages.success(request, f"Deleted {selected_user} {selected_user.username}") + + except Exception as e: + messages.error(request, f"An error occurred during the transfer: {e}") + + return redirect("admin:registrar_user_change", object_id=user_id) + + @classmethod + def update_joins_and_log(cls, model_class, field_name, selected_user, current_user, change_logs): + """ + Helper function to update the user join fields for a given model and log the changes. + """ + + filter_kwargs = {field_name: selected_user} + updated_objects = model_class.objects.filter(**filter_kwargs) + + for obj in updated_objects: + # Check for duplicate UserDomainRole before updating + if model_class == UserDomainRole: + if model_class.objects.filter(user=current_user, domain=obj.domain).exists(): + continue # Skip the update to avoid a duplicate + + # Update the field on the object and save it + setattr(obj, field_name, current_user) + obj.save() + + # Log the change + cls.log_change(obj, field_name, selected_user, current_user, change_logs) + + @classmethod + def transfer_user_fields_and_log(cls, selected_user, current_user, change_logs): + """ + Transfers portfolio fields from the selected_user to the current_user. + Logs the changes for each transferred field. + """ + for field in cls.USER_FIELDS: + field_value = getattr(selected_user, field, None) + + if field_value: + setattr(current_user, field, field_value) + cls.log_change(current_user, field, field_value, field_value, change_logs) + + current_user.save() + + @classmethod + def log_change(cls, obj, field_name, field_value, new_value, change_logs): + """Logs the change for a specific field on an object""" + log_entry = f'Changed {field_name} from "{field_value}" to "{new_value}" on {obj}' + + logger.info(log_entry) + + # Collect the related object for the success message + change_logs.append(log_entry) + + @classmethod + def get_domains(cls, user): + """A simplified version of domains_json""" + user_domain_roles = UserDomainRole.objects.filter(user=user) + domain_ids = user_domain_roles.values_list("domain_id", flat=True) + domains = Domain.objects.filter(id__in=domain_ids) + + return domains + + @classmethod + def get_domain_requests(cls, user): + """A simplified version of domain_requests_json""" + domain_requests = DomainRequest.objects.filter(creator=user) + + return domain_requests + + @classmethod + def get_portfolios(cls, user): + """Get portfolios""" + portfolios = UserPortfolioPermission.objects.filter(user=user) + + return portfolios diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 7219f4358..7e4e19085 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -7,5 +7,6 @@ from .permission_views import ( DomainRequestPermissionWithdrawView, DomainInvitationPermissionDeleteView, DomainRequestWizardPermissionView, + PortfolioMembersPermission, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 6f0745f41..4552008de 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -81,12 +81,12 @@ class OrderableFieldsMixin: Or for fields with multiple order_fields: ``` - def get_sortable_submitter(self, obj): - return obj.submitter + def get_sortable_creator(self, obj): + return obj.creator # Allows column order sorting - get_sortable_submitter.admin_order_field = ["submitter__first_name", "submitter__last_name"] + get_sortable_creator.admin_order_field = ["creator__first_name", "creator__last_name"] # Sets column's header - get_sortable_submitter.short_description = "submitter" + get_sortable_creator.short_description = "creator" ``` Parameters: @@ -114,8 +114,8 @@ class OrderableFieldsMixin: Returns (example): ``` - def get_submitter(self, obj): - return obj.submitter + def get_creator(self, obj): + return obj.creator ``` """ attr = getattr(obj, field) @@ -433,7 +433,7 @@ class PortfolioDomainsPermission(PortfolioBasePermission): up from the portfolio's primary key in self.kwargs["pk"]""" portfolio = self.request.session.get("portfolio") - if not self.request.user.has_domains_portfolio_permission(portfolio): + if not self.request.user.has_any_domains_portfolio_permission(portfolio): return False return super().has_permission() @@ -450,7 +450,24 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission): up from the portfolio's primary key in self.kwargs["pk"]""" portfolio = self.request.session.get("portfolio") - if not self.request.user.has_domain_requests_portfolio_permission(portfolio): + if not self.request.user.has_any_requests_portfolio_permission(portfolio): + return False + + return super().has_permission() + + +class PortfolioMembersPermission(PortfolioBasePermission): + """Permission mixin that allows access to portfolio members pages if user + has access, otherwise 403""" + + def has_permission(self): + """Check if this user has access to members for this portfolio. + + The user is in self.request.user and the portfolio can be looked + up from the portfolio's primary key in self.kwargs["pk"]""" + + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_view_members(portfolio): return False return super().has_permission() diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 0ff7d1676..e7031cf0d 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -18,6 +18,7 @@ from .mixins import ( UserDeleteDomainRolePermission, UserProfilePermission, PortfolioBasePermission, + PortfolioMembersPermission, ) import logging @@ -229,3 +230,11 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P This abstract view cannot be instantiated. Actual views must specify `template_name`. """ + + +class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC): + """Abstract base view for portfolio domain request views that enforces permissions. + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ diff --git a/src/zap.conf b/src/zap.conf index c97897aeb..dd9ae1565 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -72,6 +72,7 @@ 10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/suborganization/ +10038 OUTOFSCOPE http://app:8080/transfer/ # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers