Merge branch 'main' into za/806-analyst-view-domain-management-data

This commit is contained in:
zandercymatics 2023-08-24 10:26:34 -06:00
commit a2572d0c73
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
26 changed files with 800 additions and 44 deletions

View file

@ -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/)

View file

@ -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:

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -4,13 +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):
@ -23,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):
@ -196,7 +200,6 @@ class DomainAdmin(ListHeaderAdmin):
class ContactAdmin(ListHeaderAdmin):
"""Custom contact admin class to add search."""
search_fields = ["email", "first_name", "last_name"]

View file

@ -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",
]

View file

@ -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

View file

@ -590,6 +590,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):

View file

@ -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

View file

@ -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)

View file

@ -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 %}
</p>

View file

@ -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. <https://manage.get.gov/>.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
----------------------------------------------------------------
{% include 'emails/includes/application_summary.txt' %}
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Visit <https://get.gov>
{% endautoescape %}

View file

@ -0,0 +1 @@
Your .gov domain request has been withdrawn

View file

@ -43,13 +43,13 @@
<td data-label="Status">{{ domain.application_status|title }}</td>
<td>
<a href="{% url "domain" pk=domain.pk %}">
<svg
class="usa-icon"
aria-hidden="true"
focusable="false"
role="img"
<svg
class="usa-icon"
aria-hidden="true"
focusable="false"
role="img"
width="24"
>
>
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use>
</svg>
Manage <span class="usa-sr-only">{{ domain.name }}</span>
@ -115,15 +115,18 @@
aria-live="polite"
></div>
{% else %}
<p>You don't have any active domain requests right now</p>
{% endif %}
<p>You don't have any active domain requests right now</p>
<p><a href="{% url 'application:' %}" class="usa-button">Start a new domain request</a></p>
{% endif %}
</section>
{# Note: Reimplement this after MVP.. #}
<!--
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<h2>Archived domains</h2>
<p>You don't have any archived domains</p>
<p>You don't have any archived domains</p>
</section>
-->
<!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
@ -132,8 +135,8 @@
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>
</section> -->
</section>
-->
</div>
{% else %} {# not user.is_authenticated #}

View file

@ -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

View file

@ -1,13 +1,34 @@
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 django.conf import settings
from unittest.mock import MagicMock
import boto3_mocking # type: ignore
import logging
logger = logging.getLogger(__name__)
class TestDomainApplicationAdmin(TestCase):
@ -394,3 +415,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()

View file

@ -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)