diff --git a/.github/ISSUE_TEMPLATE/developer-onboarding.md b/.github/ISSUE_TEMPLATE/developer-onboarding.md index 8d0f9c2d8..94b2a367d 100644 --- a/.github/ISSUE_TEMPLATE/developer-onboarding.md +++ b/.github/ISSUE_TEMPLATE/developer-onboarding.md @@ -37,7 +37,7 @@ cf login -a api.fr.cloud.gov --sso - [ ] Optional- add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does. ### Steps for the onboarder -- [ ] Add the onboardee to cloud.gov org (cisa-getgov-prototyping) +- [ ] Add the onboardee to cloud.gov org (cisa-dotgov) - [ ] Setup a [developer specific space for the new developer](#setting-up-developer-sandbox) - [ ] Add the onboardee to our login.gov sandbox team (`.gov Registrar`) via the [dashboard](https://dashboard.int.identitysandbox.gov/) diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 3b418c4d5..5d9000401 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -18,6 +18,7 @@ jobs: || startsWith(github.head_ref, 'za/') || startsWith(github.head_ref, 'rh/') || startsWith(github.head_ref, 'nl/') + || startsWith(github.head_ref, 'dk/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" @@ -52,7 +53,7 @@ jobs: with: cf_username: ${{ secrets[env.CF_USERNAME] }} cf_password: ${{ secrets[env.CF_PASSWORD] }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: ${{ env.ENVIRONMENT }} push_arguments: "-f ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml" comment: diff --git a/.github/workflows/deploy-stable.yaml b/.github/workflows/deploy-stable.yaml index 2f1a2a6b4..0a40ac097 100644 --- a/.github/workflows/deploy-stable.yaml +++ b/.github/workflows/deploy-stable.yaml @@ -36,6 +36,6 @@ jobs: with: cf_username: ${{ secrets.CF_STABLE_USERNAME }} cf_password: ${{ secrets.CF_STABLE_PASSWORD }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: stable push_arguments: "-f ops/manifests/manifest-stable.yaml" diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 068751c30..1db63e2a2 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -36,6 +36,6 @@ jobs: with: cf_username: ${{ secrets.CF_STAGING_USERNAME }} cf_password: ${{ secrets.CF_STAGING_PASSWORD }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: staging push_arguments: "-f ops/manifests/manifest-staging.yaml" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index fbfc7f17a..705014af1 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -24,6 +24,7 @@ on: - ab - bl - rjm + - dk jobs: migrate: @@ -37,6 +38,6 @@ jobs: with: cf_username: ${{ secrets[env.CF_USERNAME] }} cf_password: ${{ secrets[env.CF_PASSWORD] }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: ${{ github.event.inputs.environment }} full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate" diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index fea4f19e2..0bf1af2d9 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -25,6 +25,7 @@ on: - ab - bl - rjm + - dk jobs: reset-db: @@ -38,7 +39,7 @@ jobs: with: cf_username: ${{ secrets[env.CF_USERNAME] }} cf_password: ${{ secrets[env.CF_PASSWORD] }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: ${{ github.event.inputs.environment }} full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush" @@ -47,7 +48,7 @@ jobs: with: cf_username: ${{ secrets[env.CF_USERNAME] }} cf_password: ${{ secrets[env.CF_PASSWORD] }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: ${{ github.event.inputs.environment }} full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate" @@ -56,6 +57,6 @@ jobs: with: cf_username: ${{ secrets[env.CF_USERNAME] }} cf_password: ${{ secrets[env.CF_PASSWORD] }} - cf_org: cisa-getgov-prototyping + cf_org: cisa-dotgov cf_space: ${{ github.event.inputs.environment }} full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d745f76c7..ab15c660f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,22 @@ There are a handful of things we do not commit to the repository: - Compliance documentation that includes IP addresses - Secrets of any kind +## Branch naming convention + +For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description +Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable. + +## Approvals + +When a code change is made that is not user facing, then the following is required: +- a developer approves the PR + +When a code change is made that is user facing, beyond content updates, then the following are required: +- a developer approves the PR +- a designer approves the PR or checks off all relevant items in this checklist + +Content or document updates require a single person to approve. + ## Project Management We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking. diff --git a/docs/developer/README.md b/docs/developer/README.md index 71b36e84e..de97b6107 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -94,6 +94,7 @@ The endpoint /admin can be used to view and manage site content, including but n ``` 5. In the browser, navigate to /admin. To verify that all is working correctly, under "domain applications" you should see fake domains with various fake statuses. +6. Add an optional email key/value pair ### Adding an Analyst to /admin Analysts are a variant of the admin role with limited permissions. The process for adding an Analyst is much the same as adding an admin: @@ -115,6 +116,7 @@ Analysts are a variant of the admin role with limited permissions. The process f ``` 5. In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only. +6. Add an optional email key/value pair Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst` ## Adding to CODEOWNERS (optional) diff --git a/ops/manifests/manifest-dk.yaml b/ops/manifests/manifest-dk.yaml new file mode 100644 index 000000000..87de8a496 --- /dev/null +++ b/ops/manifests/manifest-dk.yaml @@ -0,0 +1,29 @@ +--- +applications: +- name: getgov-dk + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # Public site base URL + GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/ + routes: + - route: getgov-dk.app.cloud.gov + services: + - getgov-credentials + - getgov-dk-database diff --git a/ops/scripts/create_dev_sandbox.sh b/ops/scripts/create_dev_sandbox.sh index f180ada8d..5eeed9c10 100755 --- a/ops/scripts/create_dev_sandbox.sh +++ b/ops/scripts/create_dev_sandbox.sh @@ -21,9 +21,9 @@ then git checkout -b new-dev-sandbox-$1 fi -cf target -o cisa-getgov-prototyping +cf target -o cisa-dotgov -read -p "Are you logged in to the cisa-getgov-prototyping CF org above? (y/n) " -n 1 -r +read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]] then @@ -49,9 +49,9 @@ sed -i '' '/getgov-staging.app.cloud.gov/ {a\ echo "Creating new cloud.gov space for $1..." cf create-space $1 -cf target -o "cisa-getgov-prototyping" -s $1 -cf bind-security-group public_networks_egress cisa-getgov-prototyping --space $1 -cf bind-security-group trusted_local_networks_egress cisa-getgov-prototyping --space $1 +cf target -o "cisa-dotgov" -s $1 +cf bind-security-group public_networks_egress cisa-dotgov --space $1 +cf bind-security-group trusted_local_networks_egress cisa-dotgov --space $1 echo "Creating new cloud.gov DB for $1. This usually takes about 5 minutes..." cf create-service aws-rds micro-psql getgov-$1-database @@ -91,7 +91,7 @@ cd .. cf push getgov-$1 -f ops/manifests/manifest-$1.yaml read -p "Please provide the email of the space developer: " -r -cf set-space-role $REPLY cisa-getgov-prototyping $1 SpaceDeveloper +cf set-space-role $REPLY cisa-dotgov $1 SpaceDeveloper read -p "Should we run migrations? (y/n) " -n 1 -r echo diff --git a/ops/scripts/deploy.sh b/ops/scripts/deploy.sh index d8dd45fbc..50f0f5bfa 100755 --- a/ops/scripts/deploy.sh +++ b/ops/scripts/deploy.sh @@ -4,7 +4,7 @@ ../ops/scripts/build.sh # Deploy to sandbox -cf target -o cisa-getgov-prototyping -s $1 +cf target -o cisa-dotgov -s $1 cf push getgov-$1 -f ../ops/manifests/manifest-$1.yaml # migrations need to be run manually. Developers can use this command diff --git a/ops/scripts/destroy_dev_sandbox.sh b/ops/scripts/destroy_dev_sandbox.sh index 47a7f26d8..9e233b2f1 100755 --- a/ops/scripts/destroy_dev_sandbox.sh +++ b/ops/scripts/destroy_dev_sandbox.sh @@ -20,9 +20,9 @@ then git checkout -b remove-dev-sandbox-$1 fi -cf target -o cisa-getgov-prototyping -s $1 +cf target -o cisa-dotgov -s $1 -read -p "Are you logged in to the cisa-getgov-prototyping CF org above? (y/n) " -n 1 -r +read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]] then diff --git a/ops/scripts/rotate_cloud_secrets.sh b/ops/scripts/rotate_cloud_secrets.sh index aa77c39a8..23e4aa590 100755 --- a/ops/scripts/rotate_cloud_secrets.sh +++ b/ops/scripts/rotate_cloud_secrets.sh @@ -9,8 +9,8 @@ if [ -z "$1" ]; then exit 1 fi -cf target -o cisa-getgov-prototyping -s $1 -read -p "Are you logged in to the cisa-getgov-prototyping CF org above and targeting the correct space? (y/n) " -n 1 -r +cf target -o cisa-dotgov -s $1 +read -p "Are you logged in to the cisa-dotgov CF org above and targeting the correct space? (y/n) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]] then diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 2d42fec07..a832d34bd 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4,14 +4,13 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.contenttypes.models import ContentType from django.http.response import HttpResponseRedirect from django.urls import reverse - +from registrar.models.utility.admin_sort_fields import AdminSortFields from . import models logger = logging.getLogger(__name__) -class AuditedAdmin(admin.ModelAdmin): - +class AuditedAdmin(admin.ModelAdmin, AdminSortFields): """Custom admin to make auditing easier.""" def history_view(self, request, object_id, extra_context=None): @@ -24,9 +23,13 @@ class AuditedAdmin(admin.ModelAdmin): ) ) + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Used to sort dropdown fields alphabetically but can be expanded upon""" + form_field = super().formfield_for_foreignkey(db_field, request, **kwargs) + return self.form_field_order_helper(form_field, db_field) + class ListHeaderAdmin(AuditedAdmin): - """Custom admin to add a descriptive subheader to list views.""" def changelist_view(self, request, extra_context=None): @@ -193,7 +196,6 @@ class DomainAdmin(ListHeaderAdmin): class ContactAdmin(ListHeaderAdmin): - """Custom contact admin class to add search.""" search_fields = ["email", "first_name", "last_name"] diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index f6873b226..8b53fa82a 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -580,6 +580,7 @@ ALLOWED_HOSTS = [ "getgov-ab.app.cloud.gov", "getgov-bl.app.cloud.gov", "getgov-rjm.app.cloud.gov", + "getgov-dk.app.cloud.gov", "get.gov", ] diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 2c94a1eb4..0b1b8926d 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -79,6 +79,7 @@ class UserFixture: "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844", "first_name": "Rachid-Analyst", "last_name": "Mrad-Analyst", + "email": "rachid.mrad@gmail.com", }, { "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd", @@ -129,6 +130,8 @@ class UserFixture: user.is_superuser = True user.first_name = admin["first_name"] user.last_name = admin["last_name"] + if "email" in admin.keys(): + user.email = admin["email"] user.is_staff = True user.is_active = True user.save() @@ -146,6 +149,8 @@ class UserFixture: user.is_superuser = False user.first_name = staff["first_name"] user.last_name = staff["last_name"] + if "email" in admin.keys(): + user.email = admin["email"] user.is_staff = True user.is_active = True diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 7d017ab38..c06985079 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -594,6 +594,11 @@ class DomainApplication(TimeStampedModel): @transition(field="status", source=[SUBMITTED, IN_REVIEW], target=WITHDRAWN) def withdraw(self): """Withdraw an application that has been submitted.""" + self._send_status_update_email( + "withdraw", + "emails/domain_request_withdrawn.txt", + "emails/domain_request_withdrawn_subject.txt", + ) @transition(field="status", source=[IN_REVIEW, APPROVED], target=REJECTED) def reject(self): diff --git a/src/registrar/models/utility/admin_form_order_helper.py b/src/registrar/models/utility/admin_form_order_helper.py new file mode 100644 index 000000000..acc26db11 --- /dev/null +++ b/src/registrar/models/utility/admin_form_order_helper.py @@ -0,0 +1,63 @@ +import logging +from typing import Dict +from django.forms import ModelChoiceField + +logger = logging.getLogger(__name__) + + +class SortingDict: + """Stores a sorting dictionary object""" + + _sorting_dict: Dict[type, type] = {} + + def __init__(self, model_list, sort_list): + self._sorting_dict = { + "dropDownSelected": self.convert_list_to_dict(model_list), + "sortBy": sort_list, + } + + # Used in __init__ for model_list for performance reasons + def convert_list_to_dict(self, value_list): + """Used internally to convert model_list to a dictionary""" + return {item: item for item in value_list} + + def get_dict(self): + """Grabs the associated dictionary item, + has two fields: 'dropDownSelected': model_list and 'sortBy': sort_list""" + # This should never happen so we need to log this + if self._sorting_dict is None: + raise ValueError("_sorting_dict was None") + return self._sorting_dict + + +class AdminFormOrderHelper: + """A helper class to order a dropdown field in Django Admin, + takes the fields you want to order by as an array""" + + # Used to keep track of how we want to order_by certain FKs + _sorting_list: list[SortingDict] = [] + + def __init__(self, sort: list[SortingDict]): + self._sorting_list = sort + + def get_ordered_form_field(self, form_field, db_field) -> ModelChoiceField | None: + """Orders the queryset for a ModelChoiceField + based on the order_by_dict dictionary""" + _order_by_list = [] + + for item in self._sorting_list: + item_dict = item.get_dict() + drop_down_selected = item_dict.get("dropDownSelected") + sort_by = item_dict.get("sortBy") + + if db_field.name in drop_down_selected: + _order_by_list = sort_by + # Exit loop when order_by_list is found + break + + # Only order if we choose to do so + # noqa for the linter... reduces readability otherwise + if _order_by_list is not None and _order_by_list != []: # noqa + form_field.queryset = form_field.queryset.order_by(*_order_by_list) + + return form_field diff --git a/src/registrar/models/utility/admin_sort_fields.py b/src/registrar/models/utility/admin_sort_fields.py new file mode 100644 index 000000000..8037c6df0 --- /dev/null +++ b/src/registrar/models/utility/admin_sort_fields.py @@ -0,0 +1,27 @@ +from registrar.models.utility.admin_form_order_helper import ( + AdminFormOrderHelper, + SortingDict, +) + + +class AdminSortFields: + # Used to keep track of how we want to order_by certain FKs + foreignkey_orderby_dict: list[SortingDict] = [ + # foreign_key - order_by + # Handles fields that are sorted by 'first_name / last_name + SortingDict( + ["submitter", "authorizing_official", "investigator", "creator", "user"], + ["first_name", "last_name"], + ), + # Handles fields that are sorted by 'name' + SortingDict(["domain", "requested_domain"], ["name"]), + SortingDict(["domain_application"], ["requested_domain__name"]), + ] + + # For readability purposes, but can be replaced with a one liner + def form_field_order_helper(self, form_field, db_field): + """A shorthand for AdminFormOrderHelper(foreignkey_orderby_dict) + .get_ordered_form_field(form_field, db_field)""" + + form = AdminFormOrderHelper(self.foreignkey_orderby_dict) + return form.get_ordered_form_field(form_field, db_field) diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html index 2488bb449..99f6a1d4c 100644 --- a/src/registrar/templates/application_status.html +++ b/src/registrar/templates/application_status.html @@ -22,7 +22,7 @@ {% if domainapplication.status == 'approved' %} Approved {% elif domainapplication.status == 'in review' %} In Review {% elif domainapplication.status == 'rejected' %} Rejected - {% elif domainapplication.status == 'submitted' %} Received + {% elif domainapplication.status == 'submitted' %} Submitted {% else %}ERROR Please contact technical support/dev {% endif %}
diff --git a/src/registrar/templates/emails/domain_request_withdrawn.txt b/src/registrar/templates/emails/domain_request_withdrawn.txt new file mode 100644 index 000000000..333fdb3b7 --- /dev/null +++ b/src/registrar/templates/emails/domain_request_withdrawn.txt @@ -0,0 +1,26 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi {{ application.submitter.first_name }}. + +Your .gov domain request has been withdrawn. +DOMAIN REQUESTED: {{ application.requested_domain.name }} +REQUEST #: {{ application.id }} +STATUS: Withdrawn + + +YOU CAN EDIT YOUR WITHDRAWN REQUEST + +The details of your withdrawn request are included below. You can edit and resubmit this application by logging into the registrar.You don't have any active domain requests right now
- {% endif %} +You don't have any active domain requests right now
+ {% endif %} + {# Note: Reimplement this after MVP.. #} + - + + --> {% else %} {# not user.is_authenticated #} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 4359fc454..c4a2772b0 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -2,13 +2,26 @@ import os import logging from contextlib import contextmanager +import random +from string import ascii_uppercase from unittest.mock import Mock from typing import List, Dict from django.conf import settings from django.contrib.auth import get_user_model, login -from registrar.models import Contact, DraftDomain, Website, DomainApplication, User +from registrar.models import ( + Contact, + DraftDomain, + Website, + DomainApplication, + DomainInvitation, + User, + DomainInformation, + Domain, +) + +logger = logging.getLogger(__name__) def get_handlers(): @@ -88,6 +101,308 @@ class MockSESClient(Mock): self.EMAILS_SENT.append({"args": args, "kwargs": kwargs}) +class AuditedAdminMockData: + """Creates simple data mocks for AuditedAdminTest. + Can likely be more generalized, but the primary purpose of this class is to simplify + mock data creation, especially for lists of items, + by making the assumption that for most use cases we don't have to worry about + data 'accuracy' ('testy 2' is not an accurate first_name for example), we just care about + implementing some kind of patterning, especially with lists of items. + + Two variables are used across multiple functions: + + *item_name* - Used in patterning. Will be appended en masse to multiple str fields, + like first_name. For example, item_name 'egg' will return a user object of: + + first_name: 'egg first_name:user', + last_name: 'egg last_name:user', + username: 'egg username:user' + + where 'user' is the short_hand + + *short_hand* - Used in patterning. Certain fields will have ':{shorthand}' appended to it, + as a way to optionally include metadata in the str itself. Can be further expanded on. + Came from a bug where different querysets used in testing would effectively be 'anonymized', wherein + it would only display a list of types, but not include the variable name. + """ # noqa + + # Constants for different domain object types + INFORMATION = "information" + APPLICATION = "application" + INVITATION = "invitation" + + def dummy_user(self, item_name, short_hand): + """Creates a dummy user object, + but with a shorthand and support for multiple""" + user = User.objects.get_or_create( + first_name="{} first_name:{}".format(item_name, short_hand), + last_name="{} last_name:{}".format(item_name, short_hand), + username="{} username:{}".format(item_name, short_hand), + )[0] + return user + + def dummy_contact(self, item_name, short_hand): + """Creates a dummy contact object""" + contact = Contact.objects.get_or_create( + first_name="{} first_name:{}".format(item_name, short_hand), + last_name="{} last_name:{}".format(item_name, short_hand), + title="{} title:{}".format(item_name, short_hand), + email="{}testy@town.com".format(item_name), + phone="(555) 555 5555", + )[0] + return contact + + def dummy_draft_domain(self, item_name, prebuilt=False): + """ + Creates a dummy DraftDomain object + Args: + item_name (str): Value for 'name' in a DraftDomain object. + prebuilt (boolean): Determines return type. + Returns: + DraftDomain: Where name = 'item_name'. If prebuilt = True, then + name will be "city{}.gov".format(item_name). + """ + if prebuilt: + item_name = "city{}.gov".format(item_name) + return DraftDomain.objects.get_or_create(name=item_name)[0] + + def dummy_domain(self, item_name, prebuilt=False): + """ + Creates a dummy domain object + Args: + item_name (str): Value for 'name' in a Domain object. + prebuilt (boolean): Determines return type. + Returns: + Domain: Where name = 'item_name'. If prebuilt = True, then + domain name will be "city{}.gov".format(item_name). + """ + if prebuilt: + item_name = "city{}.gov".format(item_name) + return Domain.objects.get_or_create(name=item_name)[0] + + def dummy_website(self, item_name): + """ + Creates a dummy website object + Args: + item_name (str): Value for 'website' in a Website object. + Returns: + Website: Where website = 'item_name'. + """ + return Website.objects.get_or_create(website=item_name)[0] + + def dummy_alt(self, item_name): + """ + Creates a dummy website object for alternates + Args: + item_name (str): Value for 'website' in a Website object. + Returns: + Website: Where website = "cityalt{}.gov".format(item_name). + """ + return self.dummy_website(item_name="cityalt{}.gov".format(item_name)) + + def dummy_current(self, item_name): + """ + Creates a dummy website object for current + Args: + item_name (str): Value for 'website' in a Website object. + prebuilt (boolean): Determines return type. + Returns: + Website: Where website = "city{}.gov".format(item_name) + """ + return self.dummy_website(item_name="city{}.com".format(item_name)) + + def get_common_domain_arg_dictionary( + self, + item_name, + org_type="federal", + federal_type="executive", + purpose="Purpose of the site", + ): + """ + Generates a generic argument dict for most domains + Args: + item_name (str): A shared str value appended to first_name, last_name, + organization_name, address_line1, address_line2, + title, email, and username. + + org_type (str - optional): Sets a domains org_type + + federal_type (str - optional): Sets a domains federal_type + + purpose (str - optional): Sets a domains purpose + Returns: + Dictionary: { + organization_type: str, + federal_type: str, + purpose: str, + organization_name: str = "{} organization".format(item_name), + address_line1: str = "{} address_line1".format(item_name), + address_line2: str = "{} address_line2".format(item_name), + is_policy_acknowledged: boolean = True, + state_territory: str = "NY", + zipcode: str = "10002", + type_of_work: str = "e-Government", + anything_else: str = "There is more", + authorizing_official: Contact = self.dummy_contact(item_name, "authorizing_official"), + submitter: Contact = self.dummy_contact(item_name, "submitter"), + creator: User = self.dummy_user(item_name, "creator"), + } + """ # noqa + common_args = dict( + organization_type=org_type, + federal_type=federal_type, + purpose=purpose, + organization_name="{} organization".format(item_name), + address_line1="{} address_line1".format(item_name), + address_line2="{} address_line2".format(item_name), + is_policy_acknowledged=True, + state_territory="NY", + zipcode="10002", + type_of_work="e-Government", + anything_else="There is more", + authorizing_official=self.dummy_contact(item_name, "authorizing_official"), + submitter=self.dummy_contact(item_name, "submitter"), + creator=self.dummy_user(item_name, "creator"), + ) + return common_args + + def dummy_kwarg_boilerplate( + self, + domain_type, + item_name, + status=DomainApplication.STARTED, + org_type="federal", + federal_type="executive", + purpose="Purpose of the site", + ): + """ + Returns a prebuilt kwarg dictionary for DomainApplication, + DomainInformation, or DomainInvitation. + Args: + domain_type (str): is either 'application', 'information', + or 'invitation'. + + item_name (str): A shared str value appended to first_name, last_name, + organization_name, address_line1, address_line2, + title, email, and username. + + status (str - optional): Defines the status for DomainApplication, + e.g. DomainApplication.STARTED + + org_type (str - optional): Sets a domains org_type + + federal_type (str - optional): Sets a domains federal_type + + purpose (str - optional): Sets a domains purpose + Returns: + dict: Returns a dictionary structurally consistent with the expected input + of either DomainApplication, DomainInvitation, or DomainInformation + based on the 'domain_type' field. + """ # noqa + common_args = self.get_common_domain_arg_dictionary( + item_name, org_type, federal_type, purpose + ) + full_arg_dict = None + match domain_type: + case self.APPLICATION: + full_arg_dict = dict( + **common_args, + requested_domain=self.dummy_draft_domain(item_name), + investigator=self.dummy_user(item_name, "investigator"), + status=status, + ) + case self.INFORMATION: + domain_app = self.create_full_dummy_domain_application(item_name) + full_arg_dict = dict( + **common_args, + domain=self.dummy_domain(item_name, True), + domain_application=domain_app, + ) + case self.INVITATION: + full_arg_dict = dict( + email="test_mail@mail.com", + domain=self.dummy_domain(item_name, True), + status=DomainInvitation.INVITED, + ) + return full_arg_dict + + def create_full_dummy_domain_application( + self, item_name, status=DomainApplication.STARTED + ): + """Creates a dummy domain application object""" + domain_application_kwargs = self.dummy_kwarg_boilerplate( + self.APPLICATION, item_name, status + ) + application = DomainApplication.objects.get_or_create( + **domain_application_kwargs + )[0] + return application + + def create_full_dummy_domain_information( + self, item_name, status=DomainApplication.STARTED + ): + """Creates a dummy domain information object""" + domain_application_kwargs = self.dummy_kwarg_boilerplate( + self.INFORMATION, item_name, status + ) + application = DomainInformation.objects.get_or_create( + **domain_application_kwargs + )[0] + return application + + def create_full_dummy_domain_invitation( + self, item_name, status=DomainApplication.STARTED + ): + """Creates a dummy domain invitation object""" + domain_application_kwargs = self.dummy_kwarg_boilerplate( + self.INVITATION, item_name, status + ) + application = DomainInvitation.objects.get_or_create( + **domain_application_kwargs + )[0] + + return application + + def create_full_dummy_domain_object( + self, + domain_type, + item_name, + has_other_contacts=True, + has_current_website=True, + has_alternative_gov_domain=True, + status=DomainApplication.STARTED, + ): + """A helper to create a dummy domain application object""" + application = None + match domain_type: + case self.APPLICATION: + application = self.create_full_dummy_domain_application( + item_name, status + ) + case self.INVITATION: + application = self.create_full_dummy_domain_invitation( + item_name, status + ) + case self.INFORMATION: + application = self.create_full_dummy_domain_information( + item_name, status + ) + case _: + raise ValueError("Invalid domain_type, must conform to given constants") + + if has_other_contacts and domain_type != self.INVITATION: + other = self.dummy_contact(item_name, "other") + application.other_contacts.add(other) + if has_current_website and domain_type == self.APPLICATION: + current = self.dummy_current(item_name) + application.current_websites.add(current) + if has_alternative_gov_domain and domain_type == self.APPLICATION: + alt = self.dummy_alt(item_name) + application.alternative_domains.add(alt) + + return application + + def mock_user(): """A simple user.""" user_kwargs = dict( @@ -142,15 +457,15 @@ def completed_application( alt, _ = Website.objects.get_or_create(website="city1.gov") current, _ = Website.objects.get_or_create(website="city.com") you, _ = Contact.objects.get_or_create( - first_name="Testy you", - last_name="Tester you", + 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="Testy2", - last_name="Tester2", + first_name="Testy", + last_name="Tester", title="Another Tester", email="testy2@town.com", phone="(555) 555 5557", @@ -188,3 +503,18 @@ def completed_application( application.alternative_domains.add(alt) return application + + +def multiple_unalphabetical_domain_objects( + domain_type=AuditedAdminMockData.APPLICATION, +): + """Returns a list of generic domain objects for testing purposes""" + applications = [] + list_of_letters = list(ascii_uppercase) + random.shuffle(list_of_letters) + + mock = AuditedAdminMockData() + for object_name in list_of_letters: + application = mock.create_full_dummy_domain_object(domain_type, object_name) + applications.append(application) + return applications diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 0ae1c660f..4a38d3576 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,14 +1,35 @@ from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite -from registrar.admin import DomainApplicationAdmin, ListHeaderAdmin, MyUserAdmin -from registrar.models import DomainApplication, DomainInformation, User -from .common import completed_application, mock_user, create_superuser, create_user + +from registrar.admin import ( + DomainApplicationAdmin, + ListHeaderAdmin, + MyUserAdmin, + AuditedAdmin, +) +from registrar.models import ( + DomainApplication, + DomainInformation, + User, + DomainInvitation, +) +from .common import ( + completed_application, + mock_user, + create_superuser, + create_user, + multiple_unalphabetical_domain_objects, +) + from django.contrib.auth import get_user_model from unittest.mock import patch from django.conf import settings from unittest.mock import MagicMock import boto3_mocking # type: ignore +import logging + +logger = logging.getLogger(__name__) class TestDomainApplicationAdmin(TestCase): @@ -531,3 +552,224 @@ class MyUserAdminTest(TestCase): def tearDown(self): User.objects.all().delete() + + +class AuditedAdminTest(TestCase): + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.client = Client(HTTP_HOST="localhost:8080") + + def order_by_desired_field_helper( + self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names + ): + formatted_sort_fields = [] + for obj in obj_names: + formatted_sort_fields.append("{}__{}".format(field_name, obj)) + + ordered_list = list( + obj_to_sort.get_queryset(request) + .order_by(*formatted_sort_fields) + .values_list(*formatted_sort_fields) + ) + + return ordered_list + + def test_alphabetically_sorted_fk_fields_domain_application(self): + tested_fields = [ + DomainApplication.authorizing_official.field, + DomainApplication.submitter.field, + DomainApplication.investigator.field, + DomainApplication.creator.field, + DomainApplication.requested_domain.field, + ] + + # Creates multiple domain applications - review status does not matter + applications = multiple_unalphabetical_domain_objects("application") + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domainapplication/{}/change/".format(applications[0].pk) + ) + + model_admin = AuditedAdmin(DomainApplication, self.site) + + sorted_fields = [] + # Typically we wouldn't want two nested for fields, + # but both fields are of a fixed length. + # For test case purposes, this should be performant. + for field in tested_fields: + isNamefield: bool = field == DomainApplication.requested_domain.field + if isNamefield: + sorted_fields = ["name"] + else: + sorted_fields = ["first_name", "last_name"] + # We want both of these to be lists, as it is richer test wise. + + desired_order = self.order_by_desired_field_helper( + model_admin, request, field.name, *sorted_fields + ) + current_sort_order = list( + model_admin.formfield_for_foreignkey(field, request).queryset + ) + + # Conforms to the same object structure as desired_order + current_sort_order_coerced_type = [] + + # This is necessary as .queryset and get_queryset + # return lists of different types/structures. + # We need to parse this data and coerce them into the same type. + for contact in current_sort_order: + if not isNamefield: + first = contact.first_name + last = contact.last_name + else: + first = contact.name + last = None + + name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":") + if name_tuple is not None: + current_sort_order_coerced_type.append(name_tuple) + + self.assertEqual( + desired_order, + current_sort_order_coerced_type, + "{} is not ordered alphabetically".format(field.name), + ) + + def test_alphabetically_sorted_fk_fields_domain_information(self): + tested_fields = [ + DomainInformation.authorizing_official.field, + DomainInformation.submitter.field, + DomainInformation.creator.field, + (DomainInformation.domain.field, ["name"]), + (DomainInformation.domain_application.field, ["requested_domain__name"]), + ] + # Creates multiple domain applications - review status does not matter + applications = multiple_unalphabetical_domain_objects("information") + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domaininformation/{}/change/".format(applications[0].pk) + ) + + model_admin = AuditedAdmin(DomainInformation, self.site) + + sorted_fields = [] + # Typically we wouldn't want two nested for fields, + # but both fields are of a fixed length. + # For test case purposes, this should be performant. + for field in tested_fields: + isOtherOrderfield: bool = isinstance(field, tuple) + field_obj = None + if isOtherOrderfield: + sorted_fields = field[1] + field_obj = field[0] + else: + sorted_fields = ["first_name", "last_name"] + field_obj = field + # We want both of these to be lists, as it is richer test wise. + desired_order = self.order_by_desired_field_helper( + model_admin, request, field_obj.name, *sorted_fields + ) + current_sort_order = list( + model_admin.formfield_for_foreignkey(field_obj, request).queryset + ) + + # Conforms to the same object structure as desired_order + current_sort_order_coerced_type = [] + + # This is necessary as .queryset and get_queryset + # return lists of different types/structures. + # We need to parse this data and coerce them into the same type. + for obj in current_sort_order: + last = None + if not isOtherOrderfield: + first = obj.first_name + last = obj.last_name + elif field_obj == DomainInformation.domain.field: + first = obj.name + elif field_obj == DomainInformation.domain_application.field: + first = obj.requested_domain.name + + name_tuple = self.coerced_fk_field_helper( + first, last, field_obj.name, ":" + ) + if name_tuple is not None: + current_sort_order_coerced_type.append(name_tuple) + + self.assertEqual( + desired_order, + current_sort_order_coerced_type, + "{} is not ordered alphabetically".format(field_obj.name), + ) + + def test_alphabetically_sorted_fk_fields_domain_invitation(self): + tested_fields = [DomainInvitation.domain.field] + + # Creates multiple domain applications - review status does not matter + applications = multiple_unalphabetical_domain_objects("invitation") + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domaininvitation/{}/change/".format(applications[0].pk) + ) + + model_admin = AuditedAdmin(DomainInvitation, self.site) + + sorted_fields = [] + # Typically we wouldn't want two nested for fields, + # but both fields are of a fixed length. + # For test case purposes, this should be performant. + for field in tested_fields: + sorted_fields = ["name"] + # We want both of these to be lists, as it is richer test wise. + + desired_order = self.order_by_desired_field_helper( + model_admin, request, field.name, *sorted_fields + ) + current_sort_order = list( + model_admin.formfield_for_foreignkey(field, request).queryset + ) + + # Conforms to the same object structure as desired_order + current_sort_order_coerced_type = [] + + # This is necessary as .queryset and get_queryset + # return lists of different types/structures. + # We need to parse this data and coerce them into the same type. + for contact in current_sort_order: + first = contact.name + last = None + + name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":") + if name_tuple is not None: + current_sort_order_coerced_type.append(name_tuple) + + self.assertEqual( + desired_order, + current_sort_order_coerced_type, + "{} is not ordered alphabetically".format(field.name), + ) + + def coerced_fk_field_helper( + self, first_name, last_name, field_name, queryset_shorthand + ): + """Handles edge cases for test cases""" + if first_name is None: + raise ValueError("Invalid value for first_name, must be defined") + + returned_tuple = (first_name, last_name) + # Handles edge case for names - structured strangely + if last_name is None: + return (first_name,) + + if first_name.split(queryset_shorthand)[1] == field_name: + return returned_tuple + else: + return None + + def tearDown(self): + DomainInformation.objects.all().delete() + DomainApplication.objects.all().delete() + DomainInvitation.objects.all().delete() diff --git a/src/zap.conf b/src/zap.conf index bdd6b017d..b4e037ae4 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -31,7 +31,7 @@ 10027 OUTOFSCOPE http://app:8080/public/js/uswds-init.min.js # get-gov.js contains suspicious word "from" as in `Array.from()` 10027 OUTOFSCOPE http://app:8080/public/js/get-gov.js -# Ignore wording of "TODO" +# Ignores suspicious word "TODO" 10027 OUTOFSCOPE http://app:8080.*$ 10028 FAIL (Open Redirect - Passive/beta) 10029 FAIL (Cookie Poisoning - Passive/beta)