Merge remote-tracking branch 'origin/main' into nl/2249-fix-db-reset-workflow

This commit is contained in:
CocoByte 2024-07-19 18:23:08 -06:00
commit 9dc7b06e08
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
110 changed files with 4206 additions and 3338 deletions

View file

@ -16,6 +16,8 @@ assignees: abroddrick
There are several tools we use locally that you will need to have. There are several tools we use locally that you will need to have.
- [ ] [Install the cf CLI v7](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) for the ability to deploy - [ ] [Install the cf CLI v7](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) for the ability to deploy
- If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries)
- Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0)
- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg) - [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg)
- [ ] Install the [Github CLI](https://cli.github.com/) - [ ] Install the [Github CLI](https://cli.github.com/)
@ -70,6 +72,7 @@ when setting up your key in Github.
Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`. Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error: **Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh ```zsh
error: gpg failed to sign the data error: gpg failed to sign the data
@ -90,6 +93,15 @@ or
source ~/.zshrc source ~/.zshrc
``` ```
### Windows
If GPG doesn't work out of the box with git for you:
- You can [download the GPG binary directly](https://gnupg.org/download/).
- It may be helpful to use [gpg4win](https://www.gpg4win.org/get-gpg4win.html).
From there, you should be able to access gpg through the terminal.
Additionally, consider a gpg key manager like Kleopatra if you run into issues with environment variables or with the gpg service not running on startup.
## Setting up developer sandbox ## Setting up developer sandbox
We have three types of environments: stable, staging, and sandbox. Stable (production)and staging (pre-prod) get deployed via tagged release, and developer sandboxes are given to get.gov developers to mess around in a production-like environment without disrupting stable or staging. Each sandbox is namespaced and will automatically be deployed too when the appropriate branch syntax is used for that space in an open pull request. There are several things you need to setup to make the sandbox work for a developer. We have three types of environments: stable, staging, and sandbox. Stable (production)and staging (pre-prod) get deployed via tagged release, and developer sandboxes are given to get.gov developers to mess around in a production-like environment without disrupting stable or staging. Each sandbox is namespaced and will automatically be deployed too when the appropriate branch syntax is used for that space in an open pull request. There are several things you need to setup to make the sandbox work for a developer.

View file

@ -31,8 +31,8 @@ body:
attributes: attributes:
label: Links to other issues label: Links to other issues
description: | description: |
"Add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)." "With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)."
placeholder: 🔄 Relates to... placeholder: "- 🔄 Relates to..."
- type: markdown - type: markdown
id: note id: note
attributes: attributes:

View file

@ -0,0 +1,91 @@
# Manually deploy a branch of choice to an environment of choice.
name: Manual Build and Deploy
run-name: Manually build and deploy branch to sandbox of choice
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
default: 'backup'
type: 'choice'
options:
- ab
- backup
- cb
- dk
- es
- gd
- ko
- ky
- nl
- rb
- rh
- rjm
- meoward
- bob
- hotgov
- litterbox
# GitHub Actions has no "good" way yet to dynamically input branches
branch:
description: 'Branch to deploy'
required: true
default: 'main'
type: string
jobs:
variables:
runs-on: ubuntu-latest
steps:
- name: Setting global variables
uses: actions/github-script@v6
id: var
with:
script: |
core.setOutput('environment', '${{ github.head_ref }}'.split("/")[0]);
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: cloud-gov/cg-cli-tools@main
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/github-script@v6
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
})

View file

@ -22,6 +22,7 @@ jobs:
- name: Compile USWDS assets - name: Compile USWDS assets
working-directory: ./src working-directory: ./src
run: | run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install && docker compose run node npm install &&
docker compose run node npx gulp copyAssets && docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile docker compose run node npx gulp compile

View file

@ -28,6 +28,7 @@ jobs:
|| startsWith(github.head_ref, 'hotgov/') || startsWith(github.head_ref, 'hotgov/')
|| startsWith(github.head_ref, 'litterbox/') || startsWith(github.head_ref, 'litterbox/')
|| startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ag/')
|| startsWith(github.head_ref, 'ms/')
outputs: outputs:
environment: ${{ steps.var.outputs.environment}} environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
@ -46,6 +47,7 @@ jobs:
- name: Compile USWDS assets - name: Compile USWDS assets
working-directory: ./src working-directory: ./src
run: | run: |
docker compose run node npm install npm@latest &&
docker compose run node npm install && docker compose run node npm install &&
docker compose run node npx gulp copyAssets && docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile docker compose run node npx gulp compile

View file

@ -0,0 +1,18 @@
name: Notify users based on issue labels
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: jenschelkopf/issue-label-notification-action@1.3
with:
recipients: |
design-review=@Katherine-Osos
message: 'cc/ {recipients} — adding you to this **{label}** issue!'

View file

@ -16,6 +16,7 @@ on:
- stable - stable
- staging - staging
- development - development
- ms
- ag - ag
- litterbox - litterbox
- hotgov - hotgov

View file

@ -16,6 +16,7 @@ on:
options: options:
- staging - staging
- development - development
- ms
- ag - ag
- litterbox - litterbox
- hotgov - hotgov

View file

@ -41,7 +41,7 @@ class DomainRequest {
-- --
creator (User) creator (User)
investigator (User) investigator (User)
authorizing_official (Contact) senior_official (Contact)
submitter (Contact) submitter (Contact)
other_contacts (Contacts) other_contacts (Contacts)
approved_domain (Domain) approved_domain (Domain)
@ -80,7 +80,7 @@ class Contact {
-- --
} }
DomainRequest *-r-* Contact : authorizing_official, submitter, other_contacts DomainRequest *-r-* Contact : senior_official, submitter, other_contacts
class DraftDomain { class DraftDomain {
Requested domain Requested domain

View file

@ -20,7 +20,7 @@ docker compose exec app ./manage.py generate_puml --include registrar
## How To regenerate the database svg image ## How To regenerate the database svg image
1. Copy your puml file contents into the bottom of this file and replace the current code marked by `plantuml` 1. Copy your puml file contents into the bottom of this file and replace the current code marked by `plantuml`
2. Run the following command 2. Navigate to the `diagram` folder and then run the following command below:
```bash ```bash
docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg models_diagram.md docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg models_diagram.md
@ -103,6 +103,21 @@ class "registrar.PublicContact <Registrar>" as registrar.PublicContact #d6f4e9 {
registrar.PublicContact -- registrar.Domain registrar.PublicContact -- registrar.Domain
class "registrar.UserDomainRole <Registrar>" as registrar.UserDomainRole #d6f4e9 {
user domain role
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (ForeignKey)
~ domain (ForeignKey)
+ role (TextField)
--
}
registrar.UserDomainRole -- registrar.User
registrar.UserDomainRole -- registrar.Domain
class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 { class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 {
domain domain
-- --
@ -115,6 +130,7 @@ class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 {
+ security_contact_registry_id (TextField) + security_contact_registry_id (TextField)
+ deleted (DateField) + deleted (DateField)
+ first_ready (DateField) + first_ready (DateField)
+ dsdata_last_change (TextField)
-- --
} }
@ -126,6 +142,7 @@ class "registrar.FederalAgency <Registrar>" as registrar.FederalAgency #d6f4e9 {
+ created_at (DateTimeField) + created_at (DateTimeField)
+ updated_at (DateTimeField) + updated_at (DateTimeField)
+ agency (CharField) + agency (CharField)
+ federal_type (CharField)
-- --
} }
@ -138,7 +155,10 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ updated_at (DateTimeField) + updated_at (DateTimeField)
+ status (FSMField) + status (FSMField)
+ rejection_reason (TextField) + rejection_reason (TextField)
+ action_needed_reason (TextField)
+ action_needed_reason_email (TextField)
~ federal_agency (ForeignKey) ~ federal_agency (ForeignKey)
~ portfolio (ForeignKey)
~ creator (ForeignKey) ~ creator (ForeignKey)
~ investigator (ForeignKey) ~ investigator (ForeignKey)
+ generic_org_type (CharField) + generic_org_type (CharField)
@ -156,7 +176,7 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ zipcode (CharField) + zipcode (CharField)
+ urbanization (CharField) + urbanization (CharField)
+ about_your_organization (TextField) + about_your_organization (TextField)
~ authorizing_official (ForeignKey) ~ senior_official (ForeignKey)
~ approved_domain (OneToOneField) ~ approved_domain (OneToOneField)
~ requested_domain (OneToOneField) ~ requested_domain (OneToOneField)
~ submitter (ForeignKey) ~ submitter (ForeignKey)
@ -165,6 +185,8 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ anything_else (TextField) + anything_else (TextField)
+ has_anything_else_text (BooleanField) + has_anything_else_text (BooleanField)
+ cisa_representative_email (EmailField) + cisa_representative_email (EmailField)
+ cisa_representative_first_name (CharField)
+ cisa_representative_last_name (CharField)
+ has_cisa_representative (BooleanField) + has_cisa_representative (BooleanField)
+ is_policy_acknowledged (BooleanField) + is_policy_acknowledged (BooleanField)
+ submission_date (DateField) + submission_date (DateField)
@ -175,6 +197,7 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
-- --
} }
registrar.DomainRequest -- registrar.FederalAgency registrar.DomainRequest -- registrar.FederalAgency
registrar.DomainRequest -- registrar.Portfolio
registrar.DomainRequest -- registrar.User registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.User registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.Contact registrar.DomainRequest -- registrar.Contact
@ -194,6 +217,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
+ updated_at (DateTimeField) + updated_at (DateTimeField)
~ federal_agency (ForeignKey) ~ federal_agency (ForeignKey)
~ creator (ForeignKey) ~ creator (ForeignKey)
~ portfolio (ForeignKey)
~ domain_request (OneToOneField) ~ domain_request (OneToOneField)
+ generic_org_type (CharField) + generic_org_type (CharField)
+ organization_type (CharField) + organization_type (CharField)
@ -210,13 +234,17 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
+ zipcode (CharField) + zipcode (CharField)
+ urbanization (CharField) + urbanization (CharField)
+ about_your_organization (TextField) + about_your_organization (TextField)
~ authorizing_official (ForeignKey) ~ senior_official (ForeignKey)
~ domain (OneToOneField) ~ domain (OneToOneField)
~ submitter (ForeignKey) ~ submitter (ForeignKey)
+ purpose (TextField) + purpose (TextField)
+ no_other_contacts_rationale (TextField) + no_other_contacts_rationale (TextField)
+ anything_else (TextField) + anything_else (TextField)
+ has_anything_else_text (BooleanField)
+ cisa_representative_email (EmailField) + cisa_representative_email (EmailField)
+ cisa_representative_first_name (CharField)
+ cisa_representative_last_name (CharField)
+ has_cisa_representative (BooleanField)
+ is_policy_acknowledged (BooleanField) + is_policy_acknowledged (BooleanField)
+ notes (TextField) + notes (TextField)
# other_contacts (ManyToManyField) # other_contacts (ManyToManyField)
@ -224,6 +252,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
} }
registrar.DomainInformation -- registrar.FederalAgency registrar.DomainInformation -- registrar.FederalAgency
registrar.DomainInformation -- registrar.User registrar.DomainInformation -- registrar.User
registrar.DomainInformation -- registrar.Portfolio
registrar.DomainInformation -- registrar.DomainRequest registrar.DomainInformation -- registrar.DomainRequest
registrar.DomainInformation -- registrar.Contact registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation -- registrar.Domain registrar.DomainInformation -- registrar.Domain
@ -242,21 +271,6 @@ class "registrar.DraftDomain <Registrar>" as registrar.DraftDomain #d6f4e9 {
} }
class "registrar.UserDomainRole <Registrar>" as registrar.UserDomainRole #d6f4e9 {
user domain role
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (ForeignKey)
~ domain (ForeignKey)
+ role (TextField)
--
}
registrar.UserDomainRole -- registrar.User
registrar.UserDomainRole -- registrar.Domain
class "registrar.DomainInvitation <Registrar>" as registrar.DomainInvitation #d6f4e9 { class "registrar.DomainInvitation <Registrar>" as registrar.DomainInvitation #d6f4e9 {
domain invitation domain invitation
-- --
@ -388,6 +402,58 @@ class "registrar.WaffleFlag <Registrar>" as registrar.WaffleFlag #d6f4e9 {
registrar.WaffleFlag *--* registrar.User registrar.WaffleFlag *--* registrar.User
class "registrar.Portfolio <Registrar>" as registrar.Portfolio #d6f4e9 {
portfolio
--
+ id (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.Portfolio -- registrar.User
registrar.Portfolio -- registrar.FederalAgency
class "registrar.DomainGroup <Registrar>" as registrar.DomainGroup #d6f4e9 {
domain group
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ portfolio (ForeignKey)
# domains (ManyToManyField)
--
}
registrar.DomainGroup -- registrar.Portfolio
registrar.DomainGroup *--* registrar.DomainInformation
class "registrar.Suborganization <Registrar>" as registrar.Suborganization #d6f4e9 {
suborganization
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ portfolio (ForeignKey)
--
}
registrar.Suborganization -- registrar.Portfolio
@enduml @enduml
``` ```

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Before After
Before After

View file

@ -353,49 +353,6 @@ cf env getgov-{app name}
Then, copy the variables under the section labled `s3`. Then, copy the variables under the section labled `s3`.
## Signals
The application uses [Django signals](https://docs.djangoproject.com/en/5.0/topics/signals/). In particular, it uses a subset of prebuilt signals called [model signals](https://docs.djangoproject.com/en/5.0/ref/signals/#module-django.db.models.signals).
Per Django, signals "[...allow certain senders to notify a set of receivers that some action has taken place.](https://docs.djangoproject.com/en/5.0/topics/signals/#module-django.dispatch)"
In other words, signals are a mechanism that allows different parts of an application to communicate with each other by sending and receiving notifications when events occur. When an event occurs (such as creating, updating, or deleting a record), signals can automatically trigger specific actions in response. This allows different parts of an application to stay synchronized without tightly coupling the component.
### Rules of use
When using signals, try to adhere to these guidelines:
1. Don't use signals when you can use another method, such as an override of `save()` or `__init__`.
2. Document its usage in this readme (or another centralized location), as well as briefly on the underlying class it is associated with. For instance, since the `handle_profile` directly affects the class `Contact`, the class description notes this and links to [signals.py](../../src/registrar/signals.py).
3. Where possible, avoid chaining signals together (i.e. a signal that calls a signal). If this has to be done, clearly document the flow.
4. Minimize logic complexity within the signal as much as possible.
### When should you use signals?
Generally, you would use signals when you want an event to be synchronized across multiple areas of code at once (such as with two models or more models at once) in a way that would otherwise be difficult to achieve by overriding functions.
However, in most scenarios, if you can get away with avoiding signals - you should. The reasoning for this is that [signals give the appearance of loose coupling, but they can quickly lead to code that is hard to understand, adjust and debug](https://docs.djangoproject.com/en/5.0/topics/signals/#module-django.dispatch).
Consider using signals when:
1. Synchronizing events across multiple models or areas of code.
2. Performing logic before or after saving a model to the database (when otherwise difficult through `save()`).
3. Encountering an import loop when overriding functions such as `save()`.
4. You are otherwise unable to achieve the intended behavior by overrides or other means.
5. (Rare) Offloading tasks when multi-threading.
For the vast majority of use cases, the [pre_save](https://docs.djangoproject.com/en/5.0/ref/signals/#pre-save) and [post_save](https://docs.djangoproject.com/en/5.0/ref/signals/#post-save) signals are sufficient in terms of model-to-model management.
### Where should you use them?
This project compiles signals in a unified location to maintain readability. If you are adding a signal or otherwise utilizing one, you should always define them in [signals.py](../../src/registrar/signals.py). Except under rare circumstances, this should be adhered to for the reasons mentioned above.
### How are we currently using signals?
At the time of writing, we currently only use signals for the Contact and User objects when synchronizing data returned from Login.gov. This is because the `Contact` object holds information that the user specified in our system, whereas the `User` object holds information that was specified in Login.gov.
To keep our signal usage coherent and well-documented, add to this document when a new function is added for ease of reference and use.
#### handle_profile
This function is triggered by the post_save event on the User model, designed to manage the synchronization between User and Contact entities. It operates under the following conditions:
1. For New Users: Upon the creation of a new user, it checks for an existing `Contact` by email. If no matching contact is found, it creates a new Contact using the user's details from Login.gov. If a matching contact is found, it associates this contact with the user. In cases where multiple contacts with the same email exist, it logs a warning and associates the first contact found.
2. For Existing Users: For users logging in subsequent times, the function ensures that any updates from Login.gov are applied to the associated User record. However, it does not alter any existing Contact records.
## Disable email sending (toggling the disable_email_sending flag) ## Disable email sending (toggling the disable_email_sending flag)
1. On the app, navigate to `\admin`. 1. On the app, navigate to `\admin`.
2. Under models, click `Waffle flags`. 2. Under models, click `Waffle flags`.

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-ms
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
health-check-invocation-timeout: 40
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-ms.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-ms.app.cloud.gov
services:
- getgov-credentials
- getgov-ms-database

View file

@ -11,7 +11,7 @@
"http://localhost:8080/request/org_federal/", "http://localhost:8080/request/org_federal/",
"http://localhost:8080/request/org_election/", "http://localhost:8080/request/org_election/",
"http://localhost:8080/request/org_contact/", "http://localhost:8080/request/org_contact/",
"http://localhost:8080/request/authorizing_official/", "http://localhost:8080/request/senior_official/",
"http://localhost:8080/request/current_sites/", "http://localhost:8080/request/current_sites/",
"http://localhost:8080/request/dotgov_domain/", "http://localhost:8080/request/dotgov_domain/",
"http://localhost:8080/request/purpose/", "http://localhost:8080/request/purpose/",

View file

@ -1,4 +1,3 @@
version: "3.0"
services: services:
app: app:
build: . build: .
@ -67,8 +66,8 @@ services:
# command: "python" # command: "python"
command: > command: >
bash -c " python manage.py migrate && bash -c " python manage.py migrate &&
python manage.py load &&
python manage.py createcachetable && python manage.py createcachetable &&
python manage.py load &&
python manage.py runserver 0.0.0.0:8080" python manage.py runserver 0.0.0.0:8080"
db: db:

View file

@ -9,17 +9,17 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from waffle.decorators import flag_is_active
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
@ -167,6 +167,9 @@ class DomainRequestAdminForm(forms.ModelForm):
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
} }
labels = {
"action_needed_reason_email": "Auto-generated email",
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -445,8 +448,9 @@ class AdminSortFields:
sort_mapping = { sort_mapping = {
# == Contact == # # == Contact == #
"other_contacts": (Contact, _name_sort), "other_contacts": (Contact, _name_sort),
"authorizing_official": (Contact, _name_sort),
"submitter": (Contact, _name_sort), "submitter": (Contact, _name_sort),
# == Senior Official == #
"senior_official": (SeniorOfficial, _name_sort),
# == User == # # == User == #
"creator": (User, _name_sort), "creator": (User, _name_sort),
"user": (User, _name_sort), "user": (User, _name_sort),
@ -594,12 +598,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
return filters return filters
class UserContactInline(admin.StackedInline):
"""Edit a user's profile on the user page."""
model = models.Contact
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines.""" """Custom user admin class to use our inlines."""
@ -616,8 +614,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
_meta = Meta() _meta = Meta()
inlines = [UserContactInline]
list_display = ( list_display = (
"username", "username",
"overridden_email_field", "overridden_email_field",
@ -646,7 +642,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None, None,
{"fields": ("username", "password", "status", "verification_type")}, {"fields": ("username", "password", "status", "verification_type")},
), ),
("Personal info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {
@ -677,7 +673,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {
@ -701,12 +697,13 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# NOT all fields are readonly for admin, otherwise we would have # NOT all fields are readonly for admin, otherwise we would have
# set this at the permissions level. The exception is 'status' # set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [ analyst_readonly_fields = [
"Personal Info", "User profile",
"first_name", "first_name",
"middle_name", "middle_name",
"last_name", "last_name",
"title", "title",
"email", "email",
"phone",
"Permissions", "Permissions",
"is_active", "is_active",
"groups", "groups",
@ -895,30 +892,20 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = [ list_display = [
"name", "name",
"email", "email",
"user_exists",
] ]
# this ordering effects the ordering of results # this ordering effects the ordering of results
# in autocomplete_fields for user # in autocomplete_fields
ordering = ["first_name", "last_name", "email"] ordering = ["first_name", "last_name", "email"]
fieldsets = [ fieldsets = [
( (
None, None,
{"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]}, {"fields": ["first_name", "middle_name", "last_name", "title", "email", "phone"]},
) )
] ]
autocomplete_fields = ["user"]
change_form_template = "django/admin/email_clipboard_change_form.html" change_form_template = "django/admin/email_clipboard_change_form.html"
def user_exists(self, obj):
"""Check if the Contact has a related User"""
return "Yes" if obj.user is not None else "No"
user_exists.short_description = "Is user" # type: ignore
user_exists.admin_order_field = "user" # type: ignore
# We name the custom prop 'contact' because linter # We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it # is not allowing a short_description attr on it
# This gets around the linter limitation, for now. # This gets around the linter limitation, for now.
@ -936,9 +923,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
name.admin_order_field = "first_name" # type: ignore name.admin_order_field = "first_name" # type: ignore
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [ analyst_readonly_fields: list[str] = ["email"]
"user",
]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements. """Set the read-only state on form elements.
@ -1018,6 +1003,19 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().changelist_view(request, extra_context=extra_context) return super().changelist_view(request, extra_context=extra_context)
class SeniorOfficialAdmin(ListHeaderAdmin):
"""Custom Senior Official Admin class."""
# NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets.
search_fields = ["first_name", "last_name", "email"]
search_help_text = "Search by first name, last name or email."
list_display = ["first_name", "last_name", "email"]
# this ordering effects the ordering of results
# in autocomplete_fields for Senior Official
ordering = ["first_name", "last_name"]
class WebsiteResource(resources.ModelResource): class WebsiteResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file""" import/export file"""
@ -1234,9 +1232,9 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain." search_help_text = "Search by domain."
fieldsets = [ fieldsets = [
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}), (None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
(".gov domain", {"fields": ["domain"]}), (".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}), ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}), ("Background info", {"fields": ["anything_else"]}),
( (
"Type of organization", "Type of organization",
@ -1310,9 +1308,11 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
autocomplete_fields = [ autocomplete_fields = [
"creator", "creator",
"domain_request", "domain_request",
"authorizing_official", "senior_official",
"domain", "domain",
"submitter", "submitter",
"portfolio",
"sub_organization",
] ]
# Table ordering # Table ordering
@ -1322,6 +1322,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
superuser_only_fields = [ superuser_only_fields = [
"portfolio", "portfolio",
"sub_organization",
] ]
# DEVELOPER's NOTE: # DEVELOPER's NOTE:
@ -1342,7 +1343,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets: for name, data in fieldsets:
fields = data.get("fields", []) fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields) fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields})) modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets return modified_fieldsets
return fieldsets return fieldsets
@ -1485,6 +1486,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore custom_election_board.short_description = "Election office" # type: ignore
# This is just a placeholder. This field will be populated in the detail_table_fieldset view.
# This is not a field that exists on the model.
def status_history(self, obj):
return "No changelog to display."
status_history.short_description = "Status History" # type: ignore
# Filters # Filters
list_filter = ( list_filter = (
StatusListFilter, StatusListFilter,
@ -1510,9 +1518,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
{ {
"fields": [ "fields": [
"portfolio", "portfolio",
"sub_organization",
"status_history",
"status", "status",
"rejection_reason", "rejection_reason",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email",
"investigator", "investigator",
"creator", "creator",
"submitter", "submitter",
@ -1526,7 +1537,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Contacts", "Contacts",
{ {
"fields": [ "fields": [
"authorizing_official", "senior_official",
"other_contacts", "other_contacts",
"no_other_contacts_rationale", "no_other_contacts_rationale",
"cisa_representative_first_name", "cisa_representative_first_name",
@ -1592,6 +1603,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
) )
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
@ -1615,13 +1627,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"requested_domain", "requested_domain",
"submitter", "submitter",
"creator", "creator",
"authorizing_official", "senior_official",
"investigator", "investigator",
"portfolio",
"sub_organization",
] ]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
superuser_only_fields = [ superuser_only_fields = [
"portfolio", "portfolio",
"sub_organization",
] ]
# DEVELOPER's NOTE: # DEVELOPER's NOTE:
@ -1642,7 +1657,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets: for name, data in fieldsets:
fields = data.get("fields", []) fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in self.superuser_only_fields) fields = tuple(field for field in fields if field not in self.superuser_only_fields)
modified_fieldsets.append((name, {"fields": fields})) modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets return modified_fieldsets
return fieldsets return fieldsets
@ -1689,19 +1704,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if not change: if not change:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
# == Handle non-status changes == #
# Change this in #1901. Add a check on "not self.action_needed_reason_email"
if obj.action_needed_reason:
self._handle_action_needed_reason_email(obj)
should_save = True
# Get the original domain request from the database. # Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk) original_obj = models.DomainRequest.objects.get(pk=obj.pk)
# == Handle action_needed_reason == #
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
if reason_changed:
# Track the fact that we sent out an email
request.session["action_needed_email_sent"] = True
# Set the action_needed_reason_email to the default if nothing exists.
# Since this check occurs after save, if the user enters a value then we won't update.
default_email = self._get_action_needed_reason_default_email(obj, obj.action_needed_reason)
if obj.action_needed_reason_email:
emails = self.get_all_action_needed_reason_emails(obj)
is_custom_email = obj.action_needed_reason_email not in emails.values()
if not is_custom_email:
obj.action_needed_reason_email = default_email
else:
obj.action_needed_reason_email = default_email
# == Handle status == #
if obj.status == original_obj.status: if obj.status == original_obj.status:
# If the status hasn't changed, let the base function take care of it # If the status hasn't changed, let the base function take care of it
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
else:
# == Handle status changes == #
# Run some checks on the current object for invalid status changes # Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj) obj, should_save = self._handle_status_change(request, obj, original_obj)
@ -1709,10 +1738,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save: if should_save:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
def _handle_action_needed_reason_email(self, obj):
text = self._get_action_needed_reason_default_email_text(obj, obj.action_needed_reason)
obj.action_needed_reason_email = text.get("email_body_text")
def _handle_status_change(self, request, obj, original_obj): def _handle_status_change(self, request, obj, original_obj):
""" """
Checks for various conditions when a status change is triggered. Checks for various conditions when a status change is triggered.
@ -1906,44 +1931,54 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Initialize extra_context and add filtered entries # Initialize extra_context and add filtered entries
extra_context = extra_context or {} extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj) emails = self.get_all_action_needed_reason_emails(obj)
extra_context["action_needed_reason_emails"] = json.dumps(emails)
extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
# Denote if an action needed email was sent or not
email_sent = request.session.get("action_needed_email_sent", False)
extra_context["action_needed_email_sent"] = email_sent
if email_sent:
request.session["action_needed_email_sent"] = False
# Call the superclass method with updated extra_context # Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def get_all_action_needed_reason_emails_as_json(self, domain_request): def get_all_action_needed_reason_emails(self, domain_request):
"""Returns a json dictionary of every action needed reason and its associated email """Returns a json dictionary of every action needed reason and its associated email
for this particular domain request.""" for this particular domain request."""
emails = {} emails = {}
for action_needed_reason in domain_request.ActionNeededReasons: for action_needed_reason in domain_request.ActionNeededReasons:
enum_value = action_needed_reason.value # Map the action_needed_reason to its default email
# Change this in #1901. Just add a check for the current value. emails[action_needed_reason.value] = self._get_action_needed_reason_default_email(
emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value) domain_request, action_needed_reason.value
return json.dumps(emails) )
def _get_action_needed_reason_default_email_text(self, domain_request, action_needed_reason: str): return emails
def _get_action_needed_reason_default_email(self, domain_request, action_needed_reason):
"""Returns the default email associated with the given action needed reason""" """Returns the default email associated with the given action needed reason"""
if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER: if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
return { return None
"subject_text": None,
"email_body_text": None, if flag_is_active(None, "profile_feature"): # type: ignore
} recipient = domain_request.creator
else:
recipient = domain_request.submitter
# Return the context of the rendered views
context = {"domain_request": domain_request, "recipient": recipient}
# Get the email body # Get the email body
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
template = get_template(template_path)
# Get the email subject email_body_text = get_template(template_path).render(context=context)
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt" email_body_text_cleaned = None
subject_template = get_template(template_subject_path) if email_body_text:
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
# Return the content of the rendered views return email_body_text_cleaned
context = {"domain_request": domain_request}
return {
"subject_text": subject_template.render(context=context),
"email_body_text": template.render(context=context),
}
def process_log_entry(self, log_entry): def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable.""" """Process a log entry and return filtered entry dictionary if applicable."""
@ -2036,14 +2071,7 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
autocomplete_fields = DomainInformationAdmin.autocomplete_fields
autocomplete_fields = [
"creator",
"domain_request",
"authorizing_official",
"domain",
"submitter",
]
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
"""Custom has_change_permission override so that we can specify that """Custom has_change_permission override so that we can specify that
@ -2155,8 +2183,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
), ),
) )
# this ordering effects the ordering of results # this ordering effects the ordering of results in autocomplete_fields for domain
# in autocomplete_fields for domain
ordering = ["name"] ordering = ["name"]
def generic_org_type(self, obj): def generic_org_type(self, obj):
@ -2238,25 +2265,12 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
extra_context["state_help_message"] = Domain.State.get_admin_help_text(domain.state) extra_context["state_help_message"] = Domain.State.get_admin_help_text(domain.state)
extra_context["domain_state"] = domain.get_state_display() extra_context["domain_state"] = domain.get_state_display()
extra_context["curr_exp_date"] = (
# Pass in what the an extended expiration date would be for the expiration date modal domain.expiration_date if domain.expiration_date is not None else self._get_current_date()
self._set_expiration_date_context(domain, extra_context) )
return super().changeform_view(request, object_id, form_url, extra_context) return super().changeform_view(request, object_id, form_url, extra_context)
def _set_expiration_date_context(self, domain, extra_context):
"""Given a domain, calculate the an extended expiration date
from the current registry expiration date."""
years_to_extend_by = self._get_calculated_years_for_exp_date(domain)
try:
curr_exp_date = domain.registry_expiration_date
except KeyError:
# No expiration date was found. Return none.
extra_context["extended_expiration_date"] = None
else:
new_date = curr_exp_date + relativedelta(years=years_to_extend_by)
extra_context["extended_expiration_date"] = new_date
def response_change(self, request, obj): def response_change(self, request, obj):
# Create dictionary of action functions # Create dictionary of action functions
ACTION_FUNCTIONS = { ACTION_FUNCTIONS = {
@ -2284,11 +2298,9 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
self.message_user(request, "Object is not of type Domain.", messages.ERROR) self.message_user(request, "Object is not of type Domain.", messages.ERROR)
return None return None
years = self._get_calculated_years_for_exp_date(obj)
# Renew the domain. # Renew the domain.
try: try:
obj.renew_domain(length=years) obj.renew_domain()
self.message_user( self.message_user(
request, request,
"Successfully extended the expiration date.", "Successfully extended the expiration date.",
@ -2313,37 +2325,6 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return HttpResponseRedirect(".") return HttpResponseRedirect(".")
def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1):
"""Given the current date, an extension period, and a registry_expiration_date
on the domain object, calculate the number of years needed to extend the
current expiration date by the extension period.
"""
# Get the date we want to update to
desired_date = self._get_current_date() + relativedelta(years=extension_period)
# Grab the current expiration date
try:
exp_date = obj.registry_expiration_date
except KeyError:
# if no expiration date from registry, set it to today
logger.warning("current expiration date not set; setting to today")
exp_date = self._get_current_date()
# If the expiration date is super old (2020, for example), we need to
# "catch up" to the current year, so we add the difference.
# If both years match, then lets just proceed as normal.
calculated_exp_date = exp_date + relativedelta(years=extension_period)
year_difference = desired_date.year - exp_date.year
years = extension_period
if desired_date > calculated_exp_date:
# Max probably isn't needed here (no code flow), but it guards against negative and 0.
# In both of those cases, we just want to extend by the extension_period.
years = max(extension_period, year_difference)
return years
# Workaround for unit tests, as we cannot mock date directly. # Workaround for unit tests, as we cannot mock date directly.
# it is immutable. Rather than dealing with a convoluted workaround, # it is immutable. Rather than dealing with a convoluted workaround,
# lets wrap this in a function. # lets wrap this in a function.
@ -2686,6 +2667,11 @@ class PortfolioAdmin(ListHeaderAdmin):
# readonly_fields = [ # readonly_fields = [
# "requestor", # "requestor",
# ] # ]
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
"federal_agency",
]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
@ -2769,6 +2755,10 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration
@ -2796,6 +2786,7 @@ admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -5,12 +5,3 @@ class RegistrarConfig(AppConfig):
"""Configure signal handling for our registrar Django application.""" """Configure signal handling for our registrar Django application."""
name = "registrar" name = "registrar"
def ready(self):
"""Runs when all Django applications have been loaded.
We use it here to load signals that connect related models.
"""
# noqa here because we are importing something to make the signals
# get registered, but not using what we import
from . import signals # noqa

View file

@ -36,6 +36,15 @@ function openInNewTab(el, removeAttribute = false){
} }
}; };
// Adds or removes a boolean from our session
function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
}else {
sessionStorage.removeItem(name);
}
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Event handlers. // Event handlers.
@ -361,9 +370,12 @@ function initializeWidgetOnList(list, parentId) {
*/ */
(function (){ (function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
// This is the "auto-generated email" field
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
if (rejectionReasonFormGroup && actionNeededReasonFormGroup) { if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
let statusSelect = document.getElementById('id_status') let statusSelect = document.getElementById('id_status')
let isRejected = statusSelect.value == "rejected" let isRejected = statusSelect.value == "rejected"
let isActionNeeded = statusSelect.value == "action needed" let isActionNeeded = statusSelect.value == "action needed"
@ -371,6 +383,7 @@ function initializeWidgetOnList(list, parentId) {
// Initial handling of rejectionReasonFormGroup display // Initial handling of rejectionReasonFormGroup display
showOrHideObject(rejectionReasonFormGroup, show=isRejected) showOrHideObject(rejectionReasonFormGroup, show=isRejected)
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() { statusSelect.addEventListener('change', function() {
@ -382,6 +395,7 @@ function initializeWidgetOnList(list, parentId) {
isActionNeeded = statusSelect.value == "action needed" isActionNeeded = statusSelect.value == "action needed"
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded) addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
}); });
@ -398,6 +412,7 @@ function initializeWidgetOnList(list, parentId) {
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason) showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
} }
}); });
}); });
@ -412,51 +427,6 @@ function initializeWidgetOnList(list, parentId) {
object.classList.add("display-none"); object.classList.add("display-none");
} }
} }
// Adds or removes a boolean from our session
function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
}else {
sessionStorage.removeItem(name);
}
}
document.addEventListener('DOMContentLoaded', function() {
let statusSelect = document.getElementById('id_status');
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
if (!actionNeededReasonFormGroup || !statusSelect) {
return;
}
let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
let statusChangelog = document.getElementById('dja-status-changelog');
// On action needed, show the email that will be sent out
let showReasonEmailContainer = document.querySelector("#action_needed_reason_email_readonly")
// Prepopulate values on page load.
if (statusSelect.value === "action needed") {
flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
showElement(showReasonEmailContainer);
} else {
// Move the changelog back to its original location
let statusFlexContainer = statusSelect.closest('.flex-container');
statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
hideElement(showReasonEmailContainer);
}
}
// Call the function on page load
moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
// Add event listener to handle changes to the selector itself
statusSelect.addEventListener('change', function() {
moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
})
});
})(); })();
/** An IIFE for toggling the submit bar on domain request forms /** An IIFE for toggling the submit bar on domain request forms
@ -552,58 +522,84 @@ function initializeWidgetOnList(list, parentId) {
})(); })();
/** An IIFE that hooks up to the "show email" button. /** An IIFE that hooks to the show/hide button underneath action needed reason.
* which shows the auto generated email on action needed reason. * This shows the auto generated email on action needed reason.
*/ */
(function () { (function () {
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); // Since this is an iife, these vars will be removed from memory afterwards
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more"); var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
if(actionNeededReasonDropdown && actionNeededEmail && container) { var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
// Add a change listener to the action needed reason dropdown var readonlyView = document.querySelector("#action-needed-reason-email-readonly");
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
}
function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) { let emailWasSent = document.getElementById("action-needed-email-sent");
actionNeededReasonDropdown.addEventListener("change", function() { let actionNeededEmailData = document.getElementById('action-needed-emails-data').textContent;
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
const oldEmailValue = actionNeededEmailData ? actionNeededEmailData.value : null;
if(actionNeededReasonDropdown && actionNeededEmail && domainRequestId) {
// Add a change listener to dom load
document.addEventListener('DOMContentLoaded', function() {
let reason = actionNeededReasonDropdown.value; let reason = actionNeededReasonDropdown.value;
// If a reason isn't specified, no email will be sent. // Handle the session boolean (to enable/disable editing)
// You also cannot save the model in this state. if (emailWasSent && emailWasSent.value === "True") {
// This flow occurs if you switch back to the empty picker state. // An email was sent out - store that information in a session variable
if(!reason) { addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
showNoEmailMessage(actionNeededEmail);
return;
} }
let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent) // Show an editable email field or a readonly one
let emailData = actionNeededEmails[reason]; updateActionNeededEmailDisplay(reason)
if (emailData) { });
let emailBody = emailData.email_body_text
if (emailBody) { // Add a change listener to the action needed reason dropdown
actionNeededEmail.value = emailBody actionNeededReasonDropdown.addEventListener("change", function() {
showActionNeededEmail(actionNeededEmail); let reason = actionNeededReasonDropdown.value;
}else { let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
showNoEmailMessage(actionNeededEmail); if (reason && emailBody) {
// Replace the email content
actionNeededEmail.value = emailBody;
// Reset the session object on change since change refreshes the email content.
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
let emailSent = sessionStorage.getItem(emailSentSessionVariableName)
if (emailSent !== null){
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false)
}
} }
}else {
showNoEmailMessage(actionNeededEmail);
} }
// Show an editable email field or a readonly one
updateActionNeededEmailDisplay(reason)
}); });
} }
// Show the text field. Hide the "no email" message. // Shows an editable email field or a readonly one.
function showActionNeededEmail(actionNeededEmail){ // If the email doesn't exist or if we're of reason "other", display that no email was sent.
let noEmailMessage = document.getElementById("no-email-message"); // Likewise, if we've sent this email before, we should just display the content.
showElement(actionNeededEmail); function updateActionNeededEmailDisplay(reason) {
hideElement(noEmailMessage); let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null;
let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
let showMoreButton = document.querySelector("#action_needed_reason_email__show_details");
if ((reason && reason != "other") && !emailHasBeenSentBefore) {
showElement(actionNeededEmail.parentElement)
hideElement(readonlyView)
hideElement(showMoreButton)
} else {
if (!reason || reason === "other") {
collapseableDiv.innerHTML = reason ? "No email will be sent." : "-";
hideElement(showMoreButton)
if (collapseableDiv.classList.contains("collapsed")) {
showMoreButton.click()
}
}else {
showElement(showMoreButton)
}
hideElement(actionNeededEmail.parentElement)
showElement(readonlyView)
} }
// Hide the text field. Show the "no email" message.
function showNoEmailMessage(actionNeededEmail) {
let noEmailMessage = document.getElementById("no-email-message");
hideElement(actionNeededEmail);
showElement(noEmailMessage);
} }
})(); })();

View file

@ -46,7 +46,7 @@ function ScrollToElement(attributeName, attributeValue) {
} else if (attributeName === 'id') { } else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue); targetEl = document.getElementById(attributeValue);
} else { } else {
console.log('Error: unknown attribute name provided.'); console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided return; // Exit the function if an invalid attributeName is provided
} }
@ -78,6 +78,50 @@ function makeVisible(el) {
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
/**
* Toggles expand_more / expand_more svgs in buttons or anchors
* @param {Element} element - DOM element
*/
function toggleCaret(element) {
// Get a reference to the use element inside the button
const useElement = element.querySelector('use');
// Check if the span element text is 'Hide'
if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') {
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
}
/**
* Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id"
* @param {string} attributeValue - The class or id name
*/
function ScrollToElement(attributeName, attributeValue) {
let targetEl = null;
if (attributeName === 'class') {
targetEl = document.getElementsByClassName(attributeValue)[0];
} else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue);
} else {
console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided
}
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
window.scrollTo({
top: rect.top + scrollTop,
behavior: 'smooth' // Optional: for smooth scrolling
});
}
}
/** Creates and returns a live region element. */ /** Creates and returns a live region element. */
function createLiveRegion(id) { function createLiveRegion(id) {
const liveRegion = document.createElement("div"); const liveRegion = document.createElement("div");
@ -927,7 +971,7 @@ function unloadModals() {
* @param {string} itemName - The name displayed in the counter * @param {string} itemName - The name displayed in the counter
* @param {string} paginationSelector - CSS selector for the pagination container. * @param {string} paginationSelector - CSS selector for the pagination container.
* @param {string} counterSelector - CSS selector for the pagination counter. * @param {string} counterSelector - CSS selector for the pagination counter.
* @param {string} headerAnchor - CSS selector for the header element to anchor the links to. * @param {string} linkAnchor - CSS selector for the header element to anchor the links to.
* @param {Function} loadPageFunction - Function to call when a page link is clicked. * @param {Function} loadPageFunction - Function to call when a page link is clicked.
* @param {number} currentPage - The current page number (starting with 1). * @param {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages. * @param {number} numPages - The total number of pages.
@ -936,7 +980,7 @@ function unloadModals() {
* @param {number} totalItems - The total number of items. * @param {number} totalItems - The total number of items.
* @param {string} searchTerm - The search term * @param {string} searchTerm - The search term
*/ */
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) { function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector); const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector); const paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@ -955,7 +999,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const prevPageItem = document.createElement('li'); const prevPageItem = document.createElement('li');
prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; prevPageItem.className = 'usa-pagination__item usa-pagination__arrow';
prevPageItem.innerHTML = ` prevPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page"> <a href="${linkAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<svg class="usa-icon" aria-hidden="true" role="img"> <svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use> <use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg> </svg>
@ -974,7 +1018,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const pageItem = document.createElement('li'); const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no'; pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = ` pageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a> <a href="${linkAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`; `;
if (page === currentPage) { if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current'); pageItem.querySelector('a').classList.add('usa-current');
@ -1020,7 +1064,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const nextPageItem = document.createElement('li'); const nextPageItem = document.createElement('li');
nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; nextPageItem.className = 'usa-pagination__item usa-pagination__arrow';
nextPageItem.innerHTML = ` nextPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page"> <a href="${linkAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<span class="usa-pagination__link-text">Next</span> <span class="usa-pagination__link-text">Next</span>
<svg class="usa-icon" aria-hidden="true" role="img"> <svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_next"></use> <use xlink:href="/public/img/sprite.svg#navigate_next"></use>
@ -1039,20 +1083,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
* A helper that toggles content/ no content/ no search results * A helper that toggles content/ no content/ no search results
* *
*/ */
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => { const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data; const { unfiltered_total, total } = data;
if (searchTermHolder)
searchTermHolder.innerHTML = '';
if (unfiltered_total) { if (unfiltered_total) {
if (total) { if (total) {
showElement(dataWrapper); showElement(dataWrapper);
hideElement(noSearchResultsWrapper); hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
} else { } else {
if (searchTermHolder)
searchTermHolder.innerHTML = currentSearchTerm;
hideElement(dataWrapper); hideElement(dataWrapper);
showElement(noSearchResultsWrapper); showElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
@ -1090,14 +1128,18 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc'; let currentOrder = 'asc';
const noDomainsWrapper = document.querySelector('.domains__no-data'); const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results'); const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false; let scrollToTable = false;
let currentSearchTerm = '' let currentStatus = [];
let currentSearchTerm = '';
const domainsSearchInput = document.getElementById('domains__search-field'); const domainsSearchInput = document.getElementById('domains__search-field');
const domainsSearchSubmit = document.getElementById('domains__search-field-submit'); const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domains__search-term'); const resetSearchButton = document.querySelector('.domains__reset-search');
const resetButton = document.querySelector('.domains__reset-button'); const resetFiltersButton = document.querySelector('.domains__reset-filters');
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter');
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
@ -1105,21 +1147,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1) * @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) { function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort // fetch json of page of domains, given params
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
console.log('Error in AJAX call: ' + data.error); console.error('Error in AJAX call: ' + data.error);
return; return;
} }
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results // handle the display of proper messaging in the event that no domains exist in the list or search returns no results
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm); updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM // identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.domains__table tbody'); const domainList = document.querySelector('.domains__table tbody');
@ -1132,7 +1174,6 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
@ -1148,7 +1189,7 @@ document.addEventListener('DOMContentLoaded', function() {
data-position="top" data-position="top"
title="${domain.get_state_help_text}" title="${domain.get_state_help_text}"
focusable="true" focusable="true"
aria-label="Status Information" aria-label="${domain.get_state_help_text}"
role="tooltip" role="tooltip"
> >
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
@ -1169,16 +1210,16 @@ document.addEventListener('DOMContentLoaded', function() {
initializeTooltips(); initializeTooltips();
// Do not scroll on first page load // Do not scroll on first page load
if (loaded) if (scroll)
ScrollToElement('id', 'domains-header'); ScrollToElement('class', 'domains');
hasLoaded = true; scrollToTable = true;
// update pagination // update pagination
updatePagination( updatePagination(
'domain', 'domain',
'#domains-pagination', '#domains-pagination',
'#domains-pagination .usa-pagination__counter', '#domains-pagination .usa-pagination__counter',
'#domains-header', '#domains',
loadDomains, loadDomains,
data.page, data.page,
data.num_pages, data.num_pages,
@ -1214,13 +1255,51 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainsSearchInput.value; currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality // If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) { if (currentSearchTerm) {
showElement(resetButton); showElement(resetSearchButton);
} else { } else {
hideElement(resetButton); hideElement(resetSearchButton);
} }
loadDomains(1, 'id', 'asc'); loadDomains(1, 'id', 'asc');
resetHeaders(); resetHeaders();
}) });
if (statusToggle) {
statusToggle.addEventListener('click', function() {
toggleCaret(statusToggle);
});
}
// Add event listeners to status filter checkboxes
statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const checkboxValue = this.value;
// Update currentStatus array based on checkbox state
if (this.checked) {
currentStatus.push(checkboxValue);
} else {
const index = currentStatus.indexOf(checkboxValue);
if (index > -1) {
currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (currentStatus.length == 0) {
hideElement(resetFiltersButton);
} else {
showElement(resetFiltersButton);
}
// Disable the auto scroll
scrollToTable = false;
// Call loadDomains with updated status
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
});
});
// Reset UI and accessibility // Reset UI and accessibility
function resetHeaders() { function resetHeaders() {
@ -1235,18 +1314,78 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() { function resetSearch() {
domainsSearchInput.value = ''; domainsSearchInput.value = '';
currentSearchTerm = ''; currentSearchTerm = '';
hideElement(resetButton); hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc', hasLoaded, ''); loadDomains(1, 'id', 'asc');
resetHeaders(); resetHeaders();
} }
if (resetButton) { if (resetSearchButton) {
resetButton.addEventListener('click', function() { resetSearchButton.addEventListener('click', function() {
resetSearch(); resetSearch();
}); });
} }
// Load the first page initially function resetFilters() {
currentStatus = [];
statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(resetFiltersButton);
// Disable the auto scroll
scrollToTable = false;
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
if (resetFiltersButton) {
resetFiltersButton.addEventListener('click', function() {
resetFilters();
});
}
function updateStatusIndicator() {
statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
statusIndicator.hideElement();
if (currentStatus.length)
statusIndicator.innerHTML = '(' + currentStatus.length + ')';
statusIndicator.showElement();
}
function closeFilters() {
if (statusToggle.getAttribute("aria-expanded") === "true") {
statusToggle.click();
}
}
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// 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"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Close when user clicks outside
// 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"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Initial load
loadDomains(1); loadDomains(1);
} }
}); });
@ -1279,14 +1418,13 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc'; let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data'); const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results'); const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false; let scrollToTable = false;
let currentSearchTerm = '' let currentSearchTerm = '';
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field'); const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit'); const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domain-requests__search-term'); const resetSearchButton = document.querySelector('.domain-requests__reset-search');
const resetButton = document.querySelector('.domain-requests__reset-button');
/** /**
* 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. * 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.
@ -1316,7 +1454,7 @@ document.addEventListener('DOMContentLoaded', function() {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
// Update data and UI // Update data and UI
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm); loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, scrollToTable, currentSearchTerm);
}) })
.catch(error => console.error('Error fetching domain requests:', error)); .catch(error => console.error('Error fetching domain requests:', error));
} }
@ -1332,21 +1470,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1) * @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) { function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort // fetch json of page of domain requests, given params
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
console.log('Error in AJAX call: ' + data.error); console.error('Error in AJAX call: ' + data.error);
return; return;
} }
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results // handle the display of proper messaging in the event that no requests exist in the list or search returns no results
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm); updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain request list will be inserted into the DOM // identify the DOM element where the domain request list will be inserted into the DOM
const tbody = document.querySelector('.domain-requests__table tbody'); const tbody = document.querySelector('.domain-requests__table tbody');
@ -1533,16 +1671,16 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// Do not scroll on first page load // Do not scroll on first page load
if (loaded) if (scroll)
ScrollToElement('id', 'domain-requests-header'); ScrollToElement('class', 'domain-requests');
hasLoaded = true; scrollToTable = true;
// update the pagination after the domain requests list is updated // update the pagination after the domain requests list is updated
updatePagination( updatePagination(
'domain request', 'domain request',
'#domain-requests-pagination', '#domain-requests-pagination',
'#domain-requests-pagination .usa-pagination__counter', '#domain-requests-pagination .usa-pagination__counter',
'#domain-requests-header', '#domain-requests',
loadDomainRequests, loadDomainRequests,
data.page, data.page,
data.num_pages, data.num_pages,
@ -1577,13 +1715,13 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainRequestsSearchInput.value; currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality // If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) { if (currentSearchTerm) {
showElement(resetButton); showElement(resetSearchButton);
} else { } else {
hideElement(resetButton); hideElement(resetSearchButton);
} }
loadDomainRequests(1, 'id', 'asc'); loadDomainRequests(1, 'id', 'asc');
resetHeaders(); resetHeaders();
}) });
// Reset UI and accessibility // Reset UI and accessibility
function resetHeaders() { function resetHeaders() {
@ -1598,24 +1736,23 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() { function resetSearch() {
domainRequestsSearchInput.value = ''; domainRequestsSearchInput.value = '';
currentSearchTerm = ''; currentSearchTerm = '';
hideElement(resetButton); hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc', hasLoaded, ''); loadDomainRequests(1, 'id', 'asc');
resetHeaders(); resetHeaders();
} }
if (resetButton) { if (resetSearchButton) {
resetButton.addEventListener('click', function() { resetSearchButton.addEventListener('click', function() {
resetSearch(); resetSearch();
}); });
} }
// Load the first page initially // Initial load
loadDomainRequests(1); loadDomainRequests(1);
} }
}); });
/** /**
* An IIFE that displays confirmation modal on the user profile page * An IIFE that displays confirmation modal on the user profile page
*/ */
@ -1689,6 +1826,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
function setupListener(){ function setupListener(){
document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) {
// Get the "{field_name}" and "edit-button" // Get the "{field_name}" and "edit-button"
let fieldIdParts = button.id.split("__") let fieldIdParts = button.id.split("__")
@ -1697,12 +1837,61 @@ document.addEventListener('DOMContentLoaded', function() {
// When the edit button is clicked, show the input field under it // When the edit button is clicked, show the input field under it
handleEditButtonClick(fieldName, button); handleEditButtonClick(fieldName, button);
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
}
let inputFieldValue = inputField.value
if (inputFieldValue || fieldName == "full_name"){
if (fieldName == "full_name"){
let firstName = document.querySelector("#id_first_name");
let middleName = document.querySelector("#id_middle_name");
let lastName = document.querySelector("#id_last_name");
if (firstName && lastName && firstName.value && lastName.value) {
let values = [firstName.value, middleName.value, lastName.value]
readonlyField.innerHTML = values.join(" ");
}else {
let fullNameField = document.querySelector('#full_name__edit-button-readonly');
let svg = fullNameField.querySelector("svg use")
if (svg) {
const currentHref = svg.getAttribute('xlink:href');
if (currentHref) {
const parts = currentHref.split('#');
if (parts.length === 2) {
// Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref);
fullNameField.classList.add("input-with-edit-button__error")
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
label.innerHTML = "Unknown";
}
}
}
}
// Technically, the full_name field is optional, but we want to display it as required.
// This style is applied to readonly fields (gray text). This just removes it, as
// this is difficult to achieve otherwise by modifying the .readonly property.
if (readonlyField.classList.contains("text-base")) {
readonlyField.classList.remove("text-base")
}
}else {
readonlyField.innerHTML = inputFieldValue
}
}
}
} }
}); });
} }
function showInputOnErrorFields(){ function showInputOnErrorFields(){
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Get all input elements within the form // Get all input elements within the form
let form = document.querySelector("#finish-profile-setup-form"); let form = document.querySelector("#finish-profile-setup-form");
let inputs = form ? form.querySelectorAll("input") : null; let inputs = form ? form.querySelectorAll("input") : null;
@ -1741,9 +1930,9 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}; };
// Hookup all edit buttons to the `handleEditButtonClick` function
setupListener(); setupListener();
// Show the input fields if an error exists // Show the input fields if an error exists
showInputOnErrorFields(); showInputOnErrorFields();
})(); })();

View file

@ -0,0 +1,33 @@
@use "uswds-core" as *;
.usa-accordion--select {
display: inline-block;
width: auto;
position: relative;
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
}
.usa-accordion__content {
// 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');
padding: units(2) units(2) units(3) units(2);
width: max-content;
}
h2 {
font-size: size('body', 'sm');
}
.usa-button {
width: 100%;
}
.margin-top-0 {
margin-top: 0 !important;
}
}

View file

@ -787,10 +787,16 @@ div.dja__model-description{
color: var(--link-fg); color: var(--link-fg);
} }
.textarea-wrapper {
width: 100%;
max-width: 610px;
}
.dja-readonly-textarea-container { .dja-readonly-textarea-container {
width: 100%;
textarea { textarea {
width: 100%; width: 100%;
min-width: 610px; max-width: 610px;
resize: none; resize: none;
cursor: auto; cursor: auto;
@ -827,3 +833,20 @@ div.dja__model-description{
// Many elements in django admin try to override this, so we need !important. // Many elements in django admin try to override this, so we need !important.
display: none !important; display: none !important;
} }
.margin-top-0 {
margin-top: 0 !important;
}
.padding-top-0 {
padding-top: 0 !important;
}
.flex-container {
@media screen and (min-width: 700px) and (max-width: 1150px) {
&.flex-container--mobile-inline {
display: inline !important;
}
}
}

View file

@ -83,6 +83,10 @@ body {
padding: 0 units(2) units(3); padding: 0 units(2) units(3);
margin-top: units(3); margin-top: units(3);
&.margin-top-0 {
margin-top: 0;
}
h2 { h2 {
color: color('primary-dark'); color: color('primary-dark');
margin-top: units(2); margin-top: units(2);
@ -96,6 +100,10 @@ body {
@include at-media(mobile-lg) { @include at-media(mobile-lg) {
margin-top: units(5); margin-top: units(5);
&.margin-top-0 {
margin-top: 0;
}
h2 { h2 {
margin-bottom: 0; margin-bottom: 0;
} }
@ -182,7 +190,7 @@ abbr[title] {
svg.usa-icon { svg.usa-icon {
color: #{$dhs-red}; color: #{$dhs-red};
} }
div.readonly-field { div.input-with-edit-button__readonly-field {
color: #{$dhs-red}; color: #{$dhs-red};
} }
} }
@ -211,3 +219,7 @@ abbr[title] {
.usa-logo button.usa-button--unstyled.disabled-button:hover{ .usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85}; color: #{$dhs-dark-gray-85};
} }
.padding--8-8-9 {
padding: 8px 8px 9px !important;
}

View file

@ -161,3 +161,19 @@ a.usa-button--unstyled:visited {
margin-left: units(2); margin-left: units(2);
} }
} }
.usa-button--filter {
width: auto;
// For mobile stacking
margin-bottom: units(1);
border: solid 1px color('base-light') !important;
padding: units(1);
color: color('primary-darker') !important;
font-weight: font-weight('normal');
font-size: size('ui', 'xs');
box-shadow: none;
&:hover {
box-shadow: none;
}
}

View file

@ -1,7 +1,7 @@
@use "uswds-core" as *; @use "uswds-core" as *;
.dotgov-table { .dotgov-table a,
a { .usa-link--icon {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
color: color('primary'); color: color('primary');
@ -9,10 +9,6 @@
&:visited { &:visited {
color: color('primary'); color: color('primary');
} }
}
}
a {
.usa-icon { .usa-icon {
// align icon with x height // align icon with x height
margin-top: units(0.5); margin-top: units(0.5);

View file

@ -27,7 +27,6 @@
} }
td .no-click-outline-and-cursor-help { td .no-click-outline-and-cursor-help {
outline: none;
cursor: help; cursor: help;
use { use {
// USWDS has weird interactions with SVGs regarding tooltips, // USWDS has weird interactions with SVGs regarding tooltips,

View file

@ -12,6 +12,7 @@
@forward "typography"; @forward "typography";
@forward "links"; @forward "links";
@forward "lists"; @forward "lists";
@forward "accordions";
@forward "buttons"; @forward "buttons";
@forward "pagination"; @forward "pagination";
@forward "forms"; @forward "forms";

View file

@ -660,6 +660,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov", "getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov", "getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov", "getgov-development.app.cloud.gov",
"getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov", "getgov-ag.app.cloud.gov",
"getgov-litterbox.app.cloud.gov", "getgov-litterbox.app.cloud.gov",
"getgov-hotgov.app.cloud.gov", "getgov-hotgov.app.cloud.gov",

View file

@ -44,7 +44,7 @@ for step, view in [
(Step.ORGANIZATION_ELECTION, views.OrganizationElection), (Step.ORGANIZATION_ELECTION, views.OrganizationElection),
(Step.ORGANIZATION_CONTACT, views.OrganizationContact), (Step.ORGANIZATION_CONTACT, views.OrganizationContact),
(Step.ABOUT_YOUR_ORGANIZATION, views.AboutYourOrganization), (Step.ABOUT_YOUR_ORGANIZATION, views.AboutYourOrganization),
(Step.AUTHORIZING_OFFICIAL, views.AuthorizingOfficial), (Step.SENIOR_OFFICIAL, views.SeniorOfficial),
(Step.CURRENT_SITES, views.CurrentSites), (Step.CURRENT_SITES, views.CurrentSites),
(Step.DOTGOV_DOMAIN, views.DotgovDomain), (Step.DOTGOV_DOMAIN, views.DotgovDomain),
(Step.PURPOSE, views.Purpose), (Step.PURPOSE, views.Purpose),
@ -183,9 +183,9 @@ urlpatterns = [
name="domain-org-name-address", name="domain-org-name-address",
), ),
path( path(
"domain/<int:pk>/authorizing-official", "domain/<int:pk>/senior-official",
views.DomainAuthorizingOfficialView.as_view(), views.DomainSeniorOfficialView.as_view(),
name="domain-authorizing-official", name="domain-senior-official",
), ),
path( path(
"domain/<int:pk>/security-email", "domain/<int:pk>/security-email",

View file

@ -36,7 +36,7 @@ class DomainRequestFixture:
# "purpose": None, # "purpose": None,
# "anything_else": None, # "anything_else": None,
# "is_policy_acknowledged": None, # "is_policy_acknowledged": None,
# "authorizing_official": None, # "senior_official": None,
# "submitter": None, # "submitter": None,
# "other_contacts": [], # "other_contacts": [],
# "current_websites": [], # "current_websites": [],
@ -117,11 +117,11 @@ class DomainRequestFixture:
if not da.investigator: if not da.investigator:
da.investigator = User.objects.get(username=user.username) if "investigator" in app else None da.investigator = User.objects.get(username=user.username) if "investigator" in app else None
if not da.authorizing_official: if not da.senior_official:
if "authorizing_official" in app and app["authorizing_official"] is not None: if "senior_official" in app and app["senior_official"] is not None:
da.authorizing_official, _ = Contact.objects.get_or_create(**app["authorizing_official"]) da.senior_official, _ = Contact.objects.get_or_create(**app["senior_official"])
else: else:
da.authorizing_official = Contact.objects.create(**cls.fake_contact()) da.senior_official = Contact.objects.create(**cls.fake_contact())
if not da.submitter: if not da.submitter:
if "submitter" in app and app["submitter"] is not None: if "submitter" in app and app["submitter"] is not None:

View file

@ -22,6 +22,11 @@ class UserFixture:
""" """
ADMINS = [ ADMINS = [
{
"username": "be17c826-e200-4999-9389-2ded48c43691",
"first_name": "Matthew",
"last_name": "Spence",
},
{ {
"username": "5f283494-31bd-49b5-b024-a7e7cae00848", "username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid", "first_name": "Rachid",
@ -115,6 +120,11 @@ class UserFixture:
] ]
STAFF = [ STAFF = [
{
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
"first_name": "Matthew-Analyst",
"last_name": "Spence-Analyst",
},
{ {
"username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844", "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
"first_name": "Rachid-Analyst", "first_name": "Rachid-Analyst",

View file

@ -4,8 +4,8 @@ from .domain import (
NameserverFormset, NameserverFormset,
DomainSecurityEmailForm, DomainSecurityEmailForm,
DomainOrgNameAddressForm, DomainOrgNameAddressForm,
ContactForm, UserForm,
AuthorizingOfficialContactForm, SeniorOfficialContactForm,
DomainDnssecForm, DomainDnssecForm,
DomainDsdataFormset, DomainDsdataFormset,
DomainDsdataForm, DomainDsdataForm,

View file

@ -16,7 +16,7 @@ from registrar.utility.errors import (
SecurityEmailErrorCodes, SecurityEmailErrorCodes,
) )
from ..models import Contact, DomainInformation, Domain from ..models import Contact, DomainInformation, Domain, User
from .common import ( from .common import (
ALGORITHM_CHOICES, ALGORITHM_CHOICES,
DIGEST_TYPE_CHOICES, DIGEST_TYPE_CHOICES,
@ -203,6 +203,63 @@ NameserverFormset = formset_factory(
) )
class UserForm(forms.ModelForm):
"""Form for updating users."""
email = forms.EmailField(max_length=None)
class Meta:
model = User
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = {
"first_name": forms.TextInput,
"middle_name": forms.TextInput,
"last_name": forms.TextInput,
"title": forms.TextInput,
"email": forms.EmailInput,
"phone": RegionalPhoneNumberWidget,
}
# the database fields have blank=True so ModelForm doesn't create
# required fields by default. Use this list in __init__ to mark each
# of these fields as required
required = ["first_name", "last_name", "title", "email", "phone"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# take off maxlength attribute for the phone number field
# which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
# Define a custom validator for the email field with a custom error message
email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
self.fields["email"].validators.append(email_max_length_validator)
for field_name in self.required:
self.fields[field_name].required = True
# Set custom form label
self.fields["middle_name"].label = "Middle name (optional)"
# Set custom error messages
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
self.fields["last_name"].error_messages = {"required": "Enter your last name / family name."}
self.fields["title"].error_messages = {
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
}
self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
self.domainInfo = None
def set_domain_info(self, domainInfo):
"""Set the domain information for the form.
The form instance is associated with the contact itself. In order to access the associated
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
class ContactForm(forms.ModelForm): class ContactForm(forms.ModelForm):
"""Form for updating contacts.""" """Form for updating contacts."""
@ -260,10 +317,10 @@ class ContactForm(forms.ModelForm):
self.domainInfo = domainInfo self.domainInfo = domainInfo
class AuthorizingOfficialContactForm(ContactForm): class SeniorOfficialContactForm(ContactForm):
"""Form for updating authorizing official contacts.""" """Form for updating senior official contacts."""
JOIN = "authorizing_official" JOIN = "senior_official"
def __init__(self, disable_fields=False, *args, **kwargs): def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -273,13 +330,13 @@ class AuthorizingOfficialContactForm(ContactForm):
# Set custom error messages # Set custom error messages
self.fields["first_name"].error_messages = { self.fields["first_name"].error_messages = {
"required": "Enter the first name / given name of your authorizing official." "required": "Enter the first name / given name of your senior official."
} }
self.fields["last_name"].error_messages = { self.fields["last_name"].error_messages = {
"required": "Enter the last name / family name of your authorizing official." "required": "Enter the last name / family name of your senior official."
} }
self.fields["title"].error_messages = { self.fields["title"].error_messages = {
"required": "Enter the title or role your authorizing official has in your \ "required": "Enter the title or role your senior official has in your \
organization (e.g., Chief Information Officer)." organization (e.g., Chief Information Officer)."
} }
self.fields["email"].error_messages = { self.fields["email"].error_messages = {
@ -306,21 +363,21 @@ class AuthorizingOfficialContactForm(ContactForm):
is_federal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL is_federal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
is_tribal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL is_tribal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL
# Get the Contact object from the db for the Authorizing Official # Get the Contact object from the db for the Senior Official
db_ao = Contact.objects.get(id=self.instance.id) db_so = Contact.objects.get(id=self.instance.id)
if (is_federal or is_tribal) and self.has_changed(): if (is_federal or is_tribal) and self.has_changed():
# This action should be blocked by the UI, as the text fields are readonly. # This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way. # If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only. # This could be malicious, so lets reserve information for the backend only.
raise ValueError("Authorizing Official cannot be modified for federal or tribal domains.") raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
elif db_ao.has_more_than_one_join("information_authorizing_official"): elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the AO Contact # Handle the case where the domain information object is available and the SO Contact
# has more than one joined object. # has more than one joined object.
# In this case, create a new Contact, and update the new Contact with form data. # In this case, create a new Contact, and update the new Contact with form data.
# Then associate with domain information object as the authorizing_official # Then associate with domain information object as the senior_official
data = dict(self.cleaned_data.items()) data = dict(self.cleaned_data.items())
self.domainInfo.authorizing_official = Contact.objects.create(**data) self.domainInfo.senior_official = Contact.objects.create(**data)
self.domainInfo.save() self.domainInfo.save()
else: else:
# If all checks pass, just save normally # If all checks pass, just save normally

View file

@ -183,14 +183,14 @@ class AboutYourOrganizationForm(RegistrarForm):
) )
class AuthorizingOfficialForm(RegistrarForm): class SeniorOfficialForm(RegistrarForm):
JOIN = "authorizing_official" JOIN = "senior_official"
def to_database(self, obj): def to_database(self, obj):
if not self.is_valid(): if not self.is_valid():
return return
contact = getattr(obj, "authorizing_official", None) contact = getattr(obj, "senior_official", None)
if contact is not None and not contact.has_more_than_one_join("authorizing_official"): if contact is not None and not contact.has_more_than_one_join("senior_official"):
# if contact exists in the database and is not joined to other entities # if contact exists in the database and is not joined to other entities
super().to_database(contact) super().to_database(contact)
else: else:
@ -198,27 +198,27 @@ class AuthorizingOfficialForm(RegistrarForm):
# in either case, create a new contact and update it # in either case, create a new contact and update it
contact = Contact() contact = Contact()
super().to_database(contact) super().to_database(contact)
obj.authorizing_official = contact obj.senior_official = contact
obj.save() obj.save()
@classmethod @classmethod
def from_database(cls, obj): def from_database(cls, obj):
contact = getattr(obj, "authorizing_official", None) contact = getattr(obj, "senior_official", None)
return super().from_database(contact) return super().from_database(contact)
first_name = forms.CharField( first_name = forms.CharField(
label="First name / given name", label="First name / given name",
error_messages={"required": ("Enter the first name / given name of your authorizing official.")}, error_messages={"required": ("Enter the first name / given name of your senior official.")},
) )
last_name = forms.CharField( last_name = forms.CharField(
label="Last name / family name", label="Last name / family name",
error_messages={"required": ("Enter the last name / family name of your authorizing official.")}, error_messages={"required": ("Enter the last name / family name of your senior official.")},
) )
title = forms.CharField( title = forms.CharField(
label="Title or role in your organization", label="Title or role in your organization",
error_messages={ error_messages={
"required": ( "required": (
"Enter the title or role your authorizing official has in your" "Enter the title or role your senior official has in your"
" organization (e.g., Chief Information Officer)." " organization (e.g., Chief Information Officer)."
) )
}, },

View file

@ -1,6 +1,6 @@
from django import forms from django import forms
from registrar.models.contact import Contact from registrar.models.user import User
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -13,7 +13,7 @@ class UserProfileForm(forms.ModelForm):
redirect = forms.CharField(widget=forms.HiddenInput(), required=False) redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta: class Meta:
model = Contact model = User
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"] fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = { widgets = {
"first_name": forms.TextInput, "first_name": forms.TextInput,
@ -71,7 +71,7 @@ class UserProfileForm(forms.ModelForm):
class FinishSetupProfileForm(UserProfileForm): class FinishSetupProfileForm(UserProfileForm):
"""Form for updating user profile.""" """Form for updating user profile."""
full_name = forms.CharField(required=True, label="Full name") full_name = forms.CharField(required=False, label="Full name")
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
@ -93,4 +93,7 @@ class FinishSetupProfileForm(UserProfileForm):
self.fields["title"].label = "Title or role in your organization" self.fields["title"].label = "Title or role in your organization"
# Define the "full_name" value # Define the "full_name" value
self.fields["full_name"].initial = self.instance.get_formatted_name() full_name = None
if self.instance.first_name and self.instance.last_name:
full_name = self.instance.get_formatted_name()
self.fields["full_name"].initial = full_name

View file

@ -1,242 +0,0 @@
import logging
import argparse
import sys
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import (
TerminalColors,
TerminalHelper,
)
from registrar.models.contact import Contact
from registrar.models.user import User
from registrar.models.utility.domain_helper import DomainHelper
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = """Copy first and last names from a contact to
a related user if it exists and if its first and last name
properties are null or blank strings."""
# ======================================================
# ===================== ARGUMENTS =====================
# ======================================================
def add_arguments(self, parser):
parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
# ======================================================
# ===================== PRINTING ======================
# ======================================================
def print_debug_mode_statements(self, debug_on: bool):
"""Prints additional terminal statements to indicate if --debug
or --limitParse are in use"""
TerminalHelper.print_conditional(
debug_on,
f"""{TerminalColors.OKCYAN}
----------DEBUG MODE ON----------
Detailed print statements activated.
{TerminalColors.ENDC}
""",
)
def print_summary_of_findings(
self,
skipped_contacts,
eligible_users,
processed_users,
debug_on,
):
"""Prints to terminal a summary of findings from
copying first and last names from contacts to users"""
total_eligible_users = len(eligible_users)
total_skipped_contacts = len(skipped_contacts)
total_processed_users = len(processed_users)
logger.info(
f"""{TerminalColors.OKGREEN}
============= FINISHED ===============
Skipped {total_skipped_contacts} contacts
Found {total_eligible_users} users linked to contacts
Processed {total_processed_users} users
{TerminalColors.ENDC}
""" # noqa
)
# DEBUG:
TerminalHelper.print_conditional(
debug_on,
f"""{TerminalColors.YELLOW}
======= DEBUG OUTPUT =======
Users who have a linked contact:
{eligible_users}
Processed users (users who have a linked contact and a missing first or last name):
{processed_users}
===== SKIPPED CONTACTS =====
{skipped_contacts}
{TerminalColors.ENDC}
""",
)
# ======================================================
# =================== USER =====================
# ======================================================
def update_user(self, contact: Contact, debug_on: bool):
"""Given a contact with a first_name and last_name, find & update an existing
corresponding user if her first_name and last_name are null.
Returns tuple of eligible (is linked to the contact) and processed
(first and last are blank) users.
"""
user_exists = User.objects.filter(contact=contact).exists()
if user_exists:
try:
# ----------------------- UPDATE USER -----------------------
# ---- GET THE USER
eligible_user = User.objects.get(contact=contact)
processed_user = None
# DEBUG:
TerminalHelper.print_conditional(
debug_on,
f"""{TerminalColors.YELLOW}
> Found linked user for contact:
{contact} {contact.email} {contact.first_name} {contact.last_name}
> The linked user is {eligible_user} {eligible_user.username}
{TerminalColors.ENDC}""", # noqa
)
# Get the fields that exist on both User and Contact. Excludes id.
common_fields = DomainHelper.get_common_fields(User, Contact)
if "email" in common_fields:
# Don't change the email field.
common_fields.remove("email")
for field in common_fields:
# Grab the value that contact has stored for this field
new_value = getattr(contact, field)
# Set it on the user field
setattr(eligible_user, field, new_value)
eligible_user.save()
processed_user = eligible_user
return (
eligible_user,
processed_user,
)
except Exception as error:
logger.warning(
f"""
{TerminalColors.FAIL}
!!! ERROR: An exception occured in the
User table for the following user:
{contact.email} {contact.first_name} {contact.last_name}
Exception is: {error}
----------TERMINATING----------"""
)
sys.exit()
else:
return None, None
# ======================================================
# ================= PROCESS CONTACTS ==================
# ======================================================
def process_contacts(
self,
debug_on,
skipped_contacts=[],
eligible_users=[],
processed_users=[],
):
for contact in Contact.objects.all():
TerminalHelper.print_conditional(
debug_on,
f"{TerminalColors.OKCYAN}"
"Processing Contact: "
f"{contact.email},"
f" {contact.first_name},"
f" {contact.last_name}"
f"{TerminalColors.ENDC}",
)
# ======================================================
# ====================== USER =======================
(eligible_user, processed_user) = self.update_user(contact, debug_on)
debug_string = ""
if eligible_user:
# ---------------- UPDATED ----------------
eligible_users.append(contact.email)
debug_string = f"eligible user: {eligible_user}"
if processed_user:
processed_users.append(contact.email)
debug_string = f"processed user: {processed_user}"
else:
skipped_contacts.append(contact.email)
debug_string = f"skipped user: {contact.email}"
# DEBUG:
TerminalHelper.print_conditional(
debug_on,
(f"{TerminalColors.OKCYAN} {debug_string} {TerminalColors.ENDC}"),
)
return (
skipped_contacts,
eligible_users,
processed_users,
)
# ======================================================
# ===================== HANDLE ========================
# ======================================================
def handle(
self,
**options,
):
"""Parse entries in Contact table
and update valid corresponding entries in the
User table."""
# grab command line arguments and store locally...
debug_on = options.get("debug")
self.print_debug_mode_statements(debug_on)
logger.info(
f"""{TerminalColors.OKCYAN}
==========================
Beginning Data Transfer
==========================
{TerminalColors.ENDC}"""
)
logger.info(
f"""{TerminalColors.OKCYAN}
========= Adding Domains and Domain Invitations =========
{TerminalColors.ENDC}"""
)
(
skipped_contacts,
eligible_users,
processed_users,
) = self.process_contacts(
debug_on,
)
self.print_summary_of_findings(
skipped_contacts,
eligible_users,
processed_users,
debug_on,
)

View file

@ -50,7 +50,7 @@ class Command(BaseCommand):
# Generate a file locally for upload # Generate a file locally for upload
with open(file_path, "w") as file: with open(file_path, "w") as file:
csv_export.export_data_federal_to_csv(file) csv_export.DomainDataFederal.export_data_to_csv(file)
if check_path and not os.path.exists(file_path): if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'") raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")

View file

@ -49,7 +49,7 @@ class Command(BaseCommand):
# Generate a file locally for upload # Generate a file locally for upload
with open(file_path, "w") as file: with open(file_path, "w") as file:
csv_export.export_data_full_to_csv(file) csv_export.DomainDataFull.export_data_to_csv(file)
if check_path and not os.path.exists(file_path): if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'") raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")

View file

@ -65,13 +65,6 @@ class Command(BaseCommand):
resourcename = f"{table_name}Resource" resourcename = f"{table_name}Resource"
# if table_name is Contact, clean the table first
# User table is loaded before Contact, and signals create
# rows in Contact table which break the import, so need
# to be cleaned again before running import on Contact table
if table_name == "Contact":
self.clean_table(table_name)
# Define the directory and the pattern for csv filenames # Define the directory and the pattern for csv filenames
tmp_dir = "tmp" tmp_dir = "tmp"
pattern = f"{table_name}_" pattern = f"{table_name}_"

View file

@ -0,0 +1,76 @@
import argparse
import csv
import logging
import os
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
from registrar.models import DomainInformation
logger = logging.getLogger(__name__)
class Command(BaseCommand, PopulateScriptTemplate):
"""
This command uses the PopulateScriptTemplate,
which provides reusable logging and bulk updating functions for mass-updating fields.
"""
help = "Loops through each valid DomainInformation object and updates its Senior Official"
prompt_title = "Do you wish to update all Senior Officials for Domain Information?"
def handle(self, domain_info_csv_path, **kwargs):
"""Loops through each valid DomainInformation object and updates its senior official field"""
# Check if the provided file path is valid.
if not os.path.isfile(domain_info_csv_path):
raise argparse.ArgumentTypeError(f"Invalid file path '{domain_info_csv_path}'")
# Simple check to make sure we don't accidentally pass in the wrong file. Crude but it works.
if "information" not in domain_info_csv_path.lower():
raise argparse.ArgumentTypeError(f"Invalid file for domain information: '{domain_info_csv_path}'")
# Get all ao data.
self.ao_dict = {}
self.ao_dict = self.read_csv_file_and_get_contacts(domain_info_csv_path)
self.mass_update_records(
DomainInformation, filter_conditions={"senior_official__isnull": True}, fields_to_update=["senior_official"]
)
def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
"--domain_info_csv_path", help="A csv containing the domain information id and the contact id"
)
def read_csv_file_and_get_contacts(self, file):
dict_data = {}
with open(file, "r") as requested_file:
reader = csv.DictReader(requested_file)
for row in reader:
domain_info_id = row.get("id")
ao_id = row.get("authorizing_official")
if ao_id:
ao_id = int(ao_id)
if domain_info_id and ao_id:
dict_data[int(domain_info_id)] = ao_id
return dict_data
def update_record(self, record: DomainInformation):
"""Defines how we update the senior official field on each record."""
record.senior_official_id = self.ao_dict.get(record.id)
logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.senior_official}{TerminalColors.ENDC}")
def should_skip_record(self, record) -> bool: # noqa
"""Defines the conditions in which we should skip updating a record."""
# Don't update this record if there isn't ao data to pull from
if self.ao_dict.get(record.id) is None:
logger.info(
f"{TerminalColors.YELLOW}Skipping update for {str(record)} => "
f"Missing authorizing_official data.{TerminalColors.ENDC}"
)
return True
else:
return False

View file

@ -0,0 +1,81 @@
import argparse
import csv
import logging
import os
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
from registrar.models import DomainRequest
logger = logging.getLogger(__name__)
class Command(BaseCommand, PopulateScriptTemplate):
"""
This command uses the PopulateScriptTemplate,
which provides reusable logging and bulk updating functions for mass-updating fields.
"""
help = """Loops through each valid DomainRequest object and updates its senior official field"""
prompt_title = "Do you wish to update all Senior Officials for Domain Requests?"
def handle(self, domain_request_csv_path, **kwargs):
"""Loops through each valid DomainRequest object and updates its senior official field"""
# Check if the provided file path is valid.
if not os.path.isfile(domain_request_csv_path):
raise argparse.ArgumentTypeError(f"Invalid file path '{domain_request_csv_path}'")
# Simple check to make sure we don't accidentally pass in the wrong file. Crude but it works.
if "request" not in domain_request_csv_path.lower():
raise argparse.ArgumentTypeError(f"Invalid file for domain requests: '{domain_request_csv_path}'")
# Get all ao data.
self.ao_dict = {}
self.ao_dict = self.read_csv_file_and_get_contacts(domain_request_csv_path)
self.mass_update_records(
DomainRequest,
filter_conditions={
"senior_official__isnull": True,
},
fields_to_update=["senior_official"],
)
def add_arguments(self, parser):
"""Add command line arguments."""
parser.add_argument(
"--domain_request_csv_path", help="A csv containing the domain request id and the contact id"
)
def read_csv_file_and_get_contacts(self, file):
dict_data: dict = {}
with open(file, "r") as requested_file:
reader = csv.DictReader(requested_file)
for row in reader:
domain_request_id = row.get("id")
ao_id = row.get("authorizing_official")
if ao_id:
ao_id = int(ao_id)
if domain_request_id and ao_id:
dict_data[int(domain_request_id)] = ao_id
return dict_data
def update_record(self, record: DomainRequest):
"""Defines how we update the federal_type field on each record."""
record.senior_official_id = self.ao_dict.get(record.id)
# record.senior_official = Contact.objects.get(id=contact_id)
logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.senior_official}{TerminalColors.ENDC}")
def should_skip_record(self, record) -> bool: # noqa
"""Defines the conditions in which we should skip updating a record."""
# Don't update this record if there isn't ao data to pull from
if self.ao_dict.get(record.id) is None:
logger.info(
f"{TerminalColors.YELLOW}Skipping update for {str(record)} => "
f"Missing authorizing_official data.{TerminalColors.ENDC}"
)
return True
else:
return False

View file

@ -390,7 +390,7 @@ class Command(BaseCommand):
fed_type = transition_domain.federal_type fed_type = transition_domain.federal_type
fed_agency = transition_domain.federal_agency fed_agency = transition_domain.federal_agency
# = AO Information = # # = SO Information = #
first_name = transition_domain.first_name first_name = transition_domain.first_name
middle_name = transition_domain.middle_name middle_name = transition_domain.middle_name
last_name = transition_domain.last_name last_name = transition_domain.last_name
@ -429,7 +429,7 @@ class Command(BaseCommand):
"domain": domain, "domain": domain,
"organization_name": transition_domain.organization_name, "organization_name": transition_domain.organization_name,
"creator": default_creator, "creator": default_creator,
"authorizing_official": contact, "senior_official": contact,
} }
if valid_org_type: if valid_org_type:

View file

@ -177,7 +177,7 @@ class LoadExtraTransitionDomain:
# STEP 3: Parse agency data # STEP 3: Parse agency data
updated_transition_domain = self.parse_agency_data(domain_name, transition_domain) updated_transition_domain = self.parse_agency_data(domain_name, transition_domain)
# STEP 4: Parse ao data # STEP 4: Parse so data
updated_transition_domain = self.parse_authority_data(domain_name, transition_domain) updated_transition_domain = self.parse_authority_data(domain_name, transition_domain)
# STEP 5: Parse creation and expiration data # STEP 5: Parse creation and expiration data
@ -326,7 +326,7 @@ class LoadExtraTransitionDomain:
) )
def parse_authority_data(self, domain_name, transition_domain) -> TransitionDomain: def parse_authority_data(self, domain_name, transition_domain) -> TransitionDomain:
"""Grabs authorizing_offical data from the parsed files and associates it """Grabs senior_offical data from the parsed files and associates it
with a transition_domain object, then returns that object.""" with a transition_domain object, then returns that object."""
if not isinstance(transition_domain, TransitionDomain): if not isinstance(transition_domain, TransitionDomain):
raise ValueError("Not a valid object, must be TransitionDomain") raise ValueError("Not a valid object, must be TransitionDomain")
@ -336,7 +336,7 @@ class LoadExtraTransitionDomain:
self.parse_logs.create_log_item( self.parse_logs.create_log_item(
EnumFilenames.AGENCY_ADHOC, EnumFilenames.AGENCY_ADHOC,
LogCode.ERROR, LogCode.ERROR,
f"Could not add authorizing_official on {domain_name}, no data exists.", f"Could not add senior_official on {domain_name}, no data exists.",
domain_name, domain_name,
not self.debug, not self.debug,
) )

View file

@ -0,0 +1,59 @@
# Generated by Django 4.2.10 on 2024-06-24 19:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0107_domainrequest_action_needed_reason_email"),
]
operations = [
migrations.RemoveField(
model_name="domaininformation",
name="authorizing_official",
),
migrations.RemoveField(
model_name="domainrequest",
name="authorizing_official",
),
migrations.AddField(
model_name="domaininformation",
name="senior_official",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_senior_official",
to="registrar.contact",
),
),
migrations.AddField(
model_name="domainrequest",
name="senior_official",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="senior_official",
to="registrar.contact",
),
),
migrations.AlterField(
model_name="domainrequest",
name="action_needed_reason",
field=models.TextField(
blank=True,
choices=[
("eligibility_unclear", "Unclear organization eligibility"),
("questionable_senior_official", "Questionable senior official"),
("already_has_domains", "Already has domains"),
("bad_name", "Doesnt meet naming requirements"),
("other", "Other (no auto-email sent)"),
],
null=True,
),
),
]

View file

@ -0,0 +1,85 @@
# Generated by Django 4.2.10 on 2024-07-02 16:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0108_domaininformation_authorizing_official_and_more"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_sub_organization",
to="registrar.suborganization",
),
),
migrations.AddField(
model_name="domainrequest",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain request is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="request_sub_organization",
to="registrar.suborganization",
),
),
migrations.AlterField(
model_name="domaininformation",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="Domain associated with this request; will be blank until request is approved",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="domain_request_approved_domain",
to="registrar.domain",
),
),
migrations.AlterField(
model_name="domainrequest",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain request",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="DomainRequest_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="requested_domain",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_request_requested_domain",
to="registrar.draftdomain",
),
),
]

View file

@ -0,0 +1,45 @@
# Generated by Django 4.2.10 on 2024-07-02 21:03
from django.db import migrations, models
import django.db.models.deletion
import phonenumber_field.modelfields
class Migration(migrations.Migration):
dependencies = [
("registrar", "0109_domaininformation_sub_organization_and_more"),
]
operations = [
migrations.CreateModel(
name="SeniorOfficial",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("first_name", models.CharField(verbose_name="first name")),
("last_name", models.CharField(verbose_name="last name")),
("title", models.CharField(verbose_name="title / role")),
(
"phone",
phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None),
),
("email", models.EmailField(blank=True, max_length=320, null=True)),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="portfolio",
name="senior_official",
field=models.ForeignKey(
blank=True,
help_text="Associated senior official",
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="registrar.seniorofficial",
),
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0079 (which populates federal agencies)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0110_seniorofficial_portfolio_senior_official"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 4.2.10 on 2024-07-02 19:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0111_create_groups_v15"),
]
operations = [
migrations.RemoveIndex(
model_name="contact",
name="registrar_c_user_id_4059c4_idx",
),
migrations.RemoveField(
model_name="contact",
name="user",
),
]

View file

@ -19,6 +19,7 @@ from .waffle_flag import WaffleFlag
from .portfolio import Portfolio from .portfolio import Portfolio
from .domain_group import DomainGroup from .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
from .senior_official import SeniorOfficial
__all__ = [ __all__ = [
@ -42,6 +43,7 @@ __all__ = [
"Portfolio", "Portfolio",
"DomainGroup", "DomainGroup",
"Suborganization", "Suborganization",
"SeniorOfficial",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -64,3 +66,4 @@ auditlog.register(WaffleFlag)
auditlog.register(Portfolio) auditlog.register(Portfolio)
auditlog.register(DomainGroup) auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)

View file

@ -8,30 +8,15 @@ from phonenumber_field.modelfields import PhoneNumberField # type: ignore
class Contact(TimeStampedModel): class Contact(TimeStampedModel):
""" """
Contact information follows a similar pattern for each contact. Contact information follows a similar pattern for each contact.
This model uses signals [as defined in [signals.py](../../src/registrar/signals.py)].
When a new user is created through Login.gov, a contact object will be created and
associated on the `user` field.
If the `user` object already exists, the underlying user object
will be updated if any updates are made to it through Login.gov.
""" """
class Meta: class Meta:
"""Contains meta information about this class""" """Contains meta information about this class"""
indexes = [ indexes = [
models.Index(fields=["user"]),
models.Index(fields=["email"]), models.Index(fields=["email"]),
] ]
user = models.OneToOneField(
"registrar.User",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
first_name = models.CharField( first_name = models.CharField(
null=True, null=True,
blank=True, blank=True,
@ -110,38 +95,6 @@ class Contact(TimeStampedModel):
def has_contact_info(self): def has_contact_info(self):
return bool(self.title or self.email or self.phone) return bool(self.title or self.email or self.phone)
def save(self, *args, **kwargs):
# Call the parent class's save method to perform the actual save
super().save(*args, **kwargs)
if self.user:
updated = False
# Update first name and last name if necessary
if not self.user.first_name or not self.user.last_name:
self.user.first_name = self.first_name
self.user.last_name = self.last_name
updated = True
# Update middle_name if necessary
if not self.user.middle_name:
self.user.middle_name = self.middle_name
updated = True
# Update phone if necessary
if not self.user.phone:
self.user.phone = self.phone
updated = True
# Update title if necessary
if not self.user.title:
self.user.title = self.title
updated = True
# Save user if any updates were made
if updated:
self.user.save()
def __str__(self): def __str__(self):
if self.first_name or self.last_name: if self.first_name or self.last_name:
return self.get_formatted_name() return self.get_formatted_name()

View file

@ -151,6 +151,11 @@ class Domain(TimeStampedModel, DomainHelper):
# previously existed but has been deleted from the registry # previously existed but has been deleted from the registry
DELETED = "deleted", "Deleted" DELETED = "deleted", "Deleted"
@classmethod
def get_state_label(cls, state: str):
"""Returns the associated label for a given state value"""
return cls(state).label if state else None
@classmethod @classmethod
def get_help_text(cls, state) -> str: def get_help_text(cls, state) -> str:
"""Returns a help message for a desired state. If none is found, an empty string is returned""" """Returns a help message for a desired state. If none is found, an empty string is returned"""

View file

@ -63,10 +63,19 @@ class DomainInformation(TimeStampedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
related_name="DomainRequest_portfolio", related_name="information_portfolio",
help_text="Portfolio associated with this domain", help_text="Portfolio associated with this domain",
) )
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="information_sub_organization",
help_text="The suborganization that this domain is included under",
)
domain_request = models.OneToOneField( domain_request = models.OneToOneField(
"registrar.DomainRequest", "registrar.DomainRequest",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -171,11 +180,11 @@ class DomainInformation(TimeStampedModel):
blank=True, blank=True,
) )
authorizing_official = models.ForeignKey( senior_official = models.ForeignKey(
"registrar.Contact", "registrar.Contact",
null=True, null=True,
blank=True, blank=True,
related_name="information_authorizing_official", related_name="information_senior_official",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
@ -361,6 +370,10 @@ class DomainInformation(TimeStampedModel):
# domain_request, if so short circuit the create # domain_request, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first() existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first()
if existing_domain_info: if existing_domain_info:
logger.info(
f"create_from_da() -> Shortcircuting create on {existing_domain_info}. "
"This record already exists. No values updated!"
)
return existing_domain_info return existing_domain_info
# Get the fields that exist on both DomainRequest and DomainInformation # Get the fields that exist on both DomainRequest and DomainInformation

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Union from typing import Union
import logging import logging
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@ -136,6 +135,13 @@ class DomainRequest(TimeStampedModel):
@classmethod @classmethod
def get_org_label(cls, org_name: str): def get_org_label(cls, org_name: str):
"""Returns the associated label for a given org name""" """Returns the associated label for a given org name"""
# This is an edgecase on domains with no org.
# This unlikely to happen but
# a break will occur in certain edge cases without this.
# (more specifically, csv exports).
if not org_name:
return None
org_names = org_name.split("_election") org_names = org_name.split("_election")
if len(org_names) > 0: if len(org_names) > 0:
org_name = org_names[0] org_name = org_names[0]
@ -266,7 +272,7 @@ class DomainRequest(TimeStampedModel):
"""Defines common action needed reasons for domain requests""" """Defines common action needed reasons for domain requests"""
ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility")
QUESTIONABLE_AUTHORIZING_OFFICIAL = ("questionable_authorizing_official", "Questionable authorizing official") QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official")
ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains")
BAD_NAME = ("bad_name", "Doesnt meet naming requirements") BAD_NAME = ("bad_name", "Doesnt meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)") OTHER = ("other", "Other (no auto-email sent)")
@ -315,10 +321,19 @@ class DomainRequest(TimeStampedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
related_name="DomainInformation_portfolio", related_name="DomainRequest_portfolio",
help_text="Portfolio associated with this domain request", help_text="Portfolio associated with this domain request",
) )
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="request_sub_organization",
help_text="The suborganization that this domain request is included under",
)
# This is the domain request user who created this domain request. The contact # This is the domain request user who created this domain request. The contact
# information that they gave is in the `submitter` field # information that they gave is in the `submitter` field
creator = models.ForeignKey( creator = models.ForeignKey(
@ -423,11 +438,11 @@ class DomainRequest(TimeStampedModel):
blank=True, blank=True,
) )
authorizing_official = models.ForeignKey( senior_official = models.ForeignKey(
"registrar.Contact", "registrar.Contact",
null=True, null=True,
blank=True, blank=True,
related_name="authorizing_official", related_name="senior_official",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
@ -444,7 +459,7 @@ class DomainRequest(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="Domain associated with this request; will be blank until request is approved", help_text="Domain associated with this request; will be blank until request is approved",
related_name="domain_request", related_name="domain_request_approved_domain",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
@ -452,7 +467,7 @@ class DomainRequest(TimeStampedModel):
"DraftDomain", "DraftDomain",
null=True, null=True,
blank=True, blank=True,
related_name="domain_request", related_name="domain_request_requested_domain",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
@ -603,8 +618,9 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Handle the action needed email. We send one when moving to action_needed, # Handle the action needed email.
# but we don't send one when we are _already_ in the state and change the reason. # An email is sent out when action_needed_reason is changed or added.
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
self.sync_action_needed_reason() self.sync_action_needed_reason()
# Update the cached values after saving # Update the cached values after saving
@ -615,10 +631,10 @@ class DomainRequest(TimeStampedModel):
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
reason_changed = self._cached_action_needed_reason != self.action_needed_reason reason_changed = self._cached_action_needed_reason != self.action_needed_reason
if was_already_action_needed and (reason_exists and reason_changed): if was_already_action_needed and reason_exists and reason_changed:
# We don't send emails out in state "other" # We don't send emails out in state "other"
if self.action_needed_reason != self.ActionNeededReasons.OTHER: if self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email() self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
def sync_yes_no_form_fields(self): def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not. """Some yes/no forms use a db field to track whether it was checked or not.
@ -672,7 +688,15 @@ class DomainRequest(TimeStampedModel):
logger.error(f"Can't query an approved domain while attempting {called_from}") logger.error(f"Can't query an approved domain while attempting {called_from}")
def _send_status_update_email( def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False self,
new_status,
email_template,
email_template_subject,
bcc_address="",
context=None,
send_email=True,
wrap_email=False,
custom_email_content=None,
): ):
"""Send a status update email to the creator. """Send a status update email to the creator.
@ -683,11 +707,18 @@ class DomainRequest(TimeStampedModel):
If the waffle flag "profile_feature" is active, then this email will be sent to the If the waffle flag "profile_feature" is active, then this email will be sent to the
domain request creator rather than the submitter domain request creator rather than the submitter
Optional args:
bcc_address: str -> the address to bcc to
context: dict -> The context sent to the template
send_email: bool -> Used to bypass the send_templated_email function, in the event send_email: bool -> Used to bypass the send_templated_email function, in the event
we just want to log that an email would have been sent, rather than actually sending one. we just want to log that an email would have been sent, rather than actually sending one.
wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given
paragraph exceeds our desired max length (for prettier display). paragraph exceeds our desired max length (for prettier display).
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 flag_is_active(None, "profile_feature") else self.submitter
@ -705,15 +736,21 @@ class DomainRequest(TimeStampedModel):
return None return None
try: try:
if not context:
context = {
"domain_request": self,
# This is the user that we refer to in the email
"recipient": recipient,
}
if custom_email_content:
context["custom_email_content"] = custom_email_content
send_templated_email( send_templated_email(
email_template, email_template,
email_template_subject, email_template_subject,
recipient.email, recipient.email,
context={ context=context,
"domain_request": self,
# This is the user that we refer to in the email
"recipient": recipient,
},
bcc_address=bcc_address, bcc_address=bcc_address,
wrap_email=wrap_email, wrap_email=wrap_email,
) )
@ -771,8 +808,8 @@ class DomainRequest(TimeStampedModel):
"submission confirmation", "submission confirmation",
"emails/submission_confirmation.txt", "emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt", "emails/submission_confirmation_subject.txt",
True, send_email=True,
bcc_address, bcc_address=bcc_address,
) )
@transition( @transition(
@ -842,41 +879,26 @@ class DomainRequest(TimeStampedModel):
# Send out an email if an action needed reason exists # Send out an email if an action needed reason exists
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER: if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email(send_email) email_content = self.action_needed_reason_email
self._send_action_needed_reason_email(send_email, email_content)
def _send_action_needed_reason_email(self, send_email=True): def _send_action_needed_reason_email(self, send_email=True, email_content=None):
"""Sends out an automatic email for each valid action needed reason provided""" """Sends out an automatic email for each valid action needed reason provided"""
# Store the filenames of the template and template subject email_template_name = "custom_email.txt"
email_template_name: str = ""
email_template_subject_name: str = ""
# Check for the "type" of action needed reason.
can_send_email = True
match self.action_needed_reason:
# Add to this match if you need to pass in a custom filename for these templates.
case self.ActionNeededReasons.OTHER, _:
# Unknown and other are default cases - do nothing
can_send_email = False
# Assumes that the template name matches the action needed reason if nothing is specified.
# This is so you can override if you need, or have this taken care of for you.
if not email_template_name and not email_template_subject_name:
email_template_name = f"{self.action_needed_reason}.txt"
email_template_subject_name = f"{self.action_needed_reason}_subject.txt" email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
bcc_address = "" bcc_address = ""
if settings.IS_PRODUCTION: if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL bcc_address = settings.DEFAULT_FROM_EMAIL
# If we can, try to send out an email as long as send_email=True
if can_send_email:
self._send_status_update_email( self._send_status_update_email(
new_status="action needed", new_status="action needed",
email_template=f"emails/action_needed_reasons/{email_template_name}", email_template=f"emails/action_needed_reasons/{email_template_name}",
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
send_email=send_email, send_email=send_email,
bcc_address=bcc_address, bcc_address=bcc_address,
custom_email_content=email_content,
wrap_email=True, wrap_email=True,
) )
@ -936,7 +958,7 @@ class DomainRequest(TimeStampedModel):
"domain request approved", "domain request approved",
"emails/status_change_approved.txt", "emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt", "emails/status_change_approved_subject.txt",
send_email, send_email=send_email,
) )
@transition( @transition(
@ -1128,8 +1150,8 @@ class DomainRequest(TimeStampedModel):
and self.zipcode is None and self.zipcode is None
) )
def _is_authorizing_official_complete(self): def _is_senior_official_complete(self):
return self.authorizing_official is not None return self.senior_official is not None
def _is_requested_domain_complete(self): def _is_requested_domain_complete(self):
return self.requested_domain is not None return self.requested_domain is not None
@ -1191,10 +1213,10 @@ class DomainRequest(TimeStampedModel):
has_profile_feature_flag = flag_is_active(request, "profile_feature") has_profile_feature_flag = flag_is_active(request, "profile_feature")
return ( return (
self._is_organization_name_and_address_complete() self._is_organization_name_and_address_complete()
and self._is_authorizing_official_complete() and self._is_senior_official_complete()
and self._is_requested_domain_complete() and self._is_requested_domain_complete()
and self._is_purpose_complete() and self._is_purpose_complete()
# NOTE: This flag leaves submitter as empty (request wont submit) hence preset to True # 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_submitter_complete() if not has_profile_feature_flag else True)
and self._is_other_contacts_complete() and self._is_other_contacts_complete()
and self._is_additional_details_complete() and self._is_additional_details_complete()

View file

@ -38,6 +38,15 @@ class Portfolio(TimeStampedModel):
default=FederalAgency.get_non_federal_agency, default=FederalAgency.get_non_federal_agency,
) )
senior_official = models.ForeignKey(
"registrar.SeniorOfficial",
on_delete=models.PROTECT,
help_text="Associated senior official",
unique=False,
null=True,
blank=True,
)
organization_type = models.CharField( organization_type = models.CharField(
max_length=255, max_length=255,
choices=OrganizationChoices.choices, choices=OrganizationChoices.choices,

View file

@ -0,0 +1,50 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
class SeniorOfficial(TimeStampedModel):
"""
Senior Official is a distinct Contact-like entity (NOT to be inherited
from Contacts) developed for the unique role these individuals have in
managing Portfolios.
"""
first_name = models.CharField(
null=False,
blank=False,
verbose_name="first name",
)
last_name = models.CharField(
null=False,
blank=False,
verbose_name="last name",
)
title = models.CharField(
null=False,
blank=False,
verbose_name="title / role",
)
phone = PhoneNumberField(
null=True,
blank=True,
)
email = models.EmailField(
null=True,
blank=True,
max_length=320,
)
def get_formatted_name(self):
"""Returns the contact's name in Western order."""
names = [n for n in [self.first_name, self.last_name] if n]
return " ".join(names) if names else "Unknown"
def __str__(self):
if self.first_name or self.last_name:
return self.get_formatted_name()
elif self.pk:
return str(self.pk)
else:
return ""

View file

@ -23,10 +23,6 @@ class User(AbstractUser):
A custom user model that performs identically to the default user model A custom user model that performs identically to the default user model
but can be customized later. but can be customized later.
This model uses signals [as defined in [signals.py](../../src/registrar/signals.py)].
When a new user is created through Login.gov, a contact object will be created and
associated on the contacts `user` field.
If the `user` object already exists, said user object If the `user` object already exists, said user object
will be updated if any updates are made to it through Login.gov. will be updated if any updates are made to it through Login.gov.
""" """
@ -113,17 +109,14 @@ class User(AbstractUser):
Tracks if the user finished their profile setup or not. This is so Tracks if the user finished their profile setup or not. This is so
we can globally enforce that new users provide additional account information before proceeding. we can globally enforce that new users provide additional account information before proceeding.
""" """
# Change this to self once the user and contact objects are merged.
# For now, since they are linked, lets test on the underlying contact object.
user_info = self.contact # noqa
user_values = [ user_values = [
user_info.first_name, self.first_name,
user_info.last_name, self.last_name,
user_info.title, self.title,
user_info.phone, self.phone,
] ]
return None not in user_values
return None not in user_values and "" not in user_values
def __str__(self): def __str__(self):
# this info is pulled from Login.gov # this info is pulled from Login.gov
@ -169,8 +162,13 @@ class User(AbstractUser):
"""Return count of ineligible requests""" """Return count of ineligible requests"""
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.INELIGIBLE).count() return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.INELIGIBLE).count()
def get_formatted_name(self):
"""Returns the contact's name in Western order."""
names = [n for n in [self.first_name, self.middle_name, self.last_name] if n]
return " ".join(names) if names else "Unknown"
def has_contact_info(self): def has_contact_info(self):
return bool(self.contact.title or self.contact.email or self.contact.phone) return bool(self.title or self.email or self.phone)
@classmethod @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):

View file

@ -1,59 +0,0 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import User, Contact
logger = logging.getLogger(__name__)
@receiver(post_save, sender=User)
def handle_profile(sender, instance, **kwargs):
"""Method for when a User is saved.
A first time registrant may have been invited, so we'll search for a matching
Contact record, by email address, and associate them, if possible.
A first time registrant may not have a matching Contact, so we'll create one,
copying the contact values we received from Login.gov in order to initialize it.
During subsequent login, a User record may be updated with new data from Login.gov,
but in no case will we update contact values on an existing Contact record.
"""
first_name = getattr(instance, "first_name", "")
middle_name = getattr(instance, "middle_name", "")
last_name = getattr(instance, "last_name", "")
email = getattr(instance, "email", "")
phone = getattr(instance, "phone", "")
title = getattr(instance, "title", "")
is_new_user = kwargs.get("created", False)
if is_new_user:
contacts = Contact.objects.filter(email=email)
else:
contacts = Contact.objects.filter(user=instance)
if len(contacts) == 0: # no matching contact
Contact.objects.create(
user=instance,
first_name=first_name,
middle_name=middle_name,
last_name=last_name,
email=email,
phone=phone,
title=title,
)
if len(contacts) >= 1 and is_new_user: # a matching contact
contacts[0].user = instance
contacts[0].save()
if len(contacts) > 1: # multiple matches
logger.warning(
"There are multiple Contacts with the same email address."
f" Picking #{contacts[0].id} for User #{instance.id}."
)

View file

@ -32,7 +32,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% for field in line %} {% for field in line %}
<div> <div>
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
{% block flex_container_start %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}"> <div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endblock flex_container_start %}
{% if field.is_checkbox %} {% if field.is_checkbox %}
{# .gov override #} {# .gov override #}
{% block field_checkbox %} {% block field_checkbox %}
@ -52,7 +54,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% endblock field_other%} {% endblock field_other%}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% block flex_container_end %}
</div> </div>
{% endblock flex_container_end %}
{% if field.field.help_text %} {% if field.field.help_text %}
{# .gov override #} {# .gov override #}

View file

@ -69,7 +69,7 @@
</h2> </h2>
<div class="usa-prose"> <div class="usa-prose">
<p> <p>
This will extend the expiration date by one year. This will extend the expiration date by one year from today.
{# Acts as a <br> #} {# Acts as a <br> #}
<div class="display-inline"></div> <div class="display-inline"></div>
This action cannot be undone. This action cannot be undone.
@ -78,7 +78,7 @@
Domain: <b>{{ original.name }}</b> Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #} {# Acts as a <br> #}
<div class="display-inline"></div> <div class="display-inline"></div>
New expiration date: <b>{{ extended_expiration_date }}</b> Current expiration date: <b>{{ curr_exp_date }}</b>
{{test}} {{test}}
</p> </p>
</div> </div>

View file

@ -12,37 +12,24 @@
{% if user.has_contact_info %} {% if user.has_contact_info %}
{# Title #} {# Title #}
{% if user.title or user.contact.title %} {% if user.title %}
{% if user.contact.title %}
{{ user.contact.title }}
{% else %}
{{ user.title }} {{ user.title }}
{% endif %}
<br> <br>
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}
{# Email #} {# Email #}
{% if user.email or user.contact.email %} {% if user.email %}
{% if user.contact.email %}
{{ user.contact.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %}
{{ user.email }} {{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %}
<br class="admin-icon-group__br"> <br class="admin-icon-group__br">
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}
{# Phone #} {# Phone #}
{% if user.phone or user.contact.phone %} {% if user.phone %}
{% if user.contact.phone %}
{{ user.contact.phone }}
{% else %}
{{ user.phone }} {{ user.phone }}
{% endif %}
<br> <br>
{% else %} {% else %}
None<br> None<br>

View file

@ -1,6 +1,6 @@
<p> <p>
Contacts include anyone who has access to the registrar (known as “users”) and anyone listed in a domain request, Contacts include anyone who has access to the registrar (known as “users”) and anyone listed in a domain request,
including other employees and authorizing officials. including other employees and senior officials.
Only contacts who have access to the registrar will have Only contacts who have access to the registrar will have
a corresponding record within the <a class="text-underline" href="{% url 'admin:registrar_user_changelist' %}">Users</a> table. a corresponding record within the <a class="text-underline" href="{% url 'admin:registrar_user_changelist' %}">Users</a> table.
</p> </p>

View file

@ -6,9 +6,85 @@
This is using a custom implementation fieldset.html (see admin/fieldset.html) This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %} {% endcomment %}
{% block flex_container_start %}
{% if field.field.name == "status_history" %}
<div class="flex-container flex-container--mobile-inline {% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% else %}
{% comment %} Default flex container element {% endcomment %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endif %}
{% endblock flex_container_start %}
{% block field_readonly %} {% block field_readonly %}
{% with all_contacts=original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %}
{% if field.field.name == "other_contacts" %} {% if field.field.name == "status_history" %}
{% if filtered_audit_log_entries %}
<div class="readonly">
<div class="usa-table-container--scrollable collapse--dgsimple collapsed margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Changed at</th>
</tr>
</thead>
<tbody>
{% for entry in filtered_audit_log_entries %}
<tr>
<td>
{% if entry.status %}
{{ entry.status|default:"Error" }}
{% else %}
Error
{% endif %}
{% if entry.rejection_reason %}
- {{ entry.rejection_reason|default:"Error" }}
{% endif %}
{% if entry.action_needed_reason %}
- {{ entry.action_needed_reason|default:"Error" }}
{% endif %}
</td>
<td>{{ entry.actor|default:"Error" }}</td>
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
{% else %}
<div class="readonly">
No changelog to display.
</div>
{% endif %}
{% elif field.field.name == "action_needed_reason_email" %}
<div class="readonly textarea-wrapper">
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-0 padding-top-0 margin-bottom-1 thin-border collapse--dgsimple collapsed">
<label class="max-full" for="action_needed_reason_email_view_more">
<strong>Sent to {% if has_profile_feature_flag %}creator{%else%}submitter{%endif%}</strong>
</label>
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
{{ original_object.action_needed_reason_email }}
</textarea>
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
{% elif field.field.name == "other_contacts" %}
{% if all_contacts.count > 2 %} {% if all_contacts.count > 2 %}
<div class="readonly"> <div class="readonly">
{% for contact in all_contacts %} {% for contact in all_contacts %}
@ -67,49 +143,30 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endwith %} {% endwith %}
{% endblock field_readonly %} {% endblock field_readonly %}
{% block after_help_text %} {% block field_other %}
{% if field.field.name == "status" %} {% if field.field.name == "action_needed_reason_email" %}
<div class="flex-container" id="dja-status-changelog"> <div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
<label aria-label="Status changelog"></label> <div class="margin-top-05 collapse--dgsimple collapsed">
{{ field.field.value|linebreaks }}
</div>
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div> <div>
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0"> {{ field.field }}
{% if filtered_audit_log_entries %} <input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
<table class="usa-table usa-table--borderless"> </div>
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Changed at</th>
</tr>
</thead>
<tbody>
{% for entry in filtered_audit_log_entries %}
<tr>
<td>
{% if entry.status %}
{{ entry.status|default:"Error" }}
{% else %} {% else %}
Error {{ field.field }}
{% endif %}
{% if entry.rejection_reason %}
- {{ entry.rejection_reason|default:"Error" }}
{% endif %}
{% if entry.action_needed_reason %}
- {{ entry.action_needed_reason|default:"Error" }}
{% endif %}
</td>
<td>{{ entry.actor|default:"Error" }}</td>
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No changelog to display.</p>
{% endif %} {% endif %}
{% endblock field_other %}
{% block after_help_text %}
{% if field.field.name == "action_needed_reason_email" %}
{% comment %} {% comment %}
Store the action needed reason emails in a json-based dictionary. Store the action needed reason emails in a json-based dictionary.
This allows us to change the action_needed_reason_email field dynamically, depending on value. This allows us to change the action_needed_reason_email field dynamically, depending on value.
@ -122,26 +179,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{{ action_needed_reason_emails|safe }} {{ action_needed_reason_emails|safe }}
</script> </script>
{% endif %} {% endif %}
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-2 thin-border display-none">
<label class="max-full" for="action_needed_reason_email_view_more">
<strong>Auto-generated email (sent to submitter)</strong>
</label>
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
{{ original_object.action_needed_reason_email }}
</textarea>
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
</div>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
</div>
{% elif field.field.name == "creator" %} {% elif field.field.name == "creator" %}
<div class="flex-container tablet:margin-top-2"> <div class="flex-container tablet:margin-top-2">
<label aria-label="Creator contact details"></label> <label aria-label="Creator contact details"></label>
@ -153,10 +190,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<label aria-label="Submitter contact details"></label> <label aria-label="Submitter contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %} {% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
</div> </div>
{% elif field.field.name == "authorizing_official" %} {% elif field.field.name == "senior_official" %}
<div class="flex-container"> <div class="flex-container">
<label aria-label="Authorizing official contact details"></label> <label aria-label="Senior official contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.authorizing_official no_title_top_padding=field.is_readonly %} {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
</div> </div>
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %} {% elif field.field.name == "other_contacts" and original_object.other_contacts.all %}
{% with all_contacts=original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %}

View file

@ -56,13 +56,13 @@
{% url 'domain-org-name-address' pk=domain.id as url %} {% 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=domain.is_editable %} {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=domain.is_editable %}
{% url 'domain-authorizing-official' pk=domain.id as url %} {% url 'domain-senior-official' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Authorizing official' value=domain.domain_info.authorizing_official contact='true' edit_link=url editable=domain.is_editable %} {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=domain.is_editable %}
{# Conditionally display profile #} {# Conditionally display profile #}
{% if not has_profile_feature_flag %} {% if not has_profile_feature_flag %}
{% url 'domain-your-contact-information' pk=domain.id as url %} {% url 'domain-your-contact-information' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url editable=domain.is_editable %} {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=domain.is_editable %}
{% endif %} {% endif %}
{% url 'domain-security-email' pk=domain.id as url %} {% url 'domain-security-email' pk=domain.id as url %}

View file

@ -1,37 +0,0 @@
{% extends 'domain_request_form.html' %}
{% load field_helpers url_helpers %}
{% block form_instructions %}
<h2 class="margin-bottom-05">
Who is the authorizing official for your organization?
</h2>
{% if not is_federal %}
<p>Your authorizing official is a person within your organization who can authorize your domain request. This person must be in a role of significant, executive responsibility within the organization.</p>
{% endif %}
<div class="ao_example">
{% include "includes/ao_example.html" %}
</div>
<p>We typically dont reach out to the authorizing official, but if contact is necessary, our practice is to coordinate with you, the requestor, first.</p>
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset">
<legend class="usa-sr-only">
Who is the authorizing official for your organization?
</legend>
{% input_with_errors forms.0.first_name %}
{% input_with_errors forms.0.last_name %}
{% input_with_errors forms.0.title %}
{% input_with_errors forms.0.email %}
</fieldset>
{% endblock %}

View file

@ -18,7 +18,7 @@
completing your domain request might take around 15 minutes.</p> completing your domain request might take around 15 minutes.</p>
{% if has_profile_feature_flag %} {% if has_profile_feature_flag %}
<h2>How well reach you</h2> <h2>How well reach you</h2>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p> <p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review. If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
{% include "includes/profile_information.html" with user=user%} {% include "includes/profile_information.html" with user=user%}
{% endif %} {% endif %}

View file

@ -40,7 +40,7 @@
<p>We may need to suspend or terminate a domain registration for violations of these requirements. When we discover a violation, well make reasonable efforts to contact a registrant, including emails or phone calls to: <p>We may need to suspend or terminate a domain registration for violations of these requirements. When we discover a violation, well make reasonable efforts to contact a registrant, including emails or phone calls to:
<ul class="usa-list"> <ul class="usa-list">
<li>Domain contacts</li> <li>Domain contacts</li>
<li>The authorizing official</li> <li>The senior official</li>
<li>The government organization, a parent organization, or affiliated entities</li> <li>The government organization, a parent organization, or affiliated entities</li>
</ul> </ul>
</p> </p>

View file

@ -79,10 +79,10 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% if step == Step.AUTHORIZING_OFFICIAL %} {% if step == Step.SENIOR_OFFICIAL %}
{% namespaced_url 'domain-request' step as domain_request_url %} {% namespaced_url 'domain-request' step as domain_request_url %}
{% if domain_request.authorizing_official is not None %} {% if domain_request.senior_official is not None %}
{% with title=form_titles|get_item:step value=domain_request.authorizing_official %} {% with title=form_titles|get_item:step value=domain_request.senior_official %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %}
{% endwith %} {% endwith %}
{% else %} {% else %}

View file

@ -0,0 +1,37 @@
{% extends 'domain_request_form.html' %}
{% load field_helpers url_helpers %}
{% block form_instructions %}
<h2 class="margin-bottom-05">
Who is the senior official for your organization?
</h2>
{% if not is_federal %}
<p>Your senior official is a person within your organization who can authorize your domain request. This person must be in a role of significant, executive responsibility within the organization.</p>
{% endif %}
<div class="so_example">
{% include "includes/so_example.html" %}
</div>
<p>We typically dont reach out to the senior official, but if contact is necessary, our practice is to coordinate with you, the requestor, first.</p>
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset">
<legend class="usa-sr-only">
Who is the senior official for your organization?
</legend>
{% input_with_errors forms.0.first_name %}
{% input_with_errors forms.0.last_name %}
{% input_with_errors forms.0.title %}
{% input_with_errors forms.0.email %}
</fieldset>
{% endblock %}

View file

@ -42,10 +42,13 @@
</div> </div>
<br> <br>
<p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p> <p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p>
{% if DomainRequest.status != 'rejected' %}
<p>{% include "includes/domain_request.html" %}</p> <p>{% include "includes/domain_request.html" %}</p>
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline"> <p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a> Withdraw request</a>
</p> </p>
{% endif %}
</div> </div>
<div class="grid-col desktop:grid-offset-2 maxw-tablet"> <div class="grid-col desktop:grid-offset-2 maxw-tablet">
@ -86,8 +89,8 @@
{% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %}
{% endif %} {% endif %}
{% if DomainRequest.authorizing_official %} {% if DomainRequest.senior_official %}
{% include "includes/summary_item.html" with title='Authorizing official' value=DomainRequest.authorizing_official contact='true' heading_level=heading_level %} {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %}
{% endif %} {% endif %}
{% if DomainRequest.current_websites.all %} {% if DomainRequest.current_websites.all %}

View file

@ -1,20 +1,20 @@
{% extends "domain_base.html" %} {% extends "domain_base.html" %}
{% load static field_helpers url_helpers %} {% load static field_helpers url_helpers %}
{% block title %}Authorizing official | {{ domain.name }} | {% endblock %} {% block title %}Senior official | {{ domain.name }} | {% endblock %}
{% block domain_content %} {% block domain_content %}
{# this is right after the messages block in the parent template #} {# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %} {% include "includes/form_errors.html" with form=form %}
<h1>Authorizing official</h1> <h1>Senior official</h1>
<p>Your authorizing official is a person within your organization who can <p>Your senior official is a person within your organization who can
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p> authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-a-senior-official-within-your-organization' %}">who can serve as a senior official</a>.</p>
{% if generic_org_type == "federal" or generic_org_type == "tribal" %} {% if generic_org_type == "federal" or generic_org_type == "tribal" %}
<p> <p>
The authorizing official for your organization cant be updated here. The senior official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>. To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p> </p>
{% else %} {% else %}
@ -27,10 +27,10 @@
{% if generic_org_type == "federal" or generic_org_type == "tribal" %} {% if generic_org_type == "federal" or generic_org_type == "tribal" %}
{# If all fields are disabled, add SR content #} {# If all fields are disabled, add SR content #}
<div class="usa-sr-only" aria-labelledby="id_first_name" id="sr-ao-first-name">{{ form.first_name.value }}</div> <div class="usa-sr-only" aria-labelledby="id_first_name" id="sr-so-first-name">{{ form.first_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_last_name" id="sr-ao-last-name">{{ form.last_name.value }}</div> <div class="usa-sr-only" aria-labelledby="id_last_name" id="sr-so-last-name">{{ form.last_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_title" id="sr-ao-title">{{ form.title.value }}</div> <div class="usa-sr-only" aria-labelledby="id_title" id="sr-so-title">{{ form.title.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_email" id="sr-ao-email">{{ form.email.value }}</div> <div class="usa-sr-only" aria-labelledby="id_email" id="sr-so-email">{{ form.email.value }}</div>
{% endif %} {% endif %}
{% input_with_errors form.first_name %} {% input_with_errors form.first_name %}

View file

@ -65,11 +65,11 @@
</li> </li>
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'domain-authorizing-official' pk=domain.id as url %} {% url 'domain-senior-official' pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}
> >
Authorizing official Senior official
</a> </a>
</li> </li>

View file

@ -8,7 +8,7 @@
<p> <p>
Domain managers can update all information related to a domain within the Domain managers can update all information related to a domain within the
.gov registrar, including contact details, authorizing official, security .gov registrar, including contact details, senior official, security
email, and DNS name servers. email, and DNS name servers.
</p> </p>

View file

@ -0,0 +1,3 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
{{ custom_email_content }}
{% endautoescape %}

View file

@ -9,18 +9,18 @@ STATUS: Action needed
---------------------------------------------------------------- ----------------------------------------------------------------
AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS
We've reviewed your domain request, but we need more information about the authorizing official listed on the request: We've reviewed your domain request, but we need more information about the senior official listed on the request:
- {{ domain_request.authorizing_official.get_formatted_name }} - {{ domain_request.senior_official.get_formatted_name }}
- {{ domain_request.authorizing_official.title }} - {{ domain_request.senior_official.title }}
We expect an authorizing official to be someone in a role of significant, executive responsibility within the organization. Our guidelines are open-ended to accommodate the wide variety of government organizations that are eligible for .gov domains, but the person you listed does not meet our expectations for your type of organization. Read more about our guidelines for authorizing officials. <https://get.gov/domains/eligibility/> We expect a senior official to be someone in a role of significant, executive responsibility within the organization. Our guidelines are open-ended to accommodate the wide variety of government organizations that are eligible for .gov domains, but the person you listed does not meet our expectations for your type of organization. Read more about our guidelines for senior officials. <https://get.gov/domains/eligibility/>
ACTION NEEDED ACTION NEEDED
Reply to this email with a justification for naming {{ domain_request.authorizing_official.get_formatted_name }} as the authorizing official. If you have questions or comments, include those in your reply. Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply.
Alternatively, you can log in to the registrar and enter a different authorizing official for this domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process. Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process.
THANK YOU THANK YOU

View file

@ -27,8 +27,8 @@ Organization name and mailing address:
About your organization: About your organization:
{% spaceless %}{{ domain_request.about_your_organization }}{% endspaceless %} {% spaceless %}{{ domain_request.about_your_organization }}{% endspaceless %}
{% endif %} {% endif %}
Authorizing official: Senior official:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.authorizing_official %}{% endspaceless %} {% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.senior_official %}{% endspaceless %}
{% if domain_request.current_websites.exists %}{# if block makes a newline #} {% if domain_request.current_websites.exists %}{# if block makes a newline #}
Current websites: {% for site in domain_request.current_websites.all %} Current websites: {% for site in domain_request.current_websites.all %}
{% spaceless %}{{ site.website }}{% endspaceless %} {% spaceless %}{{ site.website }}{% endspaceless %}

View file

@ -42,13 +42,11 @@ Your domain request was rejected because we determined that {{ domain_request.or
eligible for a .gov domain. .Gov domains are only available to official U.S.-based eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations. government organizations.
Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.
DEMONSTRATE ELIGIBILITY If you have questions or comments, reply to this email.
If you can provide documentation that demonstrates your eligibility, reply to this email. {% elif domain_request.rejection_reason == 'naming_not_met' %}
This can include links to (or copies of) your authorizing legislation, your founding
charter or bylaws, or other similar documentation. Without this, we cant approve a
.gov domain for your organization. Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.{% elif domain_request.rejection_reason == 'naming_not_met' %}
Your domain request was rejected because it does not meet our naming requirements. Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization general public. Learn more about naming requirements for your type of organization

View file

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<section class="section--outlined domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label> <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
<input <input
class="usa-input" class="usa-input"
id="domain-requests__search-field" id="domain-requests__search-field"
@ -33,7 +33,7 @@
</section> </section>
</div> </div>
</div> </div>
<div class="domain-requests__table-wrapper display-none"> <div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption> <caption class="sr-only">Your domain requests</caption>
<thead> <thead>
@ -58,7 +58,7 @@
<p>You haven't requested any domains.</p> <p>You haven't requested any domains.</p>
</div> </div>
<div class="domain-requests__no-search-results display-none"> <div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">

View file

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<section class="section--outlined domains"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="grid-row">
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button">
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domains__search-field">Search</label> <label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
<input <input
class="usa-input" class="usa-input"
id="domains__search-field" id="domains__search-field"
@ -33,7 +33,102 @@
</section> </section>
</div> </div>
</div> </div>
<div class="domains__table-wrapper display-none"> {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="domain__filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-dns-needed"
type="checkbox"
name="filter-status"
value="unknown"
/>
<label class="usa-checkbox__label" for="filter-status-dns-needed"
>DNS Needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ready"
type="checkbox"
name="filter-status"
value="ready"
/>
<label class="usa-checkbox__label" for="filter-status-ready"
>Ready</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-on-hold"
type="checkbox"
name="filter-status"
value="on hold"
/>
<label class="usa-checkbox__label" for="filter-status-on-hold"
>On hold</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-expired"
type="checkbox"
name="filter-status"
value="expired"
/>
<label class="usa-checkbox__label" for="filter-status-expired"
>Expired</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-deleted"
type="checkbox"
name="filter-status"
value="deleted"
/>
<label class="usa-checkbox__label" for="filter-status-deleted"
>Deleted</label
>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domains__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
{% endif %}
<div class="domains__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
<caption class="sr-only">Your registered domains</caption> <caption class="sr-only">Your registered domains</caption>
<thead> <thead>
@ -61,7 +156,7 @@
<div class="domains__no-data display-none"> <div class="domains__no-data display-none">
<p>You don't have any registered domains.</p> <p>You don't have any registered domains.</p>
<p class="maxw-none clearfix"> <p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank"> <a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use> <use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg> </svg>
@ -70,7 +165,7 @@
</p> </p>
</div> </div>
<div class="domains__no-search-results display-none"> <div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">

View file

@ -77,11 +77,11 @@
</fieldset> </fieldset>
<div> <div>
<button type="submit" name="contact_setup_save_button" class="usa-button "> <button type="submit" name="user_setup_save_button" class="usa-button ">
Save Save
</button> </button>
{% if user_finished_setup and going_to_specific_page %} {% if user_finished_setup and going_to_specific_page %}
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline"> <button type="submit" name="user_setup_submit_button" class="usa-button usa-button--outline">
{{redirect_button_text }} {{redirect_button_text }}
</button> </button>
{% endif %} {% endif %}

View file

@ -13,10 +13,10 @@
</h3> </h3>
<div class="usa-summary-box__text"> <div class="usa-summary-box__text">
<ul> <ul>
<li>Full name: <b>{{ user.contact.get_formatted_name }}</b></li> <li>Full name: <b>{{ user.get_formatted_name }}</b></li>
<li>Organization email: <b>{{ user.email }}</b></li> <li>Organization email: <b>{{ user.email }}</b></li>
<li>Title or role in your organization: <b>{{ user.contact.title }}</b></li> <li>Title or role in your organization: <b>{{ user.title }}</b></li>
<li>Phone: <b>{{ user.contact.phone.as_national }}</b></li> <li>Phone: <b>{{ user.phone.as_national }}</b></li>
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use> <use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %} {%endif %}
</svg> </svg>
<div class="display-inline padding-left-05 margin-left-3 readonly-field {% if not field.field.required %}text-base{% endif %}"> <div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %} {% if field.name != "phone" %}
{{ field.value }} {{ field.value }}
{% else %} {% else %}

View file

@ -112,8 +112,11 @@
<div class="text-right"> <div class="text-right">
<a <a
href="{{ edit_link }}" href="{{ edit_link }}"
class="usa-link font-sans-sm" class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
> >
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
</svg>
Edit<span class="sr-only"> {{ title }}</span> Edit<span class="sr-only"> {{ title }}</span>
</a> </a>
</div> </div>

View file

@ -3,6 +3,6 @@
{% load static %} {% load static %}
{% block portfolio_content %} {% block portfolio_content %}
<h1>Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %} {% include "includes/domains_table.html" with portfolio=portfolio %}
{% endblock %} {% endblock %}

View file

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% block portfolio_content %} {% block portfolio_content %}
<h1>Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>
{% comment %} {% comment %}
IMPORTANT: IMPORTANT:

View file

@ -3,7 +3,7 @@
<div class="margin-bottom-4 tablet:margin-bottom-0"> <div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label=""> <nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2> <h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav"> <ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %} {% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}> <a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>

View file

@ -389,7 +389,7 @@ class AuditedAdminMockData:
zipcode: str = "10002", zipcode: str = "10002",
about_your_organization: str = "e-Government", about_your_organization: str = "e-Government",
anything_else: str = "There is more", anything_else: str = "There is more",
authorizing_official: Contact = self.dummy_contact(item_name, "authorizing_official"), senior_official: Contact = self.dummy_contact(item_name, "senior_official"),
submitter: Contact = self.dummy_contact(item_name, "submitter"), submitter: Contact = self.dummy_contact(item_name, "submitter"),
creator: User = self.dummy_user(item_name, "creator"), creator: User = self.dummy_user(item_name, "creator"),
} }
@ -407,7 +407,7 @@ class AuditedAdminMockData:
zipcode="10002", zipcode="10002",
about_your_organization="e-Government", about_your_organization="e-Government",
anything_else="There is more", anything_else="There is more",
authorizing_official=self.dummy_contact(item_name, "authorizing_official"), senior_official=self.dummy_contact(item_name, "senior_official"),
submitter=self.dummy_contact(item_name, "submitter"), submitter=self.dummy_contact(item_name, "submitter"),
creator=creator, creator=creator,
) )
@ -810,6 +810,8 @@ def create_superuser():
user = User.objects.create_user( user = User.objects.create_user(
username="superuser", username="superuser",
email="admin@example.com", email="admin@example.com",
first_name="first",
last_name="last",
is_staff=True, is_staff=True,
password=p, password=p,
) )
@ -826,6 +828,8 @@ def create_user():
user = User.objects.create_user( user = User.objects.create_user(
username="staffuser", username="staffuser",
email="staff@example.com", email="staff@example.com",
first_name="first",
last_name="last",
is_staff=True, is_staff=True,
password=p, password=p,
) )
@ -864,7 +868,7 @@ def completed_domain_request( # noqa
"""A completed domain request.""" """A completed domain request."""
if not user: if not user:
user = get_user_model().objects.create(username="username" + str(uuid.uuid4())[:8]) user = get_user_model().objects.create(username="username" + str(uuid.uuid4())[:8])
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -908,7 +912,7 @@ def completed_domain_request( # noqa
address_line2="address 2", address_line2="address 2",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
requested_domain=domain, requested_domain=domain,
submitter=submitter, submitter=submitter,
creator=user, creator=user,
@ -1551,8 +1555,6 @@ class MockEppLib(TestCase):
def mockInfoDomainCommands(self, _request, cleaned): def mockInfoDomainCommands(self, _request, cleaned):
request_name = getattr(_request, "name", None).lower() request_name = getattr(_request, "name", None).lower()
print(request_name)
# Define a dictionary to map request names to data and extension values # Define a dictionary to map request names to data and extension values
request_mappings = { request_mappings = {
"security.gov": (self.infoDomainNoContact, None), "security.gov": (self.infoDomainNoContact, None),

View file

@ -44,6 +44,7 @@ from registrar.models import (
UserGroup, UserGroup,
TransitionDomain, TransitionDomain,
) )
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff from registrar.models.verified_by_staff import VerifiedByStaff
from .common import ( from .common import (
@ -242,15 +243,11 @@ class TestDomainAdmin(MockEppLib, WebTest):
username="MrMeoward", username="MrMeoward",
first_name="Meoward", first_name="Meoward",
last_name="Jones", last_name="Jones",
email="meoward.jones@igorville.gov",
phone="(555) 123 12345",
title="Treat inspector",
) )
# Due to the relation between User <==> Contact,
# the underlying contact has to be modified this way.
_creator.contact.email = "meoward.jones@igorville.gov"
_creator.contact.phone = "(555) 123 12345"
_creator.contact.title = "Treat inspector"
_creator.contact.save()
# Create a fake domain request # Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve() domain_request.approve()
@ -288,7 +285,7 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertContains(response, "(555) 555 5556") self.assertContains(response, "(555) 555 5556")
self.assertContains(response, "Testy2 Tester2") self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == # # == Check for the senior_official == #
self.assertContains(response, "testy@town.com") self.assertContains(response, "testy@town.com")
self.assertContains(response, "Chief Tester") self.assertContains(response, "Chief Tester")
self.assertContains(response, "(555) 555 5555") self.assertContains(response, "(555) 555 5555")
@ -374,9 +371,9 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Create a ready domain with a preset expiration date # Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# load expiration date into cache and registrar with below command
domain.registry_expiration_date
# Make sure the ex date is what we expect it to be # Make sure the ex date is what we expect it to be
domain_ex_date = Domain.objects.get(id=domain.id).expiration_date domain_ex_date = Domain.objects.get(id=domain.id).expiration_date
self.assertEqual(domain_ex_date, date(2023, 5, 25)) self.assertEqual(domain_ex_date, date(2023, 5, 25))
@ -400,7 +397,6 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name) self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date") self.assertContains(response, "Extend expiration date")
self.assertContains(response, "New expiration date: <b>May 25, 2025</b>")
# Ensure the message we recieve is in line with what we expect # Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date." expected_message = "Successfully extended the expiration date."
@ -519,70 +515,10 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Follow the response # Follow the response
response = response.follow() response = response.follow()
# This value is based off of the current year - the expiration date. # Assert that it is calling the function with the default extension length.
# We "freeze" time to 2024, so 2024 - 2023 will always result in an
# "extension" of 2, as that will be one year of extension from that date.
extension_length = 2
# Assert that it is calling the function with the right extension length.
# We only need to test the value that EPP sends, as we can assume the other # We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function. # test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False) renew_mock.assert_has_calls([call()], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
expected_call = call(
# The WGSI request doesn't need to be tested
ANY,
messages.INFO,
expected_message,
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2023, 1, 1))
def test_extend_expiration_date_button_date_matches_epp(self, mock_date_today):
"""
Tests if extend_expiration_date button sends the right epp command
when the current year matches the expiration date
"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Make sure that the page is loading as expected
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Grab the form to submit
form = response.forms["domain_form"]
with patch("django.contrib.messages.add_message") as mock_add_message:
with patch("registrar.models.Domain.renew_domain") as renew_mock:
# Submit the form
response = form.submit("_extend_expiration_date")
# Follow the response
response = response.follow()
extension_length = 1
# Assert that it is calling the function with the right extension length.
# We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
# We should not make duplicate calls # We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1) self.assertEqual(renew_mock.call_count, 1)
@ -996,6 +932,32 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.mock_client = MockSESClient() self.mock_client = MockSESClient()
def test_domain_request_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
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")
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
model_admin = AuditedAdmin(DomainRequest, self.site)
# Get the queryset that would be returned for the list
senior_offical_queryset = model_admin.formfield_for_foreignkey(
DomainInformation.senior_official.field, request
).queryset
# Make the list we're comparing on a bit prettier display-wise. Optional step.
current_sort_order = []
for official in senior_offical_queryset:
current_sort_order.append(f"{official.first_name} {official.last_name}")
expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
self.assertEqual(current_sort_order, expected_sort_order)
@less_console_noise_decorator @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
@ -1437,13 +1399,18 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "status in [submitted,in review,action needed]", count=1) self.assertContains(response, "status in [submitted,in review,action needed]", count=1)
@less_console_noise_decorator @less_console_noise_decorator
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None, action_needed_reason=None): def transition_state_and_send_email(
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None
):
"""Helper method for the email test cases.""" """Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Create a mock request # Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
# Create a fake session to hook to
request.session = {}
# Modify the domain request's properties # Modify the domain request's properties
domain_request.status = status domain_request.status = status
@ -1453,6 +1420,9 @@ class TestDomainRequestAdmin(MockEppLib):
if action_needed_reason: if action_needed_reason:
domain_request.action_needed_reason = action_needed_reason domain_request.action_needed_reason = action_needed_reason
if action_needed_reason_email:
domain_request.action_needed_reason_email = action_needed_reason_email
# Use the model admin's save_model method # Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True) self.admin.save_model(request, domain_request, form=None, change=True)
@ -1506,6 +1476,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Test the email sent out for already_has_domains # Test the email sent out for already_has_domains
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@ -1525,11 +1496,11 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test the email sent out for questionable_ao # Test that a custom email is sent out for questionable_so
questionable_ao = DomainRequest.ActionNeededReasons.QUESTIONABLE_AUTHORIZING_OFFICIAL questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_ao) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
self.assert_email_is_accurate( self.assert_email_is_accurate(
"AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
@ -1540,6 +1511,43 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before # Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
# Tests if an analyst can override existing email content
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
self.transition_state_and_send_email(
domain_request,
action_needed,
action_needed_reason=questionable_so,
action_needed_reason_email="custom email content",
)
domain_request.refresh_from_db()
self.assert_email_is_accurate("custom email content", 4, 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.
# An email should NOT be sent out if we just modify the email content.
self.transition_state_and_send_email(
domain_request,
action_needed,
action_needed_reason=questionable_so,
action_needed_reason_email="dummy email content",
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
# Set the request back to in review
domain_request.in_review()
# Try sending another email when changing states AND including content
self.transition_state_and_send_email(
domain_request,
action_needed,
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.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
def test_save_model_sends_submitted_email(self): def test_save_model_sends_submitted_email(self):
"""When transitioning to submitted from started or withdrawn on a domain request, """When transitioning to submitted from started or withdrawn on a domain request,
an email is sent out. an email is sent out.
@ -2128,15 +2136,11 @@ class TestDomainRequestAdmin(MockEppLib):
username="MrMeoward", username="MrMeoward",
first_name="Meoward", first_name="Meoward",
last_name="Jones", last_name="Jones",
email="meoward.jones@igorville.gov",
phone="(555) 123 12345",
title="Treat inspector",
) )
# Due to the relation between User <==> Contact,
# the underlying contact has to be modified this way.
_creator.contact.email = "meoward.jones@igorville.gov"
_creator.contact.phone = "(555) 123 12345"
_creator.contact.title = "Treat inspector"
_creator.contact.save()
# Create a fake domain request # Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
@ -2153,11 +2157,11 @@ class TestDomainRequestAdmin(MockEppLib):
# == Check for the creator == # # == Check for the creator == #
# Check for the right title, email, and phone number in the response. # Check for the right title and phone number in the response.
# Email will appear more than once
expected_creator_fields = [ expected_creator_fields = [
# Field, expected value # Field, expected value
("title", "Treat inspector"), ("title", "Treat inspector"),
("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"), ("phone", "(555) 123 12345"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields)
@ -2174,17 +2178,17 @@ class TestDomainRequestAdmin(MockEppLib):
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2") self.assertContains(response, "Testy2 Tester2")
self.assertContains(response, "meoward.jones@igorville.gov")
# == Check for the authorizing_official == # # == Check for the senior_official == #
self.assertContains(response, "testy@town.com", count=2) self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [ expected_so_fields = [
# Field, expected value # Field, expected value
("phone", "(555) 555 5555"), ("phone", "(555) 555 5555"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
self.assertContains(response, "Chief Tester")
self.assertContains(response, "Testy Tester") self.test_helper.assert_response_contains_distinct_values(response, expected_so_fields)
self.assertContains(response, "Chief Tester")
# == Test the other_employees field == # # == Test the other_employees field == #
self.assertContains(response, "testy2@town.com") self.assertContains(response, "testy2@town.com")
@ -2284,8 +2288,8 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "When a domain request is in ineligible status") self.assertContains(response, "When a domain request is in ineligible status")
self.assertContains(response, "Yes, select ineligible status") self.assertContains(response, "Yes, select ineligible status")
@less_console_noise_decorator
def test_readonly_when_restricted_creator(self): def test_readonly_when_restricted_creator(self):
with less_console_noise():
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
domain_request.creator.status = User.RESTRICTED domain_request.creator.status = User.RESTRICTED
@ -2302,6 +2306,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"id", "id",
"created_at", "created_at",
"updated_at", "updated_at",
@ -2311,6 +2316,7 @@ class TestDomainRequestAdmin(MockEppLib):
"action_needed_reason_email", "action_needed_reason_email",
"federal_agency", "federal_agency",
"portfolio", "portfolio",
"sub_organization",
"creator", "creator",
"investigator", "investigator",
"generic_org_type", "generic_org_type",
@ -2328,7 +2334,7 @@ class TestDomainRequestAdmin(MockEppLib):
"zipcode", "zipcode",
"urbanization", "urbanization",
"about_your_organization", "about_your_organization",
"authorizing_official", "senior_official",
"approved_domain", "approved_domain",
"requested_domain", "requested_domain",
"submitter", "submitter",
@ -2361,6 +2367,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",
@ -2391,6 +2398,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -2460,6 +2468,8 @@ class TestDomainRequestAdmin(MockEppLib):
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
request.user = self.superuser request.user = self.superuser
request.session = {}
# Define a custom implementation for is_active # Define a custom implementation for is_active
def custom_is_active(self): def custom_is_active(self):
return domain_is_active # Override to return True return domain_is_active # Override to return True
@ -2787,6 +2797,7 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete() User.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
Website.objects.all().delete() Website.objects.all().delete()
SeniorOfficial.objects.all().delete()
self.mock_client.EMAILS_SENT.clear() self.mock_client.EMAILS_SENT.clear()
@ -2968,6 +2979,38 @@ class TestDomainInformationAdmin(TestCase):
Domain.objects.all().delete() Domain.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
SeniorOfficial.objects.all().delete()
def test_domain_information_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
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="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
)
domain_request.approve()
domain_info = DomainInformation.objects.get(domain_request=domain_request)
request = self.factory.post("/admin/registrar/domaininformation/{}/change/".format(domain_info.pk))
model_admin = AuditedAdmin(DomainInformation, self.site)
# Get the queryset that would be returned for the list
senior_offical_queryset = model_admin.formfield_for_foreignkey(
DomainInformation.senior_official.field, request
).queryset
# Make the list we're comparing on a bit prettier display-wise. Optional step.
current_sort_order = []
for official in senior_offical_queryset:
current_sort_order.append(f"{official.first_name} {official.last_name}")
expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
self.assertEqual(current_sort_order, expected_sort_order)
@less_console_noise_decorator @less_console_noise_decorator
def test_admin_can_see_cisa_region_federal(self): def test_admin_can_see_cisa_region_federal(self):
@ -3164,15 +3207,11 @@ class TestDomainInformationAdmin(TestCase):
username="MrMeoward", username="MrMeoward",
first_name="Meoward", first_name="Meoward",
last_name="Jones", last_name="Jones",
email="meoward.jones@igorville.gov",
phone="(555) 123 12345",
title="Treat inspector",
) )
# Due to the relation between User <==> Contact,
# the underlying contact has to be modified this way.
_creator.contact.email = "meoward.jones@igorville.gov"
_creator.contact.phone = "(555) 123 12345"
_creator.contact.title = "Treat inspector"
_creator.contact.save()
# Create a fake domain request # Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve() domain_request.approve()
@ -3194,16 +3233,16 @@ class TestDomainInformationAdmin(TestCase):
# == Check for the creator == # # == Check for the creator == #
# Check for the right title, email, and phone number in the response. # Check for the right title and phone number in the response.
# We only need to check for the end tag # We only need to check for the end tag
# (Otherwise this test will fail if we change classes, etc) # (Otherwise this test will fail if we change classes, etc)
expected_creator_fields = [ expected_creator_fields = [
# Field, expected value # Field, expected value
("title", "Treat inspector"), ("title", "Treat inspector"),
("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"), ("phone", "(555) 123 12345"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields)
self.assertContains(response, "meoward.jones@igorville.gov")
# Check for the field itself # Check for the field itself
self.assertContains(response, "Meoward Jones") self.assertContains(response, "Meoward Jones")
@ -3218,14 +3257,14 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2") self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == # # == Check for the senior_official == #
self.assertContains(response, "testy@town.com", count=2) self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [ expected_so_fields = [
# Field, expected value # Field, expected value
("title", "Chief Tester"), ("title", "Chief Tester"),
("phone", "(555) 555 5555"), ("phone", "(555) 555 5555"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_so_fields)
self.assertContains(response, "Testy Tester", count=10) self.assertContains(response, "Testy Tester", count=10)
@ -3619,7 +3658,7 @@ class TestMyUserAdmin(MockDb):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("Permissions", {"fields": ("is_active", "groups")}), ("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )
@ -3722,6 +3761,7 @@ class AuditedAdminTest(TestCase):
self.site = AdminSite() self.site = AdminSite()
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.staffuser = create_user()
def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names): def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names):
with less_console_noise(): with less_console_noise():
@ -3773,7 +3813,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_request(self): def test_alphabetically_sorted_fk_fields_domain_request(self):
with less_console_noise(): with less_console_noise():
tested_fields = [ tested_fields = [
DomainRequest.authorizing_official.field, # 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.submitter.field,
# DomainRequest.investigator.field, # DomainRequest.investigator.field,
DomainRequest.creator.field, DomainRequest.creator.field,
@ -3831,7 +3873,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_information(self): def test_alphabetically_sorted_fk_fields_domain_information(self):
with less_console_noise(): with less_console_noise():
tested_fields = [ tested_fields = [
DomainInformation.authorizing_official.field, # 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.submitter.field,
# DomainInformation.creator.field, # DomainInformation.creator.field,
(DomainInformation.domain.field, ["name"]), (DomainInformation.domain.field, ["name"]),
@ -3864,7 +3908,6 @@ class AuditedAdminTest(TestCase):
# Conforms to the same object structure as desired_order # Conforms to the same object structure as desired_order
current_sort_order_coerced_type = [] current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset # This is necessary as .queryset and get_queryset
# return lists of different types/structures. # return lists of different types/structures.
# We need to parse this data and coerce them into the same type. # We need to parse this data and coerce them into the same type.
@ -3941,7 +3984,8 @@ class AuditedAdminTest(TestCase):
if last_name is None: if last_name is None:
return (first_name,) return (first_name,)
if first_name.split(queryset_shorthand)[1] == field_name: split_name = first_name.split(queryset_shorthand)
if len(split_name) == 2 and split_name[1] == field_name:
return returned_tuple return returned_tuple
else: else:
return None return None
@ -4110,9 +4154,7 @@ class TestContactAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [ expected_fields = ["email"]
"user",
]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -4128,15 +4170,18 @@ class TestContactAdmin(TestCase):
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
def test_change_view_for_joined_contact_five_or_less(self): def test_change_view_for_joined_contact_five_or_less(self):
"""Create a contact, join it to 4 domain requests. The 5th join will be a user. """Create a contact, join it to 4 domain requests.
Assert that the warning on the contact form lists 5 joins.""" Assert that the warning on the contact form lists 4 joins."""
with less_console_noise(): with less_console_noise():
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
# Create an instance of the model # Create an instance of the model
contact, _ = Contact.objects.get_or_create(user=self.staffuser) contact, _ = Contact.objects.get_or_create(
first_name="Henry",
last_name="McFakerson",
)
# join it to 4 domain requests. The 5th join will be a user. # join it to 4 domain requests.
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov") domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov") domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov") domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
@ -4159,24 +4204,26 @@ class TestContactAdmin(TestCase):
f"domainrequest/{domain_request3.pk}/change/'>city3.gov</a></li>" f"domainrequest/{domain_request3.pk}/change/'>city3.gov</a></li>"
"<li>Joined to DomainRequest: <a href='/admin/registrar/" "<li>Joined to DomainRequest: <a href='/admin/registrar/"
f"domainrequest/{domain_request4.pk}/change/'>city4.gov</a></li>" f"domainrequest/{domain_request4.pk}/change/'>city4.gov</a></li>"
"<li>Joined to User: <a href='/admin/registrar/"
f"user/{self.staffuser.pk}/change/'>staff@example.com</a></li>"
"</ul>", "</ul>",
) )
def test_change_view_for_joined_contact_five_or_more(self): def test_change_view_for_joined_contact_five_or_more(self):
"""Create a contact, join it to 5 domain requests. The 6th join will be a user. """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.""" Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis."""
with less_console_noise(): with less_console_noise():
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
# Create an instance of the model # Create an instance of the model
# join it to 5 domain requests. The 6th join will be a user. # join it to 6 domain requests.
contact, _ = Contact.objects.get_or_create(user=self.staffuser) contact, _ = Contact.objects.get_or_create(
first_name="Henry",
last_name="McFakerson",
)
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov") domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov") domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov") domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
domain_request4 = completed_domain_request(submitter=contact, name="city4.gov") domain_request4 = completed_domain_request(submitter=contact, name="city4.gov")
domain_request5 = completed_domain_request(submitter=contact, name="city5.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: with patch("django.contrib.messages.warning") as mock_warning:
# Use the test client to simulate the request # Use the test client to simulate the request
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk])) response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))

View file

@ -1,124 +0,0 @@
from django.test import TestCase
from registrar.models import (
User,
Contact,
)
from registrar.management.commands.copy_names_from_contacts_to_users import Command
class TestDataUpdates(TestCase):
def setUp(self):
"""We cannot setup the user details because contacts will override the first and last names in its save method
so we will initiate the users, setup the contacts and link them, and leave the rest of the setup to the test(s).
"""
self.user1 = User.objects.create(username="user1")
self.user2 = User.objects.create(username="user2")
self.user3 = User.objects.create(username="user3")
self.user4 = User.objects.create(username="user4")
# The last user created triggers the creation of a contact and attaches itself to it. See signals.
# This bs_user defuses that situation.
self.bs_user = User.objects.create()
self.contact1 = Contact.objects.create(
user=self.user1,
email="email1@igorville.gov",
first_name="first1",
last_name="last1",
middle_name="middle1",
title="title1",
)
self.contact2 = Contact.objects.create(
user=self.user2,
email="email2@igorville.gov",
first_name="first2",
last_name="last2",
middle_name="middle2",
title="title2",
)
self.contact3 = Contact.objects.create(
user=self.user3,
email="email3@igorville.gov",
first_name="first3",
last_name="last3",
middle_name="middle3",
title="title3",
)
self.contact4 = Contact.objects.create(
email="email4@igorville.gov", first_name="first4", last_name="last4", middle_name="middle4", title="title4"
)
self.command = Command()
def tearDown(self):
"""Clean up"""
# Delete users and contacts
User.objects.all().delete()
Contact.objects.all().delete()
def test_script_updates_linked_users(self):
"""Test the script that copies contact information to the user object"""
# Set up the users' first and last names here so
# they that they don't get overwritten by Contact's save()
# User with no first or last names
self.user1.first_name = ""
self.user1.last_name = ""
self.user1.title = "dummytitle"
self.user1.middle_name = "dummymiddle"
self.user1.save()
# User with a first name but no last name
self.user2.first_name = "First name but no last name"
self.user2.last_name = ""
self.user2.save()
# User with a first and last name
self.user3.first_name = "An existing first name"
self.user3.last_name = "An existing last name"
self.user3.save()
# Call the parent method the same way we do it in the script
skipped_contacts = []
eligible_users = []
processed_users = []
(
skipped_contacts,
eligible_users,
processed_users,
) = self.command.process_contacts(
# Set debugging to False
False,
skipped_contacts,
eligible_users,
processed_users,
)
# Trigger DB refresh
self.user1.refresh_from_db()
self.user2.refresh_from_db()
self.user3.refresh_from_db()
# Asserts
# The user that has no first and last names will get them from the contact
self.assertEqual(self.user1.first_name, "first1")
self.assertEqual(self.user1.last_name, "last1")
self.assertEqual(self.user1.middle_name, "middle1")
self.assertEqual(self.user1.title, "title1")
# The user that has a first but no last will be updated
self.assertEqual(self.user2.first_name, "first2")
self.assertEqual(self.user2.last_name, "last2")
self.assertEqual(self.user2.middle_name, "middle2")
self.assertEqual(self.user2.title, "title2")
# The user that has a first and a last will be updated
self.assertEqual(self.user3.first_name, "first3")
self.assertEqual(self.user3.last_name, "last3")
self.assertEqual(self.user3.middle_name, "middle3")
self.assertEqual(self.user3.title, "title3")
# The unlinked user will be left alone
self.assertEqual(self.user4.first_name, "")
self.assertEqual(self.user4.last_name, "")
self.assertEqual(self.user4.middle_name, None)
self.assertEqual(self.user4.title, None)

View file

@ -59,7 +59,7 @@ class TestEmails(TestCase):
self.assertIn("Type of organization:", body) self.assertIn("Type of organization:", body)
self.assertIn("Federal", body) self.assertIn("Federal", body)
self.assertIn("Authorizing official:", body) self.assertIn("Senior official:", body)
self.assertIn("Testy Tester", body) self.assertIn("Testy Tester", body)
self.assertIn(".gov domain:", body) self.assertIn(".gov domain:", body)
self.assertIn("city.gov", body) self.assertIn("city.gov", body)
@ -177,7 +177,7 @@ class TestEmails(TestCase):
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("About your organization:", body) self.assertNotIn("About your organization:", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"10002\n\nAuthorizing official:") self.assertRegex(body, r"10002\n\nSenior official:")
@boto3_mocking.patching @boto3_mocking.patching
def test_submission_confirmation_anything_else_spacing(self): def test_submission_confirmation_anything_else_spacing(self):

View file

@ -8,7 +8,7 @@ from registrar.forms.domain_request_wizard import (
AlternativeDomainForm, AlternativeDomainForm,
CurrentSitesForm, CurrentSitesForm,
DotGovDomainForm, DotGovDomainForm,
AuthorizingOfficialForm, SeniorOfficialForm,
OrganizationContactForm, OrganizationContactForm,
YourContactForm, YourContactForm,
OtherContactsForm, OtherContactsForm,
@ -217,9 +217,9 @@ class TestFormValidation(MockEppLib):
["Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens)."], ["Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens)."],
) )
def test_authorizing_official_email_invalid(self): def test_senior_official_email_invalid(self):
"""must be a valid email address.""" """must be a valid email address."""
form = AuthorizingOfficialForm(data={"email": "boss@boss"}) form = SeniorOfficialForm(data={"email": "boss@boss"})
self.assertEqual( self.assertEqual(
form.errors["email"], form.errors["email"],
["Enter an email address in the required format, like name@example.com."], ["Enter an email address in the required format, like name@example.com."],

View file

@ -1077,11 +1077,6 @@ class TestImportTables(TestCase):
for table_name in table_names: for table_name in table_names:
mock_path_exists.assert_any_call(f"{table_name}_1.csv") mock_path_exists.assert_any_call(f"{table_name}_1.csv")
# Check that clean_tables is called for Contact
mock_get_model.assert_any_call("registrar", "Contact")
model_mock = mock_get_model.return_value
model_mock.objects.all().delete.assert_called()
# Check that logger.info was called for each successful import # Check that logger.info was called for each successful import
for table_name in table_names: for table_name in table_names:
mock_logger.info.assert_any_call(f"Successfully imported {table_name}_1.csv into {table_name}") mock_logger.info.assert_any_call(f"Successfully imported {table_name}_1.csv into {table_name}")

View file

@ -151,7 +151,7 @@ class TestDomainRequest(TestCase):
address_line2="APT 1A", address_line2="APT 1A",
state_territory="CA", state_territory="CA",
zipcode="12345-6789", zipcode="12345-6789",
authorizing_official=contact, senior_official=contact,
requested_domain=domain, requested_domain=domain,
submitter=contact, submitter=contact,
purpose="Igorville rules!", purpose="Igorville rules!",
@ -179,7 +179,7 @@ class TestDomainRequest(TestCase):
address_line2="APT 1A", address_line2="APT 1A",
state_territory="CA", state_territory="CA",
zipcode="12345-6789", zipcode="12345-6789",
authorizing_official=contact, senior_official=contact,
submitter=contact, submitter=contact,
purpose="Igorville rules!", purpose="Igorville rules!",
anything_else="All of Igorville loves the dotgov program.", anything_else="All of Igorville loves the dotgov program.",
@ -1209,32 +1209,25 @@ class TestUser(TestCase):
# test with a user with contact info defined # test with a user with contact info defined
self.assertTrue(self.user.has_contact_info()) self.assertTrue(self.user.has_contact_info())
# test with a user without contact info defined # test with a user without contact info defined
self.user.contact.title = None self.user.title = None
self.user.contact.email = None self.user.email = None
self.user.contact.phone = None self.user.phone = None
self.assertFalse(self.user.has_contact_info()) self.assertFalse(self.user.has_contact_info())
class TestContact(TestCase): class TestContact(TestCase):
def setUp(self): def setUp(self):
self.email_for_invalid = "intern@igorville.gov"
self.invalid_user, _ = User.objects.get_or_create(
username=self.email_for_invalid,
email=self.email_for_invalid,
first_name="",
last_name="",
phone="",
)
self.invalid_contact, _ = Contact.objects.get_or_create(user=self.invalid_user)
self.email = "mayor@igorville.gov" self.email = "mayor@igorville.gov"
self.user, _ = User.objects.get_or_create( self.user, _ = User.objects.get_or_create(
email=self.email, first_name="Jeff", last_name="Lebowski", phone="123456789" email=self.email, first_name="Jeff", last_name="Lebowski", phone="123456789"
) )
self.contact, _ = Contact.objects.get_or_create(user=self.user) self.contact, _ = Contact.objects.get_or_create(
first_name="Jeff",
last_name="Lebowski",
)
self.contact_as_ao, _ = Contact.objects.get_or_create(email="newguy@igorville.gov") self.contact_as_so, _ = Contact.objects.get_or_create(email="newguy@igorville.gov")
self.domain_request = DomainRequest.objects.create(creator=self.user, authorizing_official=self.contact_as_ao) self.domain_request = DomainRequest.objects.create(creator=self.user, senior_official=self.contact_as_so)
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -1242,98 +1235,15 @@ class TestContact(TestCase):
Contact.objects.all().delete() Contact.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
def test_saving_contact_updates_user_first_last_names_and_phone(self):
"""When a contact is updated, we propagate the changes to the linked user if it exists."""
# User and Contact are created and linked as expected.
# An empty User object should create an empty contact.
self.assertEqual(self.invalid_contact.first_name, "")
self.assertEqual(self.invalid_contact.last_name, "")
self.assertEqual(self.invalid_contact.phone, "")
self.assertEqual(self.invalid_user.first_name, "")
self.assertEqual(self.invalid_user.last_name, "")
self.assertEqual(self.invalid_user.phone, "")
# Manually update the contact - mimicking production (pre-existing data)
self.invalid_contact.first_name = "Joey"
self.invalid_contact.last_name = "Baloney"
self.invalid_contact.phone = "123456789"
self.invalid_contact.save()
# Refresh the user object to reflect the changes made in the database
self.invalid_user.refresh_from_db()
# Updating the contact's first and last names propagate to the user
self.assertEqual(self.invalid_contact.first_name, "Joey")
self.assertEqual(self.invalid_contact.last_name, "Baloney")
self.assertEqual(self.invalid_contact.phone, "123456789")
self.assertEqual(self.invalid_user.first_name, "Joey")
self.assertEqual(self.invalid_user.last_name, "Baloney")
self.assertEqual(self.invalid_user.phone, "123456789")
def test_saving_contact_does_not_update_user_first_last_names_and_phone(self):
"""When a contact is updated, we avoid propagating the changes to the linked user if it already has a value"""
# User and Contact are created and linked as expected
self.assertEqual(self.contact.first_name, "Jeff")
self.assertEqual(self.contact.last_name, "Lebowski")
self.assertEqual(self.contact.phone, "123456789")
self.assertEqual(self.user.first_name, "Jeff")
self.assertEqual(self.user.last_name, "Lebowski")
self.assertEqual(self.user.phone, "123456789")
self.contact.first_name = "Joey"
self.contact.last_name = "Baloney"
self.contact.phone = "987654321"
self.contact.save()
# Refresh the user object to reflect the changes made in the database
self.user.refresh_from_db()
# Updating the contact's first and last names propagate to the user
self.assertEqual(self.contact.first_name, "Joey")
self.assertEqual(self.contact.last_name, "Baloney")
self.assertEqual(self.contact.phone, "987654321")
self.assertEqual(self.user.first_name, "Jeff")
self.assertEqual(self.user.last_name, "Lebowski")
self.assertEqual(self.user.phone, "123456789")
def test_saving_contact_does_not_update_user_email(self):
"""When a contact's email is updated, the change is not propagated to the user."""
self.contact.email = "joey.baloney@diaperville.com"
self.contact.save()
# Refresh the user object to reflect the changes made in the database
self.user.refresh_from_db()
# Updating the contact's email does not propagate
self.assertEqual(self.contact.email, "joey.baloney@diaperville.com")
self.assertEqual(self.user.email, "mayor@igorville.gov")
def test_saving_contact_does_not_update_user_email_when_none(self):
"""When a contact's email is updated, and the first/last name is none,
the change is not propagated to the user."""
self.invalid_contact.email = "joey.baloney@diaperville.com"
self.invalid_contact.save()
# Refresh the user object to reflect the changes made in the database
self.invalid_user.refresh_from_db()
# Updating the contact's email does not propagate
self.assertEqual(self.invalid_contact.email, "joey.baloney@diaperville.com")
self.assertEqual(self.invalid_user.email, "intern@igorville.gov")
def test_has_more_than_one_join(self): def test_has_more_than_one_join(self):
"""Test the Contact model method, has_more_than_one_join""" """Test the Contact model method, has_more_than_one_join"""
# test for a contact which has one user defined # test for a contact which is assigned as a senior official on a domain request
self.assertFalse(self.contact.has_more_than_one_join("user")) self.assertFalse(self.contact_as_so.has_more_than_one_join("senior_official"))
self.assertTrue(self.contact.has_more_than_one_join("authorizing_official")) self.assertTrue(self.contact_as_so.has_more_than_one_join("submitted_domain_requests"))
# test for a contact which is assigned as an authorizing official on a domain request
self.assertFalse(self.contact_as_ao.has_more_than_one_join("authorizing_official"))
self.assertTrue(self.contact_as_ao.has_more_than_one_join("submitted_domain_requests"))
def test_has_contact_info(self): def test_has_contact_info(self):
"""Test that has_contact_info properly returns""" """Test that has_contact_info properly returns"""
self.contact.title = "Title"
# test with a contact with contact info defined # test with a contact with contact info defined
self.assertTrue(self.contact.has_contact_info()) self.assertTrue(self.contact.has_contact_info())
# test with a contact without contact info defined # test with a contact without contact info defined
@ -1660,7 +1570,7 @@ class TestDomainRequestIncomplete(TestCase):
self.user = get_user_model().objects.create( self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email username=username, first_name=first_name, last_name=last_name, email=email
) )
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Meowy", first_name="Meowy",
last_name="Meoward", last_name="Meoward",
title="Chief Cat", title="Chief Cat",
@ -1695,7 +1605,7 @@ class TestDomainRequestIncomplete(TestCase):
address_line1="address 1", address_line1="address 1",
state_territory="CA", state_territory="CA",
zipcode="94044", zipcode="94044",
authorizing_official=ao, senior_official=so,
requested_domain=draft_domain, requested_domain=draft_domain,
purpose="Some purpose", purpose="Some purpose",
submitter=you, submitter=you,
@ -1793,11 +1703,11 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.save() self.domain_request.save()
self.assertTrue(self.domain_request._is_organization_name_and_address_complete()) self.assertTrue(self.domain_request._is_organization_name_and_address_complete())
def test_is_authorizing_official_complete(self): def test_is_senior_official_complete(self):
self.assertTrue(self.domain_request._is_authorizing_official_complete()) self.assertTrue(self.domain_request._is_senior_official_complete())
self.domain_request.authorizing_official = None self.domain_request.senior_official = None
self.domain_request.save() self.domain_request.save()
self.assertFalse(self.domain_request._is_authorizing_official_complete()) self.assertFalse(self.domain_request._is_senior_official_complete())
def test_is_requested_domain_complete(self): def test_is_requested_domain_complete(self):
self.assertTrue(self.domain_request._is_requested_domain_complete()) self.assertTrue(self.domain_request._is_requested_domain_complete())

View file

@ -1,21 +1,23 @@
import csv
import io import io
from django.test import Client, RequestFactory from django.test import Client, RequestFactory
from io import StringIO from io import StringIO
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.utility.csv_export import ( from registrar.utility.csv_export import (
export_data_managed_domains_to_csv, DomainDataFull,
export_data_unmanaged_domains_to_csv, DomainDataType,
get_sliced_domains, DomainDataFederal,
get_sliced_requests, DomainGrowth,
write_csv_for_domains, DomainManaged,
DomainUnmanaged,
DomainExport,
DomainRequestExport,
DomainRequestGrowth,
DomainRequestDataFull,
get_default_start_date, get_default_start_date,
get_default_end_date, get_default_end_date,
DomainRequestExport,
) )
from django.db.models import Case, When
from django.core.management import call_command from django.core.management import call_command
from unittest.mock import MagicMock, call, mock_open, patch from unittest.mock import MagicMock, call, mock_open, patch
from api.views import get_current_federal, get_current_full from api.views import get_current_federal, get_current_full
@ -45,10 +47,10 @@ class CsvReportsTest(MockDb):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
] ]
# We don't actually want to write anything for a test case, # We don't actually want to write anything for a test case,
# we just want to verify what is being written. # we just want to verify what is being written.
@ -67,11 +69,12 @@ class CsvReportsTest(MockDb):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("adomain2.gov,Interstate,,,,, \r\n"), call("adomain2.gov,Interstate,,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
] ]
# We don't actually want to write anything for a test case, # We don't actually want to write anything for a test case,
# we just want to verify what is being written. # we just want to verify what is being written.
@ -202,11 +205,10 @@ class ExportDataTest(MockDb, MockEppLib):
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
def test_export_domains_to_writer_security_emails_and_first_ready(self): @less_console_noise_decorator
"""Test that export_domains_to_writer returns the def test_domain_data_type(self):
expected security email and first_ready value""" """Shows security contacts, domain managers, so"""
self.maxDiff = None
with less_console_noise():
# Add security email information # Add security email information
self.domain_1.name = "defaultsecurity.gov" self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save() self.domain_1.save()
@ -216,48 +218,13 @@ class ExportDataTest(MockDb, MockEppLib):
self.domain_2.security_contact self.domain_2.security_contact
# Invoke setter # Invoke setter
self.domain_3.security_contact self.domain_3.security_contact
# Add a first ready date on the first domain. Leaving the others blank. # Add a first ready date on the first domain. Leaving the others blank.
self.domain_1.first_ready = get_default_start_date() self.domain_1.first_ready = get_default_start_date()
self.domain_1.save() self.domain_1.save()
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"AO",
"AO email",
"Security contact email",
"Status",
"Expiration date",
"First ready on",
]
sort_fields = ["domain__name"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export functions # Call the export functions
write_csv_for_domains( DomainDataType.export_data_to_csv(csv_file)
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
@ -265,66 +232,53 @@ class ExportDataTest(MockDb, MockEppLib):
# We expect READY domains, # We expect READY domains,
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,AO," "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO,"
"AO email,Security contact email,Status,Expiration date, First ready on\n" "SO email,Security contact email,Domain managers,Invited domain managers\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,Ready,(blank),2024-04-03\n" "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,,"
"adomain2.gov,Interstate,(blank),Dns needed,(blank),(blank)\n" "meoward@rocks.com,\n"
"cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank),2024-04-02\n" "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,"
"ddomain3.gov,Federal,Armed Forces Retirement Home,security@mail.gov,On hold,2023-11-15,(blank)\n" ',,,(blank),"meoward@rocks.com, info@example.com, big_lebowski@dude.co",'
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission," "woofwardthethird@rocks.com\n"
"(blank),Ready,(blank),2023-11-01\n" "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,"
"zdomain12.govInterstateReady,(blank),2024-04-02\n" "squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,,"
"security@mail.gov,,\n"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,,"
"meoward@rocks.com,squeaker@rocks.com\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_write_csv_for_domains(self): @less_console_noise_decorator
"""Test that write_body returns the def test_domain_data_full(self):
existing domain, test that sort by domain name works, """Shows security contacts, filtered by state"""
test that filter works""" # Add security email information
self.domain_1.name = "defaultsecurity.gov"
with less_console_noise(): self.domain_1.save()
# Invoke setter
self.domain_1.security_contact
# Invoke setter
self.domain_2.security_contact
# Invoke setter
self.domain_3.security_contact
# Add a first ready date on the first domain. Leaving the others blank.
self.domain_1.first_ready = get_default_start_date()
self.domain_1.save()
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"AO",
"AO email",
"Submitter",
"Submitter title",
"Submitter email",
"Submitter phone",
"Security contact email",
"Status",
]
sort_fields = ["domain__name"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export functions # Call the export functions
write_csv_for_domains( DomainDataFull.export_data_to_csv(csv_file)
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
@ -332,96 +286,64 @@ class ExportDataTest(MockDb, MockEppLib):
# We expect READY domains, # We expect READY domains,
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,AO," "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"AO email,Submitter,Submitter title,Submitter email,Submitter phone," "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"Security contact email,Status\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"adomain2.gov,Interstate,Dns needed\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady\n" "adomain2.gov,Interstate,,,,,(blank)\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,Ready\n" "zdomain12.gov,Interstate,,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,On hold\n"
"zdomain12.govInterstateReady\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_write_domains_body_additional(self): @less_console_noise_decorator
"""An additional test for filters and multi-column sort""" def test_domain_data_federal(self):
"""Shows security contacts, filtered by state and org type"""
with less_console_noise(): # Add security email information
self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save()
# Invoke setter
self.domain_1.security_contact
# Invoke setter
self.domain_2.security_contact
# Invoke setter
self.domain_3.security_contact
# Add a first ready date on the first domain. Leaving the others blank.
self.domain_1.first_ready = get_default_start_date()
self.domain_1.save()
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Security contact email",
]
sort_fields = ["domain__name", "federal_agency", "generic_org_type"]
filter_condition = {
"generic_org_type__icontains": "federal",
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export functions # Call the export functions
write_csv_for_domains( DomainDataFederal.export_data_to_csv(csv_file)
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
# We expect READY domains, # We expect READY domains,
# federal only
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City," "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"State,Security contact email\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"cdomain11.govFederal-ExecutiveWorldWarICentennialCommission\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_write_domains_body_with_date_filter_pulls_domains_in_range(self): @less_console_noise_decorator
"""Test that domains that are def test_domain_growth(self):
1. READY and their first_ready dates are in range """Shows ready and deleted domains within a date range, sorted"""
2. DELETED and their deleted dates are in range # Remove "Created at" and "First ready" because we can't guess this immutable, dynamically generated test data
are pulled when the growth report conditions are applied to export_domains_to_writed.
Test that ready domains are sorted by first_ready/deleted dates first, names second.
We considered testing export_data_domain_growth_to_csv which calls write_body
and would have been easy to set up, but expected_content would contain created_at dates
which are hard to mock.
TODO: Simplify if created_at is not needed for the report."""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [ columns = [
"Domain name", "Domain name",
"Domain type", "Domain type",
@ -431,164 +353,72 @@ class ExportDataTest(MockDb, MockEppLib):
"State", "State",
"Status", "Status",
"Expiration date", "Expiration date",
# "Created at",
# "First ready",
"Deleted",
] ]
sort_fields = [ sort = {
"created_at", "custom_sort": Case(
"domain__name", When(domain__state=Domain.State.READY, then="domain__created_at"),
] When(domain__state=Domain.State.DELETED, then="domain__deleted"),
sort_fields_for_deleted_domains = [
"domain__deleted",
"domain__name",
]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
],
"domain__first_ready__lte": self.end_date,
"domain__first_ready__gte": self.start_date,
}
filter_conditions_for_deleted_domains = {
"domain__state__in": [
Domain.State.DELETED,
],
"domain__deleted__lte": self.end_date,
"domain__deleted__gte": self.start_date,
}
# Call the export functions
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
write_csv_for_domains( }
writer, with patch("registrar.utility.csv_export.DomainGrowth.get_columns", return_value=columns):
columns, with patch("registrar.utility.csv_export.DomainGrowth.get_annotations_for_sort", return_value=sort):
sort_fields_for_deleted_domains, # Create a CSV file in memory
filter_conditions_for_deleted_domains, csv_file = StringIO()
should_get_domain_managers=False, # Call the export functions
should_write_header=False, DomainGrowth.export_data_to_csv(
csv_file,
self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"),
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
# We expect READY domains first, created between day-2 and day+2, sorted by created_at then name # We expect READY domains first, created between day-2 and day+2, sorted by created_at then name
# and DELETED domains deleted between day-2 and day+2, sorted by deleted then name # and DELETED domains deleted between day-2 and day+2, sorted by deleted then name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City," "Domain name,Domain type,Agency,Organization name,City,"
"State,Status,Expiration date\n" "State,Status,Expiration date, Deleted\n"
"cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n"
"cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n"
"zdomain12.govInterstateReady(blank)\n" "zdomain12.govInterstateReady(blank)\n"
"zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n"
"sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank)\n" "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n"
"xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank)\n" "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = (
csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
)
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_export_domains_to_writer_domain_managers(self): @less_console_noise_decorator
"""Test that export_domains_to_writer returns the def test_domain_managed(self):
expected domain managers. """Shows ready and deleted domains by an end date, sorted
An invited user, woofwardthethird, should also be pulled into this report. An invited user, woofwardthethird, should also be pulled into this report.
squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers).
She should show twice in this report but not in test_export_data_managed_domains_to_csv.""" She should show twice in this report but not in test_DomainManaged."""
self.maxDiff = None
with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Status",
"Expiration date",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"AO",
"AO email",
"Security contact email",
]
sort_fields = ["domain__name"]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
Domain.State.DNS_NEEDED,
Domain.State.ON_HOLD,
],
}
# Call the export functions # Call the export functions
write_csv_for_domains( DomainManaged.export_data_to_csv(
writer, csv_file,
columns, self.start_date.strftime("%Y-%m-%d"),
sort_fields, self.end_date.strftime("%Y-%m-%d"),
filter_condition,
should_get_domain_managers=True,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
# We expect READY domains,
# sorted alphabetially by domain name
expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email,"
"Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"adomain10.gov,Ready,(blank),Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
"adomain2.gov,Dns needed,(blank),Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n"
"cdomain11.govReady,(blank),Federal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n"
"cdomain1.gov,Ready,(blank),Federal - Executive,World War I Centennial Commission,,,"
", , , ,meoward@rocks.com,R,info@example.com,R,big_lebowski@dude.co,R,"
"woofwardthethird@rocks.com,I\n"
"ddomain3.gov,On hold,(blank),Federal,Armed Forces Retirement Home,,,, , , ,,\n"
"zdomain12.gov,Ready,(blank),Interstate,meoward@rocks.com,R\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates,
get list of managed domains at end_date.
An invited user, woofwardthethird, should also be pulled into this report."""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
export_data_managed_domains_to_csv(
csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d")
)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
# We expect the READY domain names with the domain managers: Their counts, and listing at end_date. # We expect the READY domain names with the domain managers: Their counts, and listing at end_date.
expected_content = ( expected_content = (
"MANAGED DOMAINS COUNTS AT START DATE\n" "MANAGED DOMAINS COUNTS AT START DATE\n"
@ -601,29 +431,24 @@ class ExportDataTest(MockDb, MockEppLib):
"Special district,School district,Election office\n" "Special district,School district,Election office\n"
"3,2,1,0,0,0,0,0,0,0\n" "3,2,1,0,0,0,0,0,0,0\n"
"\n" "\n"
"Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status," "Domain name,Domain type,Domain managers,Invited domain managers\n"
"Domain manager 3,DM3 status,Domain manager 4,DM4 status\n" "cdomain11.gov,Federal - Executive,meoward@rocks.com,\n"
"cdomain11.govFederal-Executivemeoward@rocks.com, R\n" 'cdomain1.gov,Federal - Executive,"meoward@rocks.com, info@example.com, big_lebowski@dude.co",'
"cdomain1.gov,Federal - Executive,meoward@rocks.com,R,info@example.com,R," "woofwardthethird@rocks.com\n"
"big_lebowski@dude.co,R,woofwardthethird@rocks.com,I\n" "zdomain12.gov,Interstate,meoward@rocks.com,\n"
"zdomain12.govInterstatemeoward@rocks.com,R\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_export_data_unmanaged_domains_to_csv(self): @less_console_noise_decorator
"""Test get counts for domains that do not have domain managers for two different dates, def test_domain_unmanaged(self):
get list of unmanaged domains at end_date.""" """Shows unmanaged domains by an end date, sorted"""
with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
export_data_unmanaged_domains_to_csv( DomainUnmanaged.export_data_to_csv(
csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d") csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d")
) )
@ -655,41 +480,29 @@ class ExportDataTest(MockDb, MockEppLib):
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_write_requests_body_with_date_filter_pulls_requests_in_range(self): @less_console_noise_decorator
"""Test that requests that are def test_domain_request_growth(self):
1. SUBMITTED and their submission_date are in range """Shows submitted requests within a date range, sorted"""
are pulled when the growth report conditions are applied to export_requests_to_writed. # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data
Test that requests are sorted by requested domain name. columns = [
""" "Domain request",
"Domain type",
with less_console_noise(): "Federal type",
# "Submitted at",
]
with patch("registrar.utility.csv_export.DomainRequestGrowth.get_columns", return_value=columns):
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file) # Call the export functions
# Define columns, sort fields, and filter condition DomainRequestGrowth.export_data_to_csv(
# We'll skip submission date because it's dynamic and therefore csv_file,
# impossible to set in expected_content self.start_date.strftime("%Y-%m-%d"),
columns = ["Domain request", "Domain type", "Federal type"] self.end_date.strftime("%Y-%m-%d"),
sort_fields = [ )
"requested_domain__name",
]
filter_condition = {
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": self.end_date,
"submission_date__gte": self.start_date,
}
additional_values = ["requested_domain__name"]
all_requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct()
annotated_requests = DomainRequestExport.annotate_and_retrieve_fields(all_requests, {}, additional_values)
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
DomainRequestExport.write_csv_for_requests(writer, columns, requests_dict)
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
# We expect READY domains first, created between today-2 and today+2, sorted by created_at then name
# and DELETED domains deleted between today-2 and today+2, sorted by deleted then name
expected_content = ( expected_content = (
"Domain request,Domain type,Federal type\n" "Domain request,Domain type,Federal type\n"
"city3.gov,Federal,Executive\n" "city3.gov,Federal,Executive\n"
@ -705,69 +518,81 @@ class ExportDataTest(MockDb, MockEppLib):
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
def test_full_domain_request_report(self): def test_domain_request_data_full(self):
"""Tests the full domain request report.""" """Tests the full domain request report."""
# Remove "Submitted at" because we can't guess this immutable, dynamically generated test data
columns = [
"Domain request",
# "Submitted at",
"Status",
"Domain type",
"Federal type",
"Federal agency",
"Organization name",
"Election office",
"City",
"State/territory",
"Region",
"Creator first name",
"Creator last name",
"Creator email",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Request purpose",
"Request additional details",
"Other contacts",
"CISA regional representative",
"Current websites",
"Investigator",
]
with patch("registrar.utility.csv_export.DomainRequestDataFull.get_columns", return_value=columns):
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file) # Call the export functions
DomainRequestDataFull.export_data_to_csv(csv_file)
# Call the report. Get existing fields from the report itself.
annotations = DomainRequestExport._full_domain_request_annotations()
additional_values = [
"requested_domain__name",
"federal_agency__agency",
"authorizing_official__first_name",
"authorizing_official__last_name",
"authorizing_official__email",
"authorizing_official__title",
"creator__first_name",
"creator__last_name",
"creator__email",
"investigator__email",
]
requests = DomainRequest.objects.exclude(status=DomainRequest.DomainRequestStatus.STARTED)
annotated_requests = DomainRequestExport.annotate_and_retrieve_fields(requests, annotations, additional_values)
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
DomainRequestExport.write_csv_for_requests(writer, DomainRequestExport.all_columns, requests_dict)
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
print(csv_content) print(csv_content)
self.maxDiff = None
expected_content = ( expected_content = (
# Header # Header
"Domain request,Submitted at,Status,Domain type,Federal type," "Domain request,Status,Domain type,Federal type,"
"Federal agency,Organization name,Election office,City,State/territory," "Federal agency,Organization name,Election office,City,State/territory,"
"Region,Creator first name,Creator last name,Creator email,Creator approved domains count," "Region,Creator first name,Creator last name,Creator email,Creator approved domains count,"
"Creator active requests count,Alternative domains,AO first name,AO last name,AO email," "Creator active requests count,Alternative domains,SO first name,SO last name,SO email,"
"AO title/role,Request purpose,Request additional details,Other contacts," "SO title/role,Request purpose,Request additional details,Other contacts,"
"CISA regional representative,Current websites,Investigator\n" "CISA regional representative,Current websites,Investigator\n"
# Content # Content
"city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,2024-04-02,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"
"cheeseville.gov | city1.gov | igorville.gov,Testy,Tester,testy@town.com,Chief Tester,"
"Purpose of the site,CISA-first-name CISA-last-name | There is more,Meow Tester24 te2@town.com | "
"Testy1232 Tester24 te2@town.com | Testy Tester testy2@town.com,test@igorville.com,"
"city.com | https://www.example2.com | https://www.example.com,\n"
"city4.gov,2024-04-02,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,"
"testy@town.com,Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com,cisaRep@igorville.gov,city.com,\n"
"city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city6.gov,2024-04-02,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,"
"testy@town.com,Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," "testy@town.com,"
"Testy Tester testy2@town.com,cisaRep@igorville.gov,city.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,'
'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name '
"CISA-last-name "
'| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester '
'testy2@town.com"'
',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n'
"city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester "
"testy2@town.com"
",cisaRep@igorville.gov,city.com,\n"
"city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester "
"testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@ -794,12 +619,12 @@ class HelperFunctions(MockDb):
"domain__first_ready__lte": self.end_date, "domain__first_ready__lte": self.end_date,
} }
# Test with distinct # Test with distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) managed_domains_sliced_at_end_date = DomainExport.get_sliced_domains(filter_condition)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0] expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content) self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct # Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) managed_domains_sliced_at_end_date = DomainExport.get_sliced_domains(filter_condition)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0] expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content) self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
@ -811,6 +636,6 @@ class HelperFunctions(MockDb):
"status": DomainRequest.DomainRequestStatus.SUBMITTED, "status": DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": self.end_date, "submission_date__lte": self.end_date,
} }
submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition) submitted_requests_sliced_at_end_date = DomainRequestExport.get_sliced_requests(filter_condition)
expected_content = [3, 2, 0, 0, 0, 0, 1, 0, 0, 1] expected_content = [3, 2, 0, 0, 0, 0, 1, 0, 0, 1]
self.assertEqual(submitted_requests_sliced_at_end_date, expected_content) self.assertEqual(submitted_requests_sliced_at_end_date, expected_content)

View file

@ -1,100 +0,0 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from registrar.models import Contact
class TestUserPostSave(TestCase):
def setUp(self):
self.username = "test_user"
self.first_name = "First"
self.last_name = "Last"
self.email = "info@example.com"
self.phone = "202-555-0133"
self.preferred_first_name = "One"
self.preferred_last_name = "Two"
self.preferred_email = "front_desk@example.com"
self.preferred_phone = "202-555-0134"
def test_user_created_without_matching_contact(self):
"""Expect 1 Contact containing data copied from User."""
self.assertEqual(len(Contact.objects.all()), 0)
user = get_user_model().objects.create(
username=self.username,
first_name=self.first_name,
last_name=self.last_name,
email=self.email,
phone=self.phone,
)
actual = Contact.objects.get(user=user)
self.assertEqual(actual.first_name, self.first_name)
self.assertEqual(actual.last_name, self.last_name)
self.assertEqual(actual.email, self.email)
self.assertEqual(actual.phone, self.phone)
def test_user_created_with_matching_contact(self):
"""Expect 1 Contact associated, but with no data copied from User."""
self.assertEqual(len(Contact.objects.all()), 0)
Contact.objects.create(
first_name=self.preferred_first_name,
last_name=self.preferred_last_name,
email=self.email, # must be the same, to find the match!
phone=self.preferred_phone,
)
user = get_user_model().objects.create(
username=self.username,
first_name=self.first_name,
last_name=self.last_name,
email=self.email,
)
actual = Contact.objects.get(user=user)
self.assertEqual(actual.first_name, self.preferred_first_name)
self.assertEqual(actual.last_name, self.preferred_last_name)
self.assertEqual(actual.email, self.email)
self.assertEqual(actual.phone, self.preferred_phone)
def test_user_updated_without_matching_contact(self):
"""Expect 1 Contact containing data copied from User."""
# create the user
self.assertEqual(len(Contact.objects.all()), 0)
user = get_user_model().objects.create(username=self.username, first_name="", last_name="", email="", phone="")
# delete the contact
Contact.objects.all().delete()
self.assertEqual(len(Contact.objects.all()), 0)
# modify the user
user.username = self.username
user.first_name = self.first_name
user.last_name = self.last_name
user.email = self.email
user.phone = self.phone
user.save()
# test
actual = Contact.objects.get(user=user)
self.assertEqual(actual.first_name, self.first_name)
self.assertEqual(actual.last_name, self.last_name)
self.assertEqual(actual.email, self.email)
self.assertEqual(actual.phone, self.phone)
def test_user_updated_with_matching_contact(self):
"""Expect 1 Contact associated, but with no data copied from User."""
# create the user
self.assertEqual(len(Contact.objects.all()), 0)
user = get_user_model().objects.create(
username=self.username,
first_name=self.first_name,
last_name=self.last_name,
email=self.email,
phone=self.phone,
)
# modify the user
user.first_name = self.preferred_first_name
user.last_name = self.preferred_last_name
user.email = self.preferred_email
user.phone = self.preferred_phone
user.save()
# test
actual = Contact.objects.get(user=user)
self.assertEqual(actual.first_name, self.first_name)
self.assertEqual(actual.last_name, self.last_name)
self.assertEqual(actual.email, self.email)
self.assertEqual(actual.phone, self.phone)

View file

@ -398,7 +398,7 @@ class TestOrganizationMigration(TestCase):
federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce")
expected_creator = User.objects.filter(username="System").get() expected_creator = User.objects.filter(username="System").get()
expected_ao = Contact.objects.filter( expected_so = Contact.objects.filter(
first_name="Seline", middle_name="testmiddle2", last_name="Tower" first_name="Seline", middle_name="testmiddle2", last_name="Tower"
).get() ).get()
expected_domain_information = DomainInformation( expected_domain_information = DomainInformation(
@ -411,7 +411,7 @@ class TestOrganizationMigration(TestCase):
city="Columbus", city="Columbus",
state_territory="Oh", state_territory="Oh",
zipcode="43268", zipcode="43268",
authorizing_official=expected_ao, senior_official=expected_so,
domain=_domain, domain=_domain,
) )
# Given that these are different objects, this needs to be set # Given that these are different objects, this needs to be set
@ -454,7 +454,7 @@ class TestOrganizationMigration(TestCase):
federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce")
expected_creator = User.objects.filter(username="System").get() expected_creator = User.objects.filter(username="System").get()
expected_ao = Contact.objects.filter( expected_so = Contact.objects.filter(
first_name="Seline", middle_name="testmiddle2", last_name="Tower" first_name="Seline", middle_name="testmiddle2", last_name="Tower"
).get() ).get()
expected_domain_information = DomainInformation( expected_domain_information = DomainInformation(
@ -467,7 +467,7 @@ class TestOrganizationMigration(TestCase):
city="Olympus", city="Olympus",
state_territory="MA", state_territory="MA",
zipcode="12345", zipcode="12345",
authorizing_official=expected_ao, senior_official=expected_so,
domain=_domain, domain=_domain,
) )
# Given that these are different objects, this needs to be set # Given that these are different objects, this needs to be set

View file

@ -57,12 +57,10 @@ class TestWithUser(MockEppLib):
last_name = "Last" last_name = "Last"
email = "info@example.com" email = "info@example.com"
phone = "8003111234" phone = "8003111234"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email, phone=phone
)
title = "test title" title = "test title"
self.user.contact.title = title self.user = get_user_model().objects.create(
self.user.contact.save() username=username, first_name=first_name, last_name=last_name, title=title, email=email, phone=phone
)
username_regular_incomplete = "test_regular_user_incomplete" username_regular_incomplete = "test_regular_user_incomplete"
username_other_incomplete = "test_other_user_incomplete" username_other_incomplete = "test_other_user_incomplete"
@ -374,14 +372,17 @@ class HomeTests(TestWithUser):
) )
# Attach a user object to a contact (should not be deleted) # Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user) contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakey",
)
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create( domain_request = DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=site, requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN, status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact, senior_official=contact,
submitter=contact_user, submitter=contact_user,
) )
domain_request.other_contacts.set([contact_2]) domain_request.other_contacts.set([contact_2])
@ -392,7 +393,7 @@ class HomeTests(TestWithUser):
creator=self.user, creator=self.user,
requested_domain=site_2, requested_domain=site_2,
status=DomainRequest.DomainRequestStatus.STARTED, status=DomainRequest.DomainRequestStatus.STARTED,
authorizing_official=contact_2, senior_official=contact_2,
submitter=contact_shared, submitter=contact_shared,
) )
domain_request_2.other_contacts.set([contact_shared]) domain_request_2.other_contacts.set([contact_shared])
@ -407,17 +408,12 @@ class HomeTests(TestWithUser):
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov") igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
self.assertFalse(igorville.exists()) self.assertFalse(igorville.exists())
# Check if the orphaned contact was deleted # Check if the orphaned contacts were deleted
orphan = Contact.objects.filter(id=contact.id) orphan = Contact.objects.filter(id=contact.id)
self.assertFalse(orphan.exists()) self.assertFalse(orphan.exists())
orphan = Contact.objects.filter(id=contact_user.id)
self.assertFalse(orphan.exists())
# All non-orphan contacts should still exist and are unaltered
try:
current_user = Contact.objects.filter(id=contact_user.id).get()
except Contact.DoesNotExist:
self.fail("contact_user (a non-orphaned contact) was deleted")
self.assertEqual(current_user, contact_user)
try: try:
edge_case = Contact.objects.filter(id=contact_2.id).get() edge_case = Contact.objects.filter(id=contact_2.id).get()
except Contact.DoesNotExist: except Contact.DoesNotExist:
@ -446,14 +442,17 @@ class HomeTests(TestWithUser):
) )
# Attach a user object to a contact (should not be deleted) # Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user) contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakey",
)
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create( domain_request = DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=site, requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN, status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact, senior_official=contact,
submitter=contact_user, submitter=contact_user,
) )
domain_request.other_contacts.set([contact_2]) domain_request.other_contacts.set([contact_2])
@ -464,7 +463,7 @@ class HomeTests(TestWithUser):
creator=self.user, creator=self.user,
requested_domain=site_2, requested_domain=site_2,
status=DomainRequest.DomainRequestStatus.STARTED, status=DomainRequest.DomainRequestStatus.STARTED,
authorizing_official=contact_2, senior_official=contact_2,
submitter=contact_shared, submitter=contact_shared,
) )
domain_request_2.other_contacts.set([contact_shared]) domain_request_2.other_contacts.set([contact_shared])
@ -539,6 +538,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self._set_session_cookie() self._set_session_cookie()
return page.follow() if follow else page return page.follow() if follow else page
@less_console_noise_decorator
@override_flag("profile_feature", active=True)
def test_full_name_initial_value(self):
"""Test that full_name initial value is empty when first_name or last_name is empty.
This will later be displayed as "unknown" using javascript."""
self.app.set_user(self.incomplete_regular_user.username)
# Test when first_name is empty
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = "Doe"
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when last_name is empty
self.incomplete_regular_user.first_name = "John"
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when both first_name and last_name are empty
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "")
# Test when both first_name and last_name are present
self.incomplete_regular_user.first_name = "John"
self.incomplete_regular_user.last_name = "Doe"
self.incomplete_regular_user.save()
finish_setup_page = self.app.get(reverse("home")).follow()
form = finish_setup_page.form
self.assertEqual(form["full_name"].value, "John Doe")
@less_console_noise_decorator @less_console_noise_decorator
def test_new_user_with_profile_feature_on(self): def test_new_user_with_profile_feature_on(self):
"""Tests that a new user is redirected to the profile setup page when profile_feature is on""" """Tests that a new user is redirected to the profile setup page when profile_feature is on"""
@ -560,7 +602,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertContains(finish_setup_page, "Enter your phone number.") self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button # Check for the name of the save button
self.assertContains(finish_setup_page, "contact_setup_save_button") self.assertContains(finish_setup_page, "user_setup_save_button")
# Add a phone number # Add a phone number
finish_setup_form = finish_setup_page.form finish_setup_form = finish_setup_page.form
@ -577,6 +619,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
completed_setup_page = self.app.get(reverse("home")) completed_setup_page = self.app.get(reverse("home"))
self.assertContains(completed_setup_page, "Manage your domain") self.assertContains(completed_setup_page, "Manage your domain")
@less_console_noise_decorator
def test_new_user_with_empty_name_can_add_name(self):
"""Tests that a new user without a name can still enter this information accordingly"""
self.incomplete_regular_user.first_name = ""
self.incomplete_regular_user.last_name = ""
self.incomplete_regular_user.save()
self.app.set_user(self.incomplete_regular_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page.
# Follow implicity checks if our redirect is working.
finish_setup_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(finish_setup_page, "Finish setting up your profile")
finish_setup_page = self._submit_form_webtest(finish_setup_page.form)
self.assertEqual(finish_setup_page.status_code, 200)
# We're missing a phone number, so the page should tell us that
self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button
self.assertContains(finish_setup_page, "user_setup_save_button")
# Add a phone number
finish_setup_form = finish_setup_page.form
finish_setup_form["first_name"] = "test"
finish_setup_form["last_name"] = "test2"
finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example"
save_page = self._submit_form_webtest(finish_setup_form, follow=True)
self.assertEqual(save_page.status_code, 200)
self.assertContains(save_page, "Your profile has been updated.")
# Try to navigate back to the home page.
# This is the same as clicking the back button.
completed_setup_page = self.app.get(reverse("home"))
self.assertContains(completed_setup_page, "Manage your domain")
@less_console_noise_decorator @less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_on(self): def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
"""Tests that a new user is redirected to the domain request page when profile_feature is on""" """Tests that a new user is redirected to the domain request page when profile_feature is on"""
@ -598,10 +683,11 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertContains(finish_setup_page, "Enter your phone number.") self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button # Check for the name of the save button
self.assertContains(finish_setup_page, "contact_setup_save_button") self.assertContains(finish_setup_page, "user_setup_save_button")
# Add a phone number # Add a phone number
finish_setup_form = finish_setup_page.form finish_setup_form = finish_setup_page.form
finish_setup_form["first_name"] = "firstname"
finish_setup_form["phone"] = "(201) 555-0123" finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO" finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example" finish_setup_form["last_name"] = "example"
@ -613,7 +699,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
# Submit the form using the specific submit button to execute the redirect # Submit the form using the specific submit button to execute the redirect
completed_setup_page = self._submit_form_webtest( completed_setup_page = self._submit_form_webtest(
finish_setup_form, follow=True, name="contact_setup_submit_button" finish_setup_form, follow=True, name="user_setup_submit_button"
) )
self.assertEqual(completed_setup_page.status_code, 200) self.assertEqual(completed_setup_page.status_code, 200)
@ -730,6 +816,8 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
self.assertContains(save_page, "Your profile has been updated.") self.assertContains(save_page, "Your profile has been updated.")
# We need to assert that logo is not clickable and links to manage your domain are not present # We need to assert that logo is not clickable and links to manage your domain are not present
# NOTE: "anage" is not a typo. It is to accomodate the fact that the "m" is uppercase in one
# instance and lowercase in the other.
self.assertContains(save_page, "anage your domains", count=2) self.assertContains(save_page, "anage your domains", count=2)
self.assertNotContains( self.assertNotContains(
save_page, "Before you can manage your domains, we need you to add contact information" save_page, "Before you can manage your domains, we need you to add contact information"
@ -865,13 +953,16 @@ class UserProfileTests(TestWithUser, WebTest):
def test_request_when_profile_feature_on(self): def test_request_when_profile_feature_on(self):
"""test that Your profile is in request page when profile feature is on""" """test that Your profile is in request page when profile feature is on"""
contact_user, _ = Contact.objects.get_or_create(user=self.user) contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakerson",
)
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create( domain_request = DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=site, requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED, status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user, senior_official=contact_user,
submitter=contact_user, submitter=contact_user,
) )
with override_flag("profile_feature", active=True): with override_flag("profile_feature", active=True):
@ -884,13 +975,16 @@ class UserProfileTests(TestWithUser, WebTest):
def test_request_when_profile_feature_off(self): def test_request_when_profile_feature_off(self):
"""test that Your profile is not in request page when profile feature is off""" """test that Your profile is not in request page when profile feature is off"""
contact_user, _ = Contact.objects.get_or_create(user=self.user) contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakerson",
)
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create( domain_request = DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=site, requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED, status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user, senior_official=contact_user,
submitter=contact_user, submitter=contact_user,
) )
with override_flag("profile_feature", active=False): with override_flag("profile_feature", active=False):
@ -965,7 +1059,7 @@ class PortfoliosTests(TestWithUser, WebTest):
# Assert that we're on the right page # Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Domains</h1>") self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
@less_console_noise_decorator @less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self): def test_no_redirect_when_org_flag_false(self):

View file

@ -150,7 +150,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain-users-add", "domain-users-add",
"domain-dns-nameservers", "domain-dns-nameservers",
"domain-org-name-address", "domain-org-name-address",
"domain-authorizing-official", "domain-senior-official",
"domain-your-contact-information", "domain-your-contact-information",
"domain-security-email", "domain-security-email",
]: ]:
@ -169,7 +169,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain-users-add", "domain-users-add",
"domain-dns-nameservers", "domain-dns-nameservers",
"domain-org-name-address", "domain-org-name-address",
"domain-authorizing-official", "domain-senior-official",
"domain-your-contact-information", "domain-your-contact-information",
"domain-security-email", "domain-security-email",
]: ]:
@ -190,7 +190,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain-dns-dnssec", "domain-dns-dnssec",
"domain-dns-dnssec-dsdata", "domain-dns-dnssec-dsdata",
"domain-org-name-address", "domain-org-name-address",
"domain-authorizing-official", "domain-senior-official",
"domain-your-contact-information", "domain-your-contact-information",
"domain-security-email", "domain-security-email",
]: ]:
@ -1082,44 +1082,43 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
) )
class TestDomainAuthorizingOfficial(TestDomainOverview): class TestDomainSeniorOfficial(TestDomainOverview):
def test_domain_authorizing_official(self): def test_domain_senior_official(self):
"""Can load domain's authorizing official page.""" """Can load domain's senior official page."""
page = self.client.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
# once on the sidebar, once in the title self.assertContains(page, "Senior official", count=13)
self.assertContains(page, "Authorizing official", count=3)
def test_domain_authorizing_official_content(self): def test_domain_senior_official_content(self):
"""Authorizing official information appears on the page.""" """Senior official information appears on the page."""
self.domain_information.authorizing_official = Contact(first_name="Testy") self.domain_information.senior_official = Contact(first_name="Testy")
self.domain_information.authorizing_official.save() self.domain_information.senior_official.save()
self.domain_information.save() self.domain_information.save()
page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Testy") self.assertContains(page, "Testy")
def test_domain_edit_authorizing_official_in_place(self): def test_domain_edit_senior_official_in_place(self):
"""When editing an authorizing official for domain information and AO is not """When editing a senior official for domain information and SO is not
joined to any other objects""" joined to any other objects"""
self.domain_information.authorizing_official = Contact( self.domain_information.senior_official = Contact(
first_name="Testy", last_name="Tester", title="CIO", email="nobody@igorville.gov" first_name="Testy", last_name="Tester", title="CIO", email="nobody@igorville.gov"
) )
self.domain_information.authorizing_official.save() self.domain_information.senior_official.save()
self.domain_information.save() self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
self.assertEqual(ao_form["first_name"].value, "Testy") self.assertEqual(so_form["first_name"].value, "Testy")
ao_form["first_name"] = "Testy2" so_form["first_name"] = "Testy2"
# ao_pk is the initial pk of the authorizing official. set it before update # so_pk is the initial pk of the senior official. set it before update
# to be able to verify after update that the same contact object is in place # to be able to verify after update that the same contact object is in place
ao_pk = self.domain_information.authorizing_official.id so_pk = self.domain_information.senior_official.id
ao_form.submit() so_form.submit()
# refresh domain information # refresh domain information
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
self.assertEqual("Testy2", self.domain_information.authorizing_official.first_name) self.assertEqual("Testy2", self.domain_information.senior_official.first_name)
self.assertEqual(ao_pk, self.domain_information.authorizing_official.id) self.assertEqual(so_pk, self.domain_information.senior_official.id)
def assert_all_form_fields_have_expected_values(self, form, test_cases, test_for_disabled=False): def assert_all_form_fields_have_expected_values(self, form, test_cases, test_for_disabled=False):
""" """
@ -1147,26 +1146,26 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
# Test for disabled on each field # Test for disabled on each field
self.assertTrue("disabled" in form[field_name].attrs) self.assertTrue("disabled" in form[field_name].attrs)
def test_domain_edit_authorizing_official_federal(self): def test_domain_edit_senior_official_federal(self):
"""Tests that no edit can occur when the underlying domain is federal""" """Tests that no edit can occur when the underlying domain is federal"""
# Set the org type to federal # Set the org type to federal
self.domain_information.generic_org_type = DomainInformation.OrganizationChoices.FEDERAL self.domain_information.generic_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.save() self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level. # Add an SO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact( self.domain_information.senior_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov" first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
) )
self.domain_information.authorizing_official.save() self.domain_information.senior_official.save()
self.domain_information.save() self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly # Test if the form is populating data correctly
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
test_cases = [ test_cases = [
("first_name", "Apple"), ("first_name", "Apple"),
@ -1174,16 +1173,16 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
("title", "CIO"), ("title", "CIO"),
("email", "nobody@igorville.gov"), ("email", "nobody@igorville.gov"),
] ]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True) self.assert_all_form_fields_have_expected_values(so_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal, # Attempt to change data on each field. Because this domain is federal,
# this should not succeed. # this should not succeed.
ao_form["first_name"] = "Orange" so_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie" so_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat" so_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov" so_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit() submission = so_form.submit()
# A 302 indicates this page underwent a redirect. # A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302) self.assertEqual(submission.status_code, 302)
@ -1198,31 +1197,31 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity. # All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name) self.assertEqual("Apple", self.domain_information.senior_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name) self.assertEqual("Tester", self.domain_information.senior_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title) self.assertEqual("CIO", self.domain_information.senior_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email) self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email)
def test_domain_edit_authorizing_official_tribal(self): def test_domain_edit_senior_official_tribal(self):
"""Tests that no edit can occur when the underlying domain is tribal""" """Tests that no edit can occur when the underlying domain is tribal"""
# Set the org type to federal # Set the org type to federal
self.domain_information.generic_org_type = DomainInformation.OrganizationChoices.TRIBAL self.domain_information.generic_org_type = DomainInformation.OrganizationChoices.TRIBAL
self.domain_information.save() self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level. # Add an SO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact( self.domain_information.senior_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov" first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
) )
self.domain_information.authorizing_official.save() self.domain_information.senior_official.save()
self.domain_information.save() self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly # Test if the form is populating data correctly
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
test_cases = [ test_cases = [
("first_name", "Apple"), ("first_name", "Apple"),
@ -1230,16 +1229,16 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
("title", "CIO"), ("title", "CIO"),
("email", "nobody@igorville.gov"), ("email", "nobody@igorville.gov"),
] ]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True) self.assert_all_form_fields_have_expected_values(so_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal, # Attempt to change data on each field. Because this domain is federal,
# this should not succeed. # this should not succeed.
ao_form["first_name"] = "Orange" so_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie" so_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat" so_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov" so_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit() submission = so_form.submit()
# A 302 indicates this page underwent a redirect. # A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302) self.assertEqual(submission.status_code, 302)
@ -1254,45 +1253,45 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity. # All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name) self.assertEqual("Apple", self.domain_information.senior_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name) self.assertEqual("Tester", self.domain_information.senior_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title) self.assertEqual("CIO", self.domain_information.senior_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email) self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email)
def test_domain_edit_authorizing_official_creates_new(self): def test_domain_edit_senior_official_creates_new(self):
"""When editing an authorizing official for domain information and AO IS """When editing a senior official for domain information and SO IS
joined to another object""" joined to another object"""
# set AO and Other Contact to the same Contact object # set SO and Other Contact to the same Contact object
self.domain_information.authorizing_official = Contact( self.domain_information.senior_official = Contact(
first_name="Testy", last_name="Tester", title="CIO", email="nobody@igorville.gov" first_name="Testy", last_name="Tester", title="CIO", email="nobody@igorville.gov"
) )
self.domain_information.authorizing_official.save() self.domain_information.senior_official.save()
self.domain_information.save() self.domain_information.save()
self.domain_information.other_contacts.add(self.domain_information.authorizing_official) self.domain_information.other_contacts.add(self.domain_information.senior_official)
self.domain_information.save() self.domain_information.save()
# load the Authorizing Official in the web form # load the Senior Official in the web form
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id})) so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
# verify the first name is "Testy" and then change it to "Testy2" # verify the first name is "Testy" and then change it to "Testy2"
self.assertEqual(ao_form["first_name"].value, "Testy") self.assertEqual(so_form["first_name"].value, "Testy")
ao_form["first_name"] = "Testy2" so_form["first_name"] = "Testy2"
# ao_pk is the initial pk of the authorizing official. set it before update # so_pk is the initial pk of the senior official. set it before update
# to be able to verify after update that the same contact object is in place # to be able to verify after update that the same contact object is in place
ao_pk = self.domain_information.authorizing_official.id so_pk = self.domain_information.senior_official.id
ao_form.submit() so_form.submit()
# refresh domain information # refresh domain information
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# assert that AO information is updated, and that the AO is a new Contact # assert that SO information is updated, and that the SO is a new Contact
self.assertEqual("Testy2", self.domain_information.authorizing_official.first_name) self.assertEqual("Testy2", self.domain_information.senior_official.first_name)
self.assertNotEqual(ao_pk, self.domain_information.authorizing_official.id) self.assertNotEqual(so_pk, self.domain_information.senior_official.id)
# assert that the Other Contact information is not updated and that the Other Contact # assert that the Other Contact information is not updated and that the Other Contact
# is the original Contact object # is the original Contact object
other_contact = self.domain_information.other_contacts.all()[0] other_contact = self.domain_information.other_contacts.all()[0]
self.assertEqual("Testy", other_contact.first_name) self.assertEqual("Testy", other_contact.first_name)
self.assertEqual(ao_pk, other_contact.id) self.assertEqual(so_pk, other_contact.id)
class TestDomainOrganization(TestDomainOverview): class TestDomainOrganization(TestDomainOverview):
@ -1478,8 +1477,8 @@ class TestDomainContactInformation(TestDomainOverview):
def test_domain_your_contact_information_content(self): def test_domain_your_contact_information_content(self):
"""Logged-in user's contact information appears on the page.""" """Logged-in user's contact information appears on the page."""
self.user.contact.first_name = "Testy" self.user.first_name = "Testy"
self.user.contact.save() self.user.save()
page = self.app.get(reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})) page = self.app.get(reverse("domain-your-contact-information", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Testy") self.assertContains(page, "Testy")

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from .test_views import TestWithUser from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator
class GetDomainsJsonTest(TestWithUser, WebTest): class GetDomainsJsonTest(TestWithUser, WebTest):
@ -11,9 +12,9 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
# Create test domains # Create test domains
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="active") self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="inactive") self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="active") self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
# Create UserDomainRoles # Create UserDomainRoles
UserDomainRole.objects.create(user=self.user, domain=self.domain1) UserDomainRole.objects.create(user=self.user, domain=self.domain1)
@ -25,6 +26,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@less_console_noise_decorator
def test_get_domains_json_unauthenticated(self): def test_get_domains_json_unauthenticated(self):
"""for an unauthenticated user, test that the user is redirected for auth""" """for an unauthenticated user, test that the user is redirected for auth"""
self.app.reset() self.app.reset()
@ -32,6 +34,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
response = self.client.get(reverse("get_domains_json")) response = self.client.get(reverse("get_domains_json"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_domains_json_authenticated(self): def test_get_domains_json_authenticated(self):
"""Test that an authenticated user gets the list of 3 domains.""" """Test that an authenticated user gets the list of 3 domains."""
response = self.app.get(reverse("get_domains_json")) response = self.app.get(reverse("get_domains_json"))
@ -102,6 +105,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
) )
self.assertEqual(svg_icon_expected, svg_icons[i]) self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
def test_get_domains_json_search(self): def test_get_domains_json_search(self):
"""Test search.""" """Test search."""
# Define your URL variables as a dictionary # Define your URL variables as a dictionary
@ -131,6 +135,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
domains[0], domains[0],
) )
@less_console_noise_decorator
def test_pagination(self): def test_pagination(self):
"""Test that pagination is correct in the response""" """Test that pagination is correct in the response"""
response = self.app.get(reverse("get_domains_json"), {"page": 1}) response = self.app.get(reverse("get_domains_json"), {"page": 1})
@ -143,6 +148,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
@less_console_noise_decorator
def test_sorting(self): def test_sorting(self):
"""test that sorting works properly in the response""" """test that sorting works properly in the response"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "expiration_date", "order": "desc"}) response = self.app.get(reverse("get_domains_json"), {"sort_by": "expiration_date", "order": "desc"})
@ -161,6 +167,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
expiration_dates = [domain["expiration_date"] for domain in data["domains"]] expiration_dates = [domain["expiration_date"] for domain in data["domains"]]
self.assertEqual(expiration_dates, sorted(expiration_dates)) self.assertEqual(expiration_dates, sorted(expiration_dates))
@less_console_noise_decorator
def test_sorting_by_state_display(self): def test_sorting_by_state_display(self):
"""test that the state_display sorting works properly""" """test that the state_display sorting works properly"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "state_display", "order": "asc"}) response = self.app.get(reverse("get_domains_json"), {"sort_by": "state_display", "order": "asc"})
@ -178,3 +185,21 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
# Check if sorted by state_display in descending order # Check if sorted by state_display in descending order
states = [domain["state_display"] for domain in data["domains"]] states = [domain["state_display"] for domain in data["domains"]]
self.assertEqual(states, sorted(states, reverse=True)) self.assertEqual(states, sorted(states, reverse=True))
@less_console_noise_decorator
def test_state_filtering(self):
"""Test that different states in request get expected responses."""
expected_values = [
("unknown", 1),
("ready", 0),
("expired", 2),
("ready,expired", 2),
("unknown,expired", 3),
]
for state, num_domains in expected_values:
with self.subTest(state=state, num_domains=num_domains):
response = self.app.get(reverse("get_domains_json"), {"status": state})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domains"]), num_domains)

View file

@ -253,38 +253,38 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in # the post request should return a redirect to the next form in
# the domain request page # the domain request page
self.assertEqual(org_contact_result.status_code, 302) self.assertEqual(org_contact_result.status_code, 302)
self.assertEqual(org_contact_result["Location"], "/request/authorizing_official/") self.assertEqual(org_contact_result["Location"], "/request/senior_official/")
num_pages_tested += 1 num_pages_tested += 1
# ---- AUTHORIZING OFFICIAL PAGE ---- # ---- SENIOR OFFICIAL PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow() so_page = org_contact_result.follow()
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO" so_form["senior_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO" so_form["senior_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester" so_form["senior_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com" so_form["senior_official-email"] = "testy@town.com"
# test next button # test next button
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_result = ao_form.submit() so_result = so_form.submit()
# validate that data from this step are being saved # validate that data from this step are being saved
domain_request = DomainRequest.objects.get() # there's only one domain_request = DomainRequest.objects.get() # there's only one
self.assertEqual(domain_request.authorizing_official.first_name, "Testy ATO") self.assertEqual(domain_request.senior_official.first_name, "Testy ATO")
self.assertEqual(domain_request.authorizing_official.last_name, "Tester ATO") self.assertEqual(domain_request.senior_official.last_name, "Tester ATO")
self.assertEqual(domain_request.authorizing_official.title, "Chief Tester") self.assertEqual(domain_request.senior_official.title, "Chief Tester")
self.assertEqual(domain_request.authorizing_official.email, "testy@town.com") self.assertEqual(domain_request.senior_official.email, "testy@town.com")
# the post request should return a redirect to the next form in # the post request should return a redirect to the next form in
# the domain request page # the domain request page
self.assertEqual(ao_result.status_code, 302) self.assertEqual(so_result.status_code, 302)
self.assertEqual(ao_result["Location"], "/request/current_sites/") self.assertEqual(so_result["Location"], "/request/current_sites/")
num_pages_tested += 1 num_pages_tested += 1
# ---- CURRENT SITES PAGE ---- # ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_sites_page = ao_result.follow() current_sites_page = so_result.follow()
current_sites_form = current_sites_page.forms[0] current_sites_form = current_sites_page.forms[0]
current_sites_form["current_sites-0-website"] = "www.city.com" current_sites_form["current_sites-0-website"] = "www.city.com"
@ -610,38 +610,38 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in # the post request should return a redirect to the next form in
# the domain request page # the domain request page
self.assertEqual(org_contact_result.status_code, 302) self.assertEqual(org_contact_result.status_code, 302)
self.assertEqual(org_contact_result["Location"], "/request/authorizing_official/") self.assertEqual(org_contact_result["Location"], "/request/senior_official/")
num_pages_tested += 1 num_pages_tested += 1
# ---- AUTHORIZING OFFICIAL PAGE ---- # ---- SENIOR OFFICIAL PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow() so_page = org_contact_result.follow()
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO" so_form["senior_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO" so_form["senior_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester" so_form["senior_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com" so_form["senior_official-email"] = "testy@town.com"
# test next button # test next button
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_result = ao_form.submit() so_result = so_form.submit()
# validate that data from this step are being saved # validate that data from this step are being saved
domain_request = DomainRequest.objects.get() # there's only one domain_request = DomainRequest.objects.get() # there's only one
self.assertEqual(domain_request.authorizing_official.first_name, "Testy ATO") self.assertEqual(domain_request.senior_official.first_name, "Testy ATO")
self.assertEqual(domain_request.authorizing_official.last_name, "Tester ATO") self.assertEqual(domain_request.senior_official.last_name, "Tester ATO")
self.assertEqual(domain_request.authorizing_official.title, "Chief Tester") self.assertEqual(domain_request.senior_official.title, "Chief Tester")
self.assertEqual(domain_request.authorizing_official.email, "testy@town.com") self.assertEqual(domain_request.senior_official.email, "testy@town.com")
# the post request should return a redirect to the next form in # the post request should return a redirect to the next form in
# the domain request page # the domain request page
self.assertEqual(ao_result.status_code, 302) self.assertEqual(so_result.status_code, 302)
self.assertEqual(ao_result["Location"], "/request/current_sites/") self.assertEqual(so_result["Location"], "/request/current_sites/")
num_pages_tested += 1 num_pages_tested += 1
# ---- CURRENT SITES PAGE ---- # ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_sites_page = ao_result.follow() current_sites_page = so_result.follow()
current_sites_form = current_sites_page.forms[0] current_sites_form = current_sites_page.forms[0]
current_sites_form["current_sites-0-website"] = "www.city.com" current_sites_form["current_sites-0-website"] = "www.city.com"
@ -1576,7 +1576,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 1 "other contact" assigned to it # has 1 "other contact" assigned to it
# We'll do it from scratch so we can reuse the other contact # We'll do it from scratch so we can reuse the other contact
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -1607,7 +1607,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
@ -1703,7 +1703,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 2 "other contact" assigned to it # has 2 "other contact" assigned to it
# We'll do it from scratch so we can reuse the other contact # We'll do it from scratch so we can reuse the other contact
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -1741,7 +1741,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
@ -1784,7 +1784,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 1 "other contact" assigned to it # has 1 "other contact" assigned to it
# We'll do it from scratch so we can reuse the other contact # We'll do it from scratch so we can reuse the other contact
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -1815,7 +1815,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
@ -1861,7 +1861,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 1 "other contact" assigned to it # has 1 "other contact" assigned to it
# We'll do it from scratch so we can reuse the other contact # We'll do it from scratch so we can reuse the other contact
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -1892,7 +1892,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
@ -1937,7 +1937,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 1 "other contact" assigned to it # has 1 "other contact" assigned to it
# We'll do it from scratch # We'll do it from scratch
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -1968,7 +1968,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
@ -2017,9 +2017,9 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that # Populate the database with a domain request that
# has 1 "other contact" assigned to it, the other contact is also # has 1 "other contact" assigned to it, the other contact is also
# the authorizing official initially # the senior official initially
# We'll do it from scratch # We'll do it from scratch
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -2043,17 +2043,17 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
status="started", status="started",
) )
domain_request.other_contacts.add(ao) domain_request.other_contacts.add(so)
# other_contact_pk is the initial pk of the other contact. set it before update # other_contact_pk is the initial pk of the other contact. set it before update
# to be able to verify after update that the ao contact is still in place # to be able to verify after update that the so contact is still in place
# and not updated, and that the new contact has a new id # and not updated, and that the new contact has a new id
other_contact_pk = ao.id other_contact_pk = so.id
# prime the form by visiting /edit # prime the form by visiting /edit
self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}))
@ -2085,20 +2085,20 @@ class DomainRequestTests(TestWithUser, WebTest):
other_contact = domain_request.other_contacts.all()[0] other_contact = domain_request.other_contacts.all()[0]
self.assertNotEquals(other_contact_pk, other_contact.id) self.assertNotEquals(other_contact_pk, other_contact.id)
self.assertEquals("Testy2", other_contact.first_name) self.assertEquals("Testy2", other_contact.first_name)
# assert that the authorizing official is not updated # assert that the senior official is not updated
authorizing_official = domain_request.authorizing_official senior_official = domain_request.senior_official
self.assertEquals("Testy", authorizing_official.first_name) self.assertEquals("Testy", senior_official.first_name)
def test_edit_authorizing_official_in_place(self): def test_edit_senior_official_in_place(self):
"""When you: """When you:
1. edit an authorizing official which is not joined to another model, 1. edit a senior official which is not joined to another model,
2. then submit, 2. then submit,
the domain request is linked to the existing ao, and the ao updated.""" the domain request is linked to the existing so, and the so updated."""
# Populate the database with a domain request that # Populate the database with a domain request that
# has an authorizing_official (ao) # has a senior_official (so)
# We'll do it from scratch # We'll do it from scratch
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -2115,14 +2115,14 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
creator=self.user, creator=self.user,
status="started", status="started",
) )
# ao_pk is the initial pk of the Authorizing Official. set it before update # so_pk is the initial pk of the Senior Official. set it before update
# to be able to verify after update that the same Contact object is in place # to be able to verify after update that the same Contact object is in place
ao_pk = ao.id so_pk = so.id
# prime the form by visiting /edit # prime the form by visiting /edit
self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}))
@ -2133,38 +2133,38 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = self.app.get(reverse("domain-request:authorizing_official")) so_page = self.app.get(reverse("domain-request:senior_official"))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
# Minimal check to ensure the form is loaded # Minimal check to ensure the form is loaded
self.assertEqual(ao_form["authorizing_official-first_name"].value, "Testy") self.assertEqual(so_form["senior_official-first_name"].value, "Testy")
# update the first name of the contact # update the first name of the contact
ao_form["authorizing_official-first_name"] = "Testy2" so_form["senior_official-first_name"] = "Testy2"
# Submit the updated form # Submit the updated form
ao_form.submit() so_form.submit()
domain_request.refresh_from_db() domain_request.refresh_from_db()
# assert AO is updated "in place" # assert SO is updated "in place"
updated_ao = domain_request.authorizing_official updated_so = domain_request.senior_official
self.assertEquals(ao_pk, updated_ao.id) self.assertEquals(so_pk, updated_so.id)
self.assertEquals("Testy2", updated_ao.first_name) self.assertEquals("Testy2", updated_so.first_name)
def test_edit_authorizing_official_creates_new(self): def test_edit_senior_official_creates_new(self):
"""When you: """When you:
1. edit an existing authorizing official which IS joined to another model, 1. edit an existing senior official which IS joined to another model,
2. then submit, 2. then submit,
the domain request is linked to a new Contact, and the new Contact is updated.""" the domain request is linked to a new Contact, and the new Contact is updated."""
# Populate the database with a domain request that # Populate the database with a domain request that
# has authorizing official assigned to it, the authorizing offical is also # has senior official assigned to it, the senior offical is also
# an other contact initially # an other contact initially
# We'll do it from scratch # We'll do it from scratch
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -2181,16 +2181,16 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
creator=self.user, creator=self.user,
status="started", status="started",
) )
domain_request.other_contacts.add(ao) domain_request.other_contacts.add(so)
# ao_pk is the initial pk of the authorizing official. set it before update # so_pk is the initial pk of the senior official. set it before update
# to be able to verify after update that the other contact is still in place # to be able to verify after update that the other contact is still in place
# and not updated, and that the new ao has a new id # and not updated, and that the new so has a new id
ao_pk = ao.id so_pk = so.id
# prime the form by visiting /edit # prime the form by visiting /edit
self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}))
@ -2201,30 +2201,30 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = self.app.get(reverse("domain-request:authorizing_official")) so_page = self.app.get(reverse("domain-request:senior_official"))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
# Minimal check to ensure the form is loaded # Minimal check to ensure the form is loaded
self.assertEqual(ao_form["authorizing_official-first_name"].value, "Testy") self.assertEqual(so_form["senior_official-first_name"].value, "Testy")
# update the first name of the contact # update the first name of the contact
ao_form["authorizing_official-first_name"] = "Testy2" so_form["senior_official-first_name"] = "Testy2"
# Submit the updated form # Submit the updated form
ao_form.submit() so_form.submit()
domain_request.refresh_from_db() domain_request.refresh_from_db()
# assert that the other contact is not updated # assert that the other contact is not updated
other_contacts = domain_request.other_contacts.all() other_contacts = domain_request.other_contacts.all()
other_contact = other_contacts[0] other_contact = other_contacts[0]
self.assertEquals(ao_pk, other_contact.id) self.assertEquals(so_pk, other_contact.id)
self.assertEquals("Testy", other_contact.first_name) self.assertEquals("Testy", other_contact.first_name)
# assert that the authorizing official is updated # assert that the senior official is updated
authorizing_official = domain_request.authorizing_official senior_official = domain_request.senior_official
self.assertEquals("Testy2", authorizing_official.first_name) self.assertEquals("Testy2", senior_official.first_name)
def test_edit_submitter_in_place(self): def test_edit_submitter_in_place(self):
"""When you: """When you:
@ -2421,7 +2421,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# and the step is on the sidebar list. # and the step is on the sidebar list.
self.assertContains(tribal_government_page, self.TITLES[Step.TRIBAL_GOVERNMENT]) self.assertContains(tribal_government_page, self.TITLES[Step.TRIBAL_GOVERNMENT])
def test_domain_request_ao_dynamic_text(self): def test_domain_request_so_dynamic_text(self):
intro_page = self.app.get(reverse("domain-request:")) intro_page = self.app.get(reverse("domain-request:"))
# django-webtest does not handle cookie-based sessions well because it keeps # django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept # resetting the session key on each new request, thus destroying the concept
@ -2474,24 +2474,24 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
org_contact_result = org_contact_form.submit() org_contact_result = org_contact_form.submit()
# ---- AO CONTACT PAGE ---- # ---- SO CONTACT PAGE ----
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow() so_page = org_contact_result.follow()
self.assertContains(ao_page, "Executive branch federal agencies") self.assertContains(so_page, "Executive branch federal agencies")
# Go back to organization type page and change type # Go back to organization type page and change type
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page.click(str(self.TITLES["generic_org_type"]), index=0) so_page.click(str(self.TITLES["generic_org_type"]), index=0)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_form["generic_org_type-generic_org_type"] = "city" type_form["generic_org_type-generic_org_type"] = "city"
type_result = type_form.submit() type_result = type_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow() election_page = type_result.follow()
# Go back to AO page and test the dynamic text changed # Go back to SO page and test the dynamic text changed
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = election_page.click(str(self.TITLES["authorizing_official"]), index=0) so_page = election_page.click(str(self.TITLES["senior_official"]), index=0)
self.assertContains(ao_page, "Domain requests from cities") self.assertContains(so_page, "Domain requests from cities")
def test_domain_request_dotgov_domain_dynamic_text(self): def test_domain_request_dotgov_domain_dynamic_text(self):
intro_page = self.app.get(reverse("domain-request:")) intro_page = self.app.get(reverse("domain-request:"))
@ -2546,27 +2546,27 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
org_contact_result = org_contact_form.submit() org_contact_result = org_contact_form.submit()
# ---- AO CONTACT PAGE ---- # ---- SO CONTACT PAGE ----
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow() so_page = org_contact_result.follow()
# ---- AUTHORIZING OFFICIAL PAGE ---- # ---- senior OFFICIAL PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow() so_page = org_contact_result.follow()
ao_form = ao_page.forms[0] so_form = so_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO" so_form["senior_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO" so_form["senior_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester" so_form["senior_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com" so_form["senior_official-email"] = "testy@town.com"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_result = ao_form.submit() so_result = so_form.submit()
# ---- CURRENT SITES PAGE ---- # ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page # Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_sites_page = ao_result.follow() current_sites_page = so_result.follow()
current_sites_form = current_sites_page.forms[0] current_sites_form = current_sites_page.forms[0]
current_sites_form["current_sites-0-website"] = "www.city.com" current_sites_form["current_sites-0-website"] = "www.city.com"
@ -2627,7 +2627,7 @@ class DomainRequestTests(TestWithUser, WebTest):
""" """
Test that a previously saved domain request is available at the /edit endpoint. Test that a previously saved domain request is available at the /edit endpoint.
""" """
ao, _ = Contact.objects.get_or_create( so, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
title="Chief Tester", title="Chief Tester",
@ -2661,7 +2661,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1", address_line1="address 1",
state_territory="NY", state_territory="NY",
zipcode="10002", zipcode="10002",
authorizing_official=ao, senior_official=so,
requested_domain=domain, requested_domain=domain,
submitter=you, submitter=you,
creator=self.user, creator=self.user,
@ -2699,7 +2699,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# page = self.app.get(url) # page = self.app.get(url)
# self.assertNotContains(page, "VALUE") # self.assertNotContains(page, "VALUE")
# url = reverse("domain-request:authorizing_official") # url = reverse("domain-request:senior_official")
# self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# page = self.app.get(url) # page = self.app.get(url)
# self.assertNotContains(page, "VALUE") # self.assertNotContains(page, "VALUE")
@ -2974,14 +2974,17 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
) )
# Attach a user object to a contact (should not be deleted) # Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user) contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakey",
)
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create( domain_request = DomainRequest.objects.create(
creator=self.user, creator=self.user,
requested_domain=site, requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN, status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact, senior_official=contact,
submitter=contact_user, submitter=contact_user,
) )
domain_request.other_contacts.set([contact_2]) domain_request.other_contacts.set([contact_2])
@ -3008,7 +3011,7 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
# Now 'detail_page' contains the response after following the redirect # Now 'detail_page' contains the response after following the redirect
self.assertEqual(detail_page.status_code, 200) self.assertEqual(detail_page.status_code, 200)
# 5 unlocked steps (ao, domain, submitter, other contacts, and current sites # 5 unlocked steps (so, domain, submitter, other contacts, and current sites
# which unlocks if domain exists), one active step, the review step is locked # 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=5)
# Type of organization # Type of organization

Some files were not shown because too many files have changed in this diff Show more