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.
- [ ] [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)
- [ ] 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`.
### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh
error: gpg failed to sign the data
@ -90,6 +93,15 @@ or
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
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:
label: Links to other issues
description: |
"Add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)."
placeholder: 🔄 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..."
- type: markdown
id: note
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,7 +22,8 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node npm install &&
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

View file

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

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
- staging
- development
- ms
- ag
- litterbox
- hotgov

View file

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

View file

@ -41,7 +41,7 @@ class DomainRequest {
--
creator (User)
investigator (User)
authorizing_official (Contact)
senior_official (Contact)
submitter (Contact)
other_contacts (Contacts)
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 {
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
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
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
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 {
domain
--
@ -115,6 +130,7 @@ class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 {
+ security_contact_registry_id (TextField)
+ deleted (DateField)
+ first_ready (DateField)
+ dsdata_last_change (TextField)
--
}
@ -126,6 +142,7 @@ class "registrar.FederalAgency <Registrar>" as registrar.FederalAgency #d6f4e9 {
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ agency (CharField)
+ federal_type (CharField)
--
}
@ -138,7 +155,10 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ updated_at (DateTimeField)
+ status (FSMField)
+ rejection_reason (TextField)
+ action_needed_reason (TextField)
+ action_needed_reason_email (TextField)
~ federal_agency (ForeignKey)
~ portfolio (ForeignKey)
~ creator (ForeignKey)
~ investigator (ForeignKey)
+ generic_org_type (CharField)
@ -156,7 +176,7 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ zipcode (CharField)
+ urbanization (CharField)
+ about_your_organization (TextField)
~ authorizing_official (ForeignKey)
~ senior_official (ForeignKey)
~ approved_domain (OneToOneField)
~ requested_domain (OneToOneField)
~ submitter (ForeignKey)
@ -165,6 +185,8 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
+ anything_else (TextField)
+ has_anything_else_text (BooleanField)
+ cisa_representative_email (EmailField)
+ cisa_representative_first_name (CharField)
+ cisa_representative_last_name (CharField)
+ has_cisa_representative (BooleanField)
+ is_policy_acknowledged (BooleanField)
+ submission_date (DateField)
@ -175,6 +197,7 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
--
}
registrar.DomainRequest -- registrar.FederalAgency
registrar.DomainRequest -- registrar.Portfolio
registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.Contact
@ -194,6 +217,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
+ updated_at (DateTimeField)
~ federal_agency (ForeignKey)
~ creator (ForeignKey)
~ portfolio (ForeignKey)
~ domain_request (OneToOneField)
+ generic_org_type (CharField)
+ organization_type (CharField)
@ -210,13 +234,17 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
+ zipcode (CharField)
+ urbanization (CharField)
+ about_your_organization (TextField)
~ authorizing_official (ForeignKey)
~ senior_official (ForeignKey)
~ domain (OneToOneField)
~ submitter (ForeignKey)
+ purpose (TextField)
+ no_other_contacts_rationale (TextField)
+ anything_else (TextField)
+ has_anything_else_text (BooleanField)
+ cisa_representative_email (EmailField)
+ cisa_representative_first_name (CharField)
+ cisa_representative_last_name (CharField)
+ has_cisa_representative (BooleanField)
+ is_policy_acknowledged (BooleanField)
+ notes (TextField)
# other_contacts (ManyToManyField)
@ -224,6 +252,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
}
registrar.DomainInformation -- registrar.FederalAgency
registrar.DomainInformation -- registrar.User
registrar.DomainInformation -- registrar.Portfolio
registrar.DomainInformation -- registrar.DomainRequest
registrar.DomainInformation -- registrar.Contact
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 {
domain invitation
--
@ -388,6 +402,58 @@ class "registrar.WaffleFlag <Registrar>" as registrar.WaffleFlag #d6f4e9 {
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
```

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`.
## 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)
1. On the app, navigate to `\admin`.
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_election/",
"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/dotgov_domain/",
"http://localhost:8080/request/purpose/",

View file

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

View file

@ -9,17 +9,17 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
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.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
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.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@ -167,6 +167,9 @@ class DomainRequestAdminForm(forms.ModelForm):
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
}
labels = {
"action_needed_reason_email": "Auto-generated email",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -445,8 +448,9 @@ class AdminSortFields:
sort_mapping = {
# == Contact == #
"other_contacts": (Contact, _name_sort),
"authorizing_official": (Contact, _name_sort),
"submitter": (Contact, _name_sort),
# == Senior Official == #
"senior_official": (SeniorOfficial, _name_sort),
# == User == #
"creator": (User, _name_sort),
"user": (User, _name_sort),
@ -594,12 +598,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
return filters
class UserContactInline(admin.StackedInline):
"""Edit a user's profile on the user page."""
model = models.Contact
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines."""
@ -616,8 +614,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
_meta = Meta()
inlines = [UserContactInline]
list_display = (
"username",
"overridden_email_field",
@ -646,7 +642,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None,
{"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",
{
@ -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",
{
@ -701,12 +697,13 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# NOT all fields are readonly for admin, otherwise we would have
# set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [
"Personal Info",
"User profile",
"first_name",
"middle_name",
"last_name",
"title",
"email",
"phone",
"Permissions",
"is_active",
"groups",
@ -895,30 +892,20 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = [
"name",
"email",
"user_exists",
]
# this ordering effects the ordering of results
# in autocomplete_fields for user
# in autocomplete_fields
ordering = ["first_name", "last_name", "email"]
fieldsets = [
(
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"
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
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@ -936,9 +923,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
name.admin_order_field = "first_name" # type: ignore
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"user",
]
analyst_readonly_fields: list[str] = ["email"]
def get_readonly_fields(self, request, obj=None):
"""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)
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):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file"""
@ -1234,9 +1232,9 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain."
fieldsets = [
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
(None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
(".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"]}),
(
"Type of organization",
@ -1310,9 +1308,11 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
autocomplete_fields = [
"creator",
"domain_request",
"authorizing_official",
"senior_official",
"domain",
"submitter",
"portfolio",
"sub_organization",
]
# Table ordering
@ -1322,6 +1322,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
superuser_only_fields = [
"portfolio",
"sub_organization",
]
# DEVELOPER's NOTE:
@ -1342,7 +1343,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets:
fields = data.get("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 fieldsets
@ -1485,6 +1486,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
custom_election_board.admin_order_field = "is_election_board" # 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
list_filter = (
StatusListFilter,
@ -1510,9 +1518,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
{
"fields": [
"portfolio",
"sub_organization",
"status_history",
"status",
"rejection_reason",
"action_needed_reason",
"action_needed_reason_email",
"investigator",
"creator",
"submitter",
@ -1526,7 +1537,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Contacts",
{
"fields": [
"authorizing_official",
"senior_official",
"other_contacts",
"no_other_contacts_rationale",
"cisa_representative_first_name",
@ -1592,6 +1603,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
)
# Read only that we'll leverage for CISA Analysts
@ -1615,13 +1627,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"requested_domain",
"submitter",
"creator",
"authorizing_official",
"senior_official",
"investigator",
"portfolio",
"sub_organization",
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
superuser_only_fields = [
"portfolio",
"sub_organization",
]
# DEVELOPER's NOTE:
@ -1642,7 +1657,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
for name, data in fieldsets:
fields = data.get("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 fieldsets
@ -1689,29 +1704,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if not 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.
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 the status hasn't changed, let the base function take care of it
return super().save_model(request, obj, form, change)
else:
# Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj)
# == Handle status changes == #
# Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj)
# We should only save if we don't display any errors in the steps above.
if should_save:
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")
# We should only save if we don't display any errors in the steps above.
if should_save:
return super().save_model(request, obj, form, change)
def _handle_status_change(self, request, obj, original_obj):
"""
@ -1906,44 +1931,54 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Initialize extra_context and add filtered entries
extra_context = extra_context or {}
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
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
for this particular domain request."""
emails = {}
for action_needed_reason in domain_request.ActionNeededReasons:
enum_value = action_needed_reason.value
# Change this in #1901. Just add a check for the current value.
emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value)
return json.dumps(emails)
# Map the action_needed_reason to its default email
emails[action_needed_reason.value] = self._get_action_needed_reason_default_email(
domain_request, action_needed_reason.value
)
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"""
if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER:
return {
"subject_text": None,
"email_body_text": None,
}
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
return 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
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
template = get_template(template_path)
# Get the email subject
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
subject_template = get_template(template_subject_path)
email_body_text = get_template(template_path).render(context=context)
email_body_text_cleaned = None
if email_body_text:
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
# Return the content of the rendered views
context = {"domain_request": domain_request}
return {
"subject_text": subject_template.render(context=context),
"email_body_text": template.render(context=context),
}
return email_body_text_cleaned
def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable."""
@ -2036,14 +2071,7 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
autocomplete_fields = [
"creator",
"domain_request",
"authorizing_official",
"domain",
"submitter",
]
autocomplete_fields = DomainInformationAdmin.autocomplete_fields
def has_change_permission(self, request, obj=None):
"""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
# in autocomplete_fields for domain
# this ordering effects the ordering of results in autocomplete_fields for domain
ordering = ["name"]
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["domain_state"] = domain.get_state_display()
# Pass in what the an extended expiration date would be for the expiration date modal
self._set_expiration_date_context(domain, extra_context)
extra_context["curr_exp_date"] = (
domain.expiration_date if domain.expiration_date is not None else self._get_current_date()
)
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):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
@ -2284,11 +2298,9 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
self.message_user(request, "Object is not of type Domain.", messages.ERROR)
return None
years = self._get_calculated_years_for_exp_date(obj)
# Renew the domain.
try:
obj.renew_domain(length=years)
obj.renew_domain()
self.message_user(
request,
"Successfully extended the expiration date.",
@ -2313,37 +2325,6 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
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.
# it is immutable. Rather than dealing with a convoluted workaround,
# lets wrap this in a function.
@ -2686,6 +2667,11 @@ class PortfolioAdmin(ListHeaderAdmin):
# readonly_fields = [
# "requestor",
# ]
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
"federal_agency",
]
def save_model(self, request, obj, form, change):
@ -2769,6 +2755,10 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
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.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -5,12 +5,3 @@ class RegistrarConfig(AppConfig):
"""Configure signal handling for our registrar Django application."""
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.
@ -361,9 +370,12 @@ function initializeWidgetOnList(list, parentId) {
*/
(function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
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 isRejected = statusSelect.value == "rejected"
let isActionNeeded = statusSelect.value == "action needed"
@ -371,6 +383,7 @@ function initializeWidgetOnList(list, parentId) {
// Initial handling of rejectionReasonFormGroup display
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() {
@ -382,6 +395,7 @@ function initializeWidgetOnList(list, parentId) {
isActionNeeded = statusSelect.value == "action needed"
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
});
@ -398,6 +412,7 @@ function initializeWidgetOnList(list, parentId) {
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
}
});
});
@ -412,51 +427,6 @@ function initializeWidgetOnList(list, parentId) {
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
@ -552,58 +522,84 @@ function initializeWidgetOnList(list, parentId) {
})();
/** An IIFE that hooks up to the "show email" button.
* which shows the auto generated email on action needed reason.
/** An IIFE that hooks to the show/hide button underneath action needed reason.
* This shows the auto generated email on action needed reason.
*/
(function () {
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more");
if(actionNeededReasonDropdown && actionNeededEmail && container) {
// Add a change listener to the action needed reason dropdown
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
}
// Since this is an iife, these vars will be removed from memory afterwards
var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
var readonlyView = document.querySelector("#action-needed-reason-email-readonly");
function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) {
actionNeededReasonDropdown.addEventListener("change", function() {
let emailWasSent = document.getElementById("action-needed-email-sent");
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;
// If a reason isn't specified, no email will be sent.
// You also cannot save the model in this state.
// This flow occurs if you switch back to the empty picker state.
if(!reason) {
showNoEmailMessage(actionNeededEmail);
return;
}
let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent)
let emailData = actionNeededEmails[reason];
if (emailData) {
let emailBody = emailData.email_body_text
if (emailBody) {
actionNeededEmail.value = emailBody
showActionNeededEmail(actionNeededEmail);
}else {
showNoEmailMessage(actionNeededEmail);
}
}else {
showNoEmailMessage(actionNeededEmail);
// Handle the session boolean (to enable/disable editing)
if (emailWasSent && emailWasSent.value === "True") {
// An email was sent out - store that information in a session variable
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
}
// Show an editable email field or a readonly one
updateActionNeededEmailDisplay(reason)
});
// Add a change listener to the action needed reason dropdown
actionNeededReasonDropdown.addEventListener("change", function() {
let reason = actionNeededReasonDropdown.value;
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
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)
}
}
}
// Show an editable email field or a readonly one
updateActionNeededEmailDisplay(reason)
});
}
// Show the text field. Hide the "no email" message.
function showActionNeededEmail(actionNeededEmail){
let noEmailMessage = document.getElementById("no-email-message");
showElement(actionNeededEmail);
hideElement(noEmailMessage);
// Shows an editable email field or a readonly one.
// If the email doesn't exist or if we're of reason "other", display that no email was sent.
// Likewise, if we've sent this email before, we should just display the content.
function updateActionNeededEmailDisplay(reason) {
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') {
targetEl = document.getElementById(attributeValue);
} 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
}
@ -78,6 +78,50 @@ function makeVisible(el) {
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. */
function createLiveRegion(id) {
const liveRegion = document.createElement("div");
@ -927,7 +971,7 @@ function unloadModals() {
* @param {string} itemName - The name displayed in the counter
* @param {string} paginationSelector - CSS selector for the pagination container.
* @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 {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages.
@ -936,7 +980,7 @@ function unloadModals() {
* @param {number} totalItems - The total number of items.
* @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 paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@ -955,7 +999,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const prevPageItem = document.createElement('li');
prevPageItem.className = 'usa-pagination__item usa-pagination__arrow';
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">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg>
@ -974,7 +1018,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
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) {
pageItem.querySelector('a').classList.add('usa-current');
@ -1020,7 +1064,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const nextPageItem = document.createElement('li');
nextPageItem.className = 'usa-pagination__item usa-pagination__arrow';
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>
<svg class="usa-icon" aria-hidden="true" role="img">
<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
*
*/
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => {
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data;
if (searchTermHolder)
searchTermHolder.innerHTML = '';
if (unfiltered_total) {
if (total) {
showElement(dataWrapper);
hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
} else {
if (searchTermHolder)
searchTermHolder.innerHTML = currentSearchTerm;
hideElement(dataWrapper);
showElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
@ -1090,14 +1128,18 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc';
const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false;
let currentSearchTerm = ''
let scrollToTable = false;
let currentStatus = [];
let currentSearchTerm = '';
const domainsSearchInput = document.getElementById('domains__search-field');
const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domains__search-term');
const resetButton = document.querySelector('.domains__reset-button');
const resetSearchButton = document.querySelector('.domains__reset-search');
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
@ -1105,21 +1147,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option
* @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
*/
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) {
// fetch json of page of domains, given params
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.log('Error in AJAX call: ' + data.error);
console.error('Error in AJAX call: ' + data.error);
return;
}
// 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
const domainList = document.querySelector('.domains__table tbody');
@ -1132,7 +1174,6 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const row = document.createElement('tr');
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
@ -1148,7 +1189,7 @@ document.addEventListener('DOMContentLoaded', function() {
data-position="top"
title="${domain.get_state_help_text}"
focusable="true"
aria-label="Status Information"
aria-label="${domain.get_state_help_text}"
role="tooltip"
>
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
@ -1169,16 +1210,16 @@ document.addEventListener('DOMContentLoaded', function() {
initializeTooltips();
// Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domains-header');
hasLoaded = true;
if (scroll)
ScrollToElement('class', 'domains');
scrollToTable = true;
// update pagination
updatePagination(
'domain',
'#domains-pagination',
'#domains-pagination .usa-pagination__counter',
'#domains-header',
'#domains',
loadDomains,
data.page,
data.num_pages,
@ -1214,13 +1255,51 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
showElement(resetSearchButton);
} else {
hideElement(resetButton);
hideElement(resetSearchButton);
}
loadDomains(1, 'id', 'asc');
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
function resetHeaders() {
@ -1235,18 +1314,78 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() {
domainsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomains(1, 'id', 'asc', hasLoaded, '');
hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
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);
}
});
@ -1279,14 +1418,13 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false;
let currentSearchTerm = ''
let scrollToTable = false;
let currentSearchTerm = '';
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domain-requests__search-term');
const resetButton = document.querySelector('.domain-requests__reset-button');
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
/**
* 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}`);
}
// 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));
}
@ -1332,21 +1470,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option
* @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
*/
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
// fetch json of page of domain requests, given params
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.log('Error in AJAX call: ' + data.error);
console.error('Error in AJAX call: ' + data.error);
return;
}
// 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
const tbody = document.querySelector('.domain-requests__table tbody');
@ -1533,16 +1671,16 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domain-requests-header');
hasLoaded = true;
if (scroll)
ScrollToElement('class', 'domain-requests');
scrollToTable = true;
// update the pagination after the domain requests list is updated
updatePagination(
'domain request',
'#domain-requests-pagination',
'#domain-requests-pagination .usa-pagination__counter',
'#domain-requests-header',
'#domain-requests',
loadDomainRequests,
data.page,
data.num_pages,
@ -1577,13 +1715,13 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetButton);
showElement(resetSearchButton);
} else {
hideElement(resetButton);
hideElement(resetSearchButton);
}
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
})
});
// Reset UI and accessibility
function resetHeaders() {
@ -1598,24 +1736,23 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() {
domainRequestsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetButton);
loadDomainRequests(1, 'id', 'asc', hasLoaded, '');
hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
}
if (resetButton) {
resetButton.addEventListener('click', function() {
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
});
}
// Load the first page initially
// Initial load
loadDomainRequests(1);
}
});
/**
* An IIFE that displays confirmation modal on the user profile page
*/
@ -1689,6 +1826,9 @@ document.addEventListener('DOMContentLoaded', function() {
}
function setupListener(){
document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) {
// Get the "{field_name}" and "edit-button"
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
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(){
document.addEventListener('DOMContentLoaded', function() {
// Get all input elements within the form
let form = document.querySelector("#finish-profile-setup-form");
let inputs = form ? form.querySelectorAll("input") : null;
@ -1741,9 +1930,9 @@ document.addEventListener('DOMContentLoaded', function() {
});
};
// Hookup all edit buttons to the `handleEditButtonClick` function
setupListener();
// Show the input fields if an error exists
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);
}
.textarea-wrapper {
width: 100%;
max-width: 610px;
}
.dja-readonly-textarea-container {
width: 100%;
textarea {
width: 100%;
min-width: 610px;
max-width: 610px;
resize: none;
cursor: auto;
@ -827,3 +833,20 @@ div.dja__model-description{
// Many elements in django admin try to override this, so we need !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);
margin-top: units(3);
&.margin-top-0 {
margin-top: 0;
}
h2 {
color: color('primary-dark');
margin-top: units(2);
@ -96,6 +100,10 @@ body {
@include at-media(mobile-lg) {
margin-top: units(5);
&.margin-top-0 {
margin-top: 0;
}
h2 {
margin-bottom: 0;
}
@ -182,7 +190,7 @@ abbr[title] {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
div.input-with-edit-button__readonly-field {
color: #{$dhs-red};
}
}
@ -211,3 +219,7 @@ abbr[title] {
.usa-logo button.usa-button--unstyled.disabled-button:hover{
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);
}
}
.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,18 +1,14 @@
@use "uswds-core" as *;
.dotgov-table {
a {
display: flex;
align-items: flex-start;
color: color('primary');
.dotgov-table a,
.usa-link--icon {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
&:visited {
color: color('primary');
}
}
a {
.usa-icon {
// align icon with x height
margin-top: units(0.5);

View file

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

View file

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

View file

@ -660,6 +660,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
"getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov",
"getgov-litterbox.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_CONTACT, views.OrganizationContact),
(Step.ABOUT_YOUR_ORGANIZATION, views.AboutYourOrganization),
(Step.AUTHORIZING_OFFICIAL, views.AuthorizingOfficial),
(Step.SENIOR_OFFICIAL, views.SeniorOfficial),
(Step.CURRENT_SITES, views.CurrentSites),
(Step.DOTGOV_DOMAIN, views.DotgovDomain),
(Step.PURPOSE, views.Purpose),
@ -183,9 +183,9 @@ urlpatterns = [
name="domain-org-name-address",
),
path(
"domain/<int:pk>/authorizing-official",
views.DomainAuthorizingOfficialView.as_view(),
name="domain-authorizing-official",
"domain/<int:pk>/senior-official",
views.DomainSeniorOfficialView.as_view(),
name="domain-senior-official",
),
path(
"domain/<int:pk>/security-email",

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ from registrar.utility.errors import (
SecurityEmailErrorCodes,
)
from ..models import Contact, DomainInformation, Domain
from ..models import Contact, DomainInformation, Domain, User
from .common import (
ALGORITHM_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):
"""Form for updating contacts."""
@ -260,10 +317,10 @@ class ContactForm(forms.ModelForm):
self.domainInfo = domainInfo
class AuthorizingOfficialContactForm(ContactForm):
"""Form for updating authorizing official contacts."""
class SeniorOfficialContactForm(ContactForm):
"""Form for updating senior official contacts."""
JOIN = "authorizing_official"
JOIN = "senior_official"
def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -273,13 +330,13 @@ class AuthorizingOfficialContactForm(ContactForm):
# Set custom 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 = {
"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 = {
"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)."
}
self.fields["email"].error_messages = {
@ -306,21 +363,21 @@ class AuthorizingOfficialContactForm(ContactForm):
is_federal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
is_tribal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL
# Get the Contact object from the db for the Authorizing Official
db_ao = Contact.objects.get(id=self.instance.id)
# Get the Contact object from the db for the Senior Official
db_so = Contact.objects.get(id=self.instance.id)
if (is_federal or is_tribal) and self.has_changed():
# 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.
# This could be malicious, so lets reserve information for the backend only.
raise ValueError("Authorizing Official cannot be modified for federal or tribal domains.")
elif db_ao.has_more_than_one_join("information_authorizing_official"):
# Handle the case where the domain information object is available and the AO Contact
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the SO Contact
# has more than one joined object.
# 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())
self.domainInfo.authorizing_official = Contact.objects.create(**data)
self.domainInfo.senior_official = Contact.objects.create(**data)
self.domainInfo.save()
else:
# If all checks pass, just save normally

View file

@ -183,14 +183,14 @@ class AboutYourOrganizationForm(RegistrarForm):
)
class AuthorizingOfficialForm(RegistrarForm):
JOIN = "authorizing_official"
class SeniorOfficialForm(RegistrarForm):
JOIN = "senior_official"
def to_database(self, obj):
if not self.is_valid():
return
contact = getattr(obj, "authorizing_official", None)
if contact is not None and not contact.has_more_than_one_join("authorizing_official"):
contact = getattr(obj, "senior_official", None)
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
super().to_database(contact)
else:
@ -198,27 +198,27 @@ class AuthorizingOfficialForm(RegistrarForm):
# in either case, create a new contact and update it
contact = Contact()
super().to_database(contact)
obj.authorizing_official = contact
obj.senior_official = contact
obj.save()
@classmethod
def from_database(cls, obj):
contact = getattr(obj, "authorizing_official", None)
contact = getattr(obj, "senior_official", None)
return super().from_database(contact)
first_name = forms.CharField(
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(
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(
label="Title or role in your organization",
error_messages={
"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)."
)
},

View file

@ -1,6 +1,6 @@
from django import forms
from registrar.models.contact import Contact
from registrar.models.user import User
from django.core.validators import MaxLengthValidator
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -13,7 +13,7 @@ class UserProfileForm(forms.ModelForm):
redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = Contact
model = User
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = {
"first_name": forms.TextInput,
@ -71,7 +71,7 @@ class UserProfileForm(forms.ModelForm):
class FinishSetupProfileForm(UserProfileForm):
"""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):
cleaned_data = super().clean()
@ -93,4 +93,7 @@ class FinishSetupProfileForm(UserProfileForm):
self.fields["title"].label = "Title or role in your organization"
# 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
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):
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
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):
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"
# 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
tmp_dir = "tmp"
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_agency = transition_domain.federal_agency
# = AO Information = #
# = SO Information = #
first_name = transition_domain.first_name
middle_name = transition_domain.middle_name
last_name = transition_domain.last_name
@ -429,7 +429,7 @@ class Command(BaseCommand):
"domain": domain,
"organization_name": transition_domain.organization_name,
"creator": default_creator,
"authorizing_official": contact,
"senior_official": contact,
}
if valid_org_type:

View file

@ -177,7 +177,7 @@ class LoadExtraTransitionDomain:
# STEP 3: Parse agency data
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)
# STEP 5: Parse creation and expiration data
@ -326,7 +326,7 @@ class LoadExtraTransitionDomain:
)
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."""
if not isinstance(transition_domain, TransitionDomain):
raise ValueError("Not a valid object, must be TransitionDomain")
@ -336,7 +336,7 @@ class LoadExtraTransitionDomain:
self.parse_logs.create_log_item(
EnumFilenames.AGENCY_ADHOC,
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,
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 .domain_group import DomainGroup
from .suborganization import Suborganization
from .senior_official import SeniorOfficial
__all__ = [
@ -42,6 +43,7 @@ __all__ = [
"Portfolio",
"DomainGroup",
"Suborganization",
"SeniorOfficial",
]
auditlog.register(Contact)
@ -64,3 +66,4 @@ auditlog.register(WaffleFlag)
auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)

View file

@ -8,30 +8,15 @@ from phonenumber_field.modelfields import PhoneNumberField # type: ignore
class Contact(TimeStampedModel):
"""
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:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["user"]),
models.Index(fields=["email"]),
]
user = models.OneToOneField(
"registrar.User",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
first_name = models.CharField(
null=True,
blank=True,
@ -110,38 +95,6 @@ class Contact(TimeStampedModel):
def has_contact_info(self):
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):
if self.first_name or self.last_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
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
def get_help_text(cls, state) -> str:
"""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,
null=True,
blank=True,
related_name="DomainRequest_portfolio",
related_name="information_portfolio",
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(
"registrar.DomainRequest",
on_delete=models.PROTECT,
@ -171,11 +180,11 @@ class DomainInformation(TimeStampedModel):
blank=True,
)
authorizing_official = models.ForeignKey(
senior_official = models.ForeignKey(
"registrar.Contact",
null=True,
blank=True,
related_name="information_authorizing_official",
related_name="information_senior_official",
on_delete=models.PROTECT,
)
@ -361,6 +370,10 @@ class DomainInformation(TimeStampedModel):
# domain_request, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first()
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
# Get the fields that exist on both DomainRequest and DomainInformation

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from typing import Union
import logging
from django.apps import apps
from django.conf import settings
from django.db import models
@ -136,6 +135,13 @@ class DomainRequest(TimeStampedModel):
@classmethod
def get_org_label(cls, org_name: str):
"""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")
if len(org_names) > 0:
org_name = org_names[0]
@ -266,7 +272,7 @@ class DomainRequest(TimeStampedModel):
"""Defines common action needed reasons for domain requests"""
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")
BAD_NAME = ("bad_name", "Doesnt meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)")
@ -315,10 +321,19 @@ class DomainRequest(TimeStampedModel):
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="DomainInformation_portfolio",
related_name="DomainRequest_portfolio",
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
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
@ -423,11 +438,11 @@ class DomainRequest(TimeStampedModel):
blank=True,
)
authorizing_official = models.ForeignKey(
senior_official = models.ForeignKey(
"registrar.Contact",
null=True,
blank=True,
related_name="authorizing_official",
related_name="senior_official",
on_delete=models.PROTECT,
)
@ -444,7 +459,7 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
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,
)
@ -452,7 +467,7 @@ class DomainRequest(TimeStampedModel):
"DraftDomain",
null=True,
blank=True,
related_name="domain_request",
related_name="domain_request_requested_domain",
on_delete=models.PROTECT,
)
@ -603,9 +618,10 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs)
# Handle the action needed email. We send one when moving to action_needed,
# but we don't send one when we are _already_ in the state and change the reason.
self.sync_action_needed_reason()
# Handle the action needed email.
# 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()
# Update the cached values after saving
self._cache_status_and_action_needed_reason()
@ -615,10 +631,10 @@ class DomainRequest(TimeStampedModel):
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_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"
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):
"""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}")
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.
@ -683,11 +707,18 @@ class DomainRequest(TimeStampedModel):
If the waffle flag "profile_feature" is active, then this email will be sent to the
domain request creator rather than the submitter
Optional args:
bcc_address: str -> the address to bcc to
context: dict -> The context sent to the template
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.
wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given
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
@ -705,15 +736,21 @@ class DomainRequest(TimeStampedModel):
return None
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(
email_template,
email_template_subject,
recipient.email,
context={
"domain_request": self,
# This is the user that we refer to in the email
"recipient": recipient,
},
context=context,
bcc_address=bcc_address,
wrap_email=wrap_email,
)
@ -771,8 +808,8 @@ class DomainRequest(TimeStampedModel):
"submission confirmation",
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
True,
bcc_address,
send_email=True,
bcc_address=bcc_address,
)
@transition(
@ -842,43 +879,28 @@ class DomainRequest(TimeStampedModel):
# Send out an email if an action needed reason exists
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"""
# Store the filenames of the template and template subject
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_name = "custom_email.txt"
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
bcc_address = ""
if settings.IS_PRODUCTION:
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(
new_status="action needed",
email_template=f"emails/action_needed_reasons/{email_template_name}",
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
send_email=send_email,
bcc_address=bcc_address,
wrap_email=True,
)
self._send_status_update_email(
new_status="action needed",
email_template=f"emails/action_needed_reasons/{email_template_name}",
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
send_email=send_email,
bcc_address=bcc_address,
custom_email_content=email_content,
wrap_email=True,
)
@transition(
field="status",
@ -936,7 +958,7 @@ class DomainRequest(TimeStampedModel):
"domain request approved",
"emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt",
send_email,
send_email=send_email,
)
@transition(
@ -1128,8 +1150,8 @@ class DomainRequest(TimeStampedModel):
and self.zipcode is None
)
def _is_authorizing_official_complete(self):
return self.authorizing_official is not None
def _is_senior_official_complete(self):
return self.senior_official is not None
def _is_requested_domain_complete(self):
return self.requested_domain is not None
@ -1191,10 +1213,10 @@ class DomainRequest(TimeStampedModel):
has_profile_feature_flag = flag_is_active(request, "profile_feature")
return (
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_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_other_contacts_complete()
and self._is_additional_details_complete()

View file

@ -38,6 +38,15 @@ class Portfolio(TimeStampedModel):
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(
max_length=255,
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
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
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
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_info.first_name,
user_info.last_name,
user_info.title,
user_info.phone,
self.first_name,
self.last_name,
self.title,
self.phone,
]
return None not in user_values
return None not in user_values and "" not in user_values
def __str__(self):
# this info is pulled from Login.gov
@ -169,8 +162,13 @@ class User(AbstractUser):
"""Return count of ineligible requests"""
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):
return bool(self.contact.title or self.contact.email or self.contact.phone)
return bool(self.title or self.email or self.phone)
@classmethod
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 %}
<div>
{% 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 %}">
{% endblock flex_container_start %}
{% if field.is_checkbox %}
{# .gov override #}
{% block field_checkbox %}
@ -52,7 +54,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% endblock field_other%}
{% endif %}
{% endif %}
{% block flex_container_end %}
</div>
{% endblock flex_container_end %}
{% if field.field.help_text %}
{# .gov override #}

View file

@ -69,7 +69,7 @@
</h2>
<div class="usa-prose">
<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> #}
<div class="display-inline"></div>
This action cannot be undone.
@ -78,7 +78,7 @@
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New expiration date: <b>{{ extended_expiration_date }}</b>
Current expiration date: <b>{{ curr_exp_date }}</b>
{{test}}
</p>
</div>

View file

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

View file

@ -1,6 +1,6 @@
<p>
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
a corresponding record within the <a class="text-underline" href="{% url 'admin:registrar_user_changelist' %}">Users</a> table.
</p>

View file

@ -6,9 +6,85 @@
This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% 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 %}
{% 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 %}
<div class="readonly">
{% for contact in all_contacts %}
@ -67,81 +143,42 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endwith %}
{% endblock field_readonly %}
{% block after_help_text %}
{% if field.field.name == "status" %}
<div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label>
<div>
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
{% if filtered_audit_log_entries %}
<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>
{% else %}
<p>No changelog to display.</p>
{% endif %}
{% comment %}
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.
The alternative to this is an API endpoint.
Given that we have a limited number of emails, doing it this way makes sense.
{% endcomment %}
{% if action_needed_reason_emails %}
<script id="action-needed-emails-data" type="application/json">
{{ action_needed_reason_emails|safe }}
</script>
{% 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>
{% block field_other %}
{% if field.field.name == "action_needed_reason_email" %}
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
<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>
{{ field.field }}
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
</div>
{% else %}
{{ field.field }}
{% endif %}
{% endblock field_other %}
{% block after_help_text %}
{% if field.field.name == "action_needed_reason_email" %}
{% comment %}
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.
The alternative to this is an API endpoint.
Given that we have a limited number of emails, doing it this way makes sense.
{% endcomment %}
{% if action_needed_reason_emails %}
<script id="action-needed-emails-data" type="application/json">
{{ action_needed_reason_emails|safe }}
</script>
{% endif %}
{% elif field.field.name == "creator" %}
<div class="flex-container tablet:margin-top-2">
<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>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
</div>
{% elif field.field.name == "authorizing_official" %}
{% elif field.field.name == "senior_official" %}
<div class="flex-container">
<label aria-label="Authorizing 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 %}
<label aria-label="Senior official contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
</div>
{% elif field.field.name == "other_contacts" and 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 %}
{% 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 %}
{% include "includes/summary_item.html" with title='Authorizing official' value=domain.domain_info.authorizing_official contact='true' edit_link=url editable=domain.is_editable %}
{% url 'domain-senior-official' pk=domain.id as url %}
{% 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 #}
{% if not has_profile_feature_flag %}
{% 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 %}
{% 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>
{% if has_profile_feature_flag %}
<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%}
{% 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:
<ul class="usa-list">
<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>
</ul>
</p>

View file

@ -79,10 +79,10 @@
{% endwith %}
{% endif %}
{% if step == Step.AUTHORIZING_OFFICIAL %}
{% if step == Step.SENIOR_OFFICIAL %}
{% namespaced_url 'domain-request' step as domain_request_url %}
{% if domain_request.authorizing_official is not None %}
{% with title=form_titles|get_item:step value=domain_request.authorizing_official %}
{% if domain_request.senior_official is not None %}
{% 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' %}
{% endwith %}
{% 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>
<br>
<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><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
{% endif %}
</div>
<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 %}
{% endif %}
{% if DomainRequest.authorizing_official %}
{% include "includes/summary_item.html" with title='Authorizing official' value=DomainRequest.authorizing_official contact='true' heading_level=heading_level %}
{% if DomainRequest.senior_official %}
{% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.current_websites.all %}

View file

@ -1,20 +1,20 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}Authorizing official | {{ domain.name }} | {% endblock %}
{% block title %}Senior official | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% 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
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>
<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-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" %}
<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>.
</p>
{% else %}
@ -27,10 +27,10 @@
{% if generic_org_type == "federal" or generic_org_type == "tribal" %}
{# 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_last_name" id="sr-ao-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_email" id="sr-ao-email">{{ form.email.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-so-last-name">{{ form.last_name.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-so-email">{{ form.email.value }}</div>
{% endif %}
{% input_with_errors form.first_name %}

View file

@ -65,11 +65,11 @@
</li>
<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 }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Authorizing official
Senior official
</a>
</li>

View file

@ -8,7 +8,7 @@
<p>
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.
</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
We've reviewed your domain request, but we need more information about the authorizing official listed on the request:
- {{ domain_request.authorizing_official.get_formatted_name }}
- {{ domain_request.authorizing_official.title }}
SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS
We've reviewed your domain request, but we need more information about the senior official listed on the request:
- {{ domain_request.senior_official.get_formatted_name }}
- {{ 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
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

View file

@ -27,8 +27,8 @@ Organization name and mailing address:
About your organization:
{% spaceless %}{{ domain_request.about_your_organization }}{% endspaceless %}
{% endif %}
Authorizing official:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.authorizing_official %}{% endspaceless %}
Senior official:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.senior_official %}{% endspaceless %}
{% if domain_request.current_websites.exists %}{# if block makes a newline #}
Current websites: {% for site in domain_request.current_websites.all %}
{% 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
government organizations.
Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.
DEMONSTRATE ELIGIBILITY
If you can provide documentation that demonstrates your eligibility, reply to this email.
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' %}
If you have questions or comments, reply to this email.
{% elif domain_request.rejection_reason == 'naming_not_met' %}
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
general public. Learn more about naming requirements for your type of organization

View file

@ -1,6 +1,6 @@
{% load static %}
<section class="section--outlined domain-requests">
<section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row">
{% if portfolio is None %}
<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">
<form class="usa-search usa-search--small" method="POST" role="search">
{% 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
</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
class="usa-input"
id="domain-requests__search-field"
@ -33,7 +33,7 @@
</section>
</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">
<caption class="sr-only">Your domain requests</caption>
<thead>
@ -58,7 +58,7 @@
<p>You haven't requested any domains.</p>
</div>
<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>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">

View file

@ -1,6 +1,6 @@
{% 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">
{% if portfolio is None %}
<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">
<form class="usa-search usa-search--small" method="POST" role="search">
{% 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
</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
class="usa-input"
id="domains__search-field"
@ -33,7 +33,102 @@
</section>
</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">
<caption class="sr-only">Your registered domains</caption>
<thead>
@ -61,7 +156,7 @@
<div class="domains__no-data display-none">
<p>You don't have any registered domains.</p>
<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">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>
@ -70,7 +165,7 @@
</p>
</div>
<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>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">

View file

@ -77,11 +77,11 @@
</fieldset>
<div>
<button type="submit" name="contact_setup_save_button" class="usa-button ">
<button type="submit" name="user_setup_save_button" class="usa-button ">
Save
</button>
{% 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 }}
</button>
{% endif %}

View file

@ -13,10 +13,10 @@
</h3>
<div class="usa-summary-box__text">
<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>Title or role in your organization: <b>{{ user.contact.title }}</b></li>
<li>Phone: <b>{{ user.contact.phone.as_national }}</b></li>
<li>Title or role in your organization: <b>{{ user.title }}</b></li>
<li>Phone: <b>{{ user.phone.as_national }}</b></li>
</ul>
</div>
</div>

View file

@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %}
</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" %}
{{ field.value }}
{% else %}

View file

@ -112,8 +112,11 @@
<div class="text-right">
<a
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>
</a>
</div>

View file

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

View file

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

View file

@ -3,7 +3,7 @@
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="">
<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">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>

View file

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

View file

@ -44,6 +44,7 @@ from registrar.models import (
UserGroup,
TransitionDomain,
)
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import (
@ -242,15 +243,11 @@ class TestDomainAdmin(MockEppLib, WebTest):
username="MrMeoward",
first_name="Meoward",
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
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve()
@ -288,7 +285,7 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertContains(response, "(555) 555 5556")
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
# == Check for the senior_official == #
self.assertContains(response, "testy@town.com")
self.assertContains(response, "Chief Tester")
self.assertContains(response, "(555) 555 5555")
@ -374,9 +371,9 @@ class TestDomainAdmin(MockEppLib, WebTest):
# 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]))
# 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
domain_ex_date = Domain.objects.get(id=domain.id).expiration_date
self.assertEqual(domain_ex_date, date(2023, 5, 25))
@ -400,7 +397,6 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
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
expected_message = "Successfully extended the expiration date."
@ -519,70 +515,10 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Follow the response
response = response.follow()
# This value is based off of the current year - the expiration date.
# 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.
# Assert that it is calling the function with the default 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
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)
renew_mock.assert_has_calls([call()], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
@ -996,6 +932,32 @@ class TestDomainRequestAdmin(MockEppLib):
)
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
def test_has_model_description(self):
"""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)
@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."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Create a mock request
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
domain_request.status = status
@ -1453,6 +1420,9 @@ class TestDomainRequestAdmin(MockEppLib):
if 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
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
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.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)
@ -1525,11 +1496,11 @@ class TestDomainRequestAdmin(MockEppLib):
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test the email sent out for questionable_ao
questionable_ao = DomainRequest.ActionNeededReasons.QUESTIONABLE_AUTHORIZING_OFFICIAL
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_ao)
# Test that a custom email is sent out for questionable_so
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
self.assert_email_is_accurate(
"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)
@ -1540,6 +1511,43 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before
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):
"""When transitioning to submitted from started or withdrawn on a domain request,
an email is sent out.
@ -2128,15 +2136,11 @@ class TestDomainRequestAdmin(MockEppLib):
username="MrMeoward",
first_name="Meoward",
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
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 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 = [
# Field, expected value
("title", "Treat inspector"),
("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"),
]
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.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)
expected_ao_fields = [
expected_so_fields = [
# Field, expected value
("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 == #
self.assertContains(response, "testy2@town.com")
@ -2284,69 +2288,71 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "When a domain request is in ineligible status")
self.assertContains(response, "Yes, select ineligible status")
@less_console_noise_decorator
def test_readonly_when_restricted_creator(self):
with less_console_noise():
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
domain_request.creator.status = User.RESTRICTED
domain_request.creator.save()
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
domain_request.creator.status = User.RESTRICTED
domain_request.creator.save()
request = self.factory.get("/")
request.user = self.superuser
request = self.factory.get("/")
request.user = self.superuser
readonly_fields = self.admin.get_readonly_fields(request, domain_request)
readonly_fields = self.admin.get_readonly_fields(request, domain_request)
expected_fields = [
"other_contacts",
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"id",
"created_at",
"updated_at",
"status",
"rejection_reason",
"action_needed_reason",
"action_needed_reason_email",
"federal_agency",
"portfolio",
"creator",
"investigator",
"generic_org_type",
"is_election_board",
"organization_type",
"federally_recognized_tribe",
"state_recognized_tribe",
"tribe_name",
"federal_type",
"organization_name",
"address_line1",
"address_line2",
"city",
"state_territory",
"zipcode",
"urbanization",
"about_your_organization",
"authorizing_official",
"approved_domain",
"requested_domain",
"submitter",
"purpose",
"no_other_contacts_rationale",
"anything_else",
"has_anything_else_text",
"cisa_representative_email",
"cisa_representative_first_name",
"cisa_representative_last_name",
"has_cisa_representative",
"is_policy_acknowledged",
"submission_date",
"notes",
"alternative_domains",
]
expected_fields = [
"other_contacts",
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
"id",
"created_at",
"updated_at",
"status",
"rejection_reason",
"action_needed_reason",
"action_needed_reason_email",
"federal_agency",
"portfolio",
"sub_organization",
"creator",
"investigator",
"generic_org_type",
"is_election_board",
"organization_type",
"federally_recognized_tribe",
"state_recognized_tribe",
"tribe_name",
"federal_type",
"organization_name",
"address_line1",
"address_line2",
"city",
"state_territory",
"zipcode",
"urbanization",
"about_your_organization",
"senior_official",
"approved_domain",
"requested_domain",
"submitter",
"purpose",
"no_other_contacts_rationale",
"anything_else",
"has_anything_else_text",
"cisa_representative_email",
"cisa_representative_first_name",
"cisa_representative_last_name",
"has_cisa_representative",
"is_policy_acknowledged",
"submission_date",
"notes",
"alternative_domains",
]
self.assertEqual(readonly_fields, expected_fields)
self.assertEqual(readonly_fields, expected_fields)
def test_readonly_fields_for_analyst(self):
with less_console_noise():
@ -2361,6 +2367,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
"creator",
"about_your_organization",
"requested_domain",
@ -2391,6 +2398,7 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
]
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.user = self.superuser
request.session = {}
# Define a custom implementation for is_active
def custom_is_active(self):
return domain_is_active # Override to return True
@ -2787,6 +2797,7 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete()
Contact.objects.all().delete()
Website.objects.all().delete()
SeniorOfficial.objects.all().delete()
self.mock_client.EMAILS_SENT.clear()
@ -2968,6 +2979,38 @@ class TestDomainInformationAdmin(TestCase):
Domain.objects.all().delete()
Contact.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
def test_admin_can_see_cisa_region_federal(self):
@ -3164,15 +3207,11 @@ class TestDomainInformationAdmin(TestCase):
username="MrMeoward",
first_name="Meoward",
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
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
domain_request.approve()
@ -3194,16 +3233,16 @@ class TestDomainInformationAdmin(TestCase):
# == 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
# (Otherwise this test will fail if we change classes, etc)
expected_creator_fields = [
# Field, expected value
("title", "Treat inspector"),
("email", "meoward.jones@igorville.gov"),
("phone", "(555) 123 12345"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields)
self.assertContains(response, "meoward.jones@igorville.gov")
# Check for the field itself
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.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
# == Check for the senior_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
expected_so_fields = [
# Field, expected value
("title", "Chief Tester"),
("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)
@ -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")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
@ -3722,6 +3761,7 @@ class AuditedAdminTest(TestCase):
self.site = AdminSite()
self.factory = RequestFactory()
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):
with less_console_noise():
@ -3773,7 +3813,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_request(self):
with less_console_noise():
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.investigator.field,
DomainRequest.creator.field,
@ -3831,7 +3873,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_information(self):
with less_console_noise():
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.creator.field,
(DomainInformation.domain.field, ["name"]),
@ -3864,7 +3908,6 @@ class AuditedAdminTest(TestCase):
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
@ -3941,7 +3984,8 @@ class AuditedAdminTest(TestCase):
if last_name is None:
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
else:
return None
@ -4110,9 +4154,7 @@ class TestContactAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [
"user",
]
expected_fields = ["email"]
self.assertEqual(readonly_fields, expected_fields)
@ -4128,15 +4170,18 @@ class TestContactAdmin(TestCase):
self.assertEqual(readonly_fields, expected_fields)
def test_change_view_for_joined_contact_five_or_less(self):
"""Create a contact, join it to 4 domain requests. The 5th join will be a user.
Assert that the warning on the contact form lists 5 joins."""
"""Create a contact, join it to 4 domain requests.
Assert that the warning on the contact form lists 4 joins."""
with less_console_noise():
self.client.force_login(self.superuser)
# Create an instance of the model
contact, _ = Contact.objects.get_or_create(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_request2 = completed_domain_request(submitter=contact, name="city2.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>"
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
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>",
)
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."""
with less_console_noise():
self.client.force_login(self.superuser)
# Create an instance of the model
# join it to 5 domain requests. The 6th join will be a user.
contact, _ = Contact.objects.get_or_create(user=self.staffuser)
# join it to 6 domain requests.
contact, _ = Contact.objects.get_or_create(
first_name="Henry",
last_name="McFakerson",
)
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
domain_request4 = completed_domain_request(submitter=contact, name="city4.gov")
domain_request5 = completed_domain_request(submitter=contact, name="city5.gov")
completed_domain_request(submitter=contact, name="city6.gov")
with patch("django.contrib.messages.warning") as mock_warning:
# Use the test client to simulate the request
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))

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("Federal", body)
self.assertIn("Authorizing official:", body)
self.assertIn("Senior official:", body)
self.assertIn("Testy Tester", body)
self.assertIn(".gov domain:", body)
self.assertIn("city.gov", body)
@ -177,7 +177,7 @@ class TestEmails(TestCase):
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("About your organization:", body)
# 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
def test_submission_confirmation_anything_else_spacing(self):

View file

@ -8,7 +8,7 @@ from registrar.forms.domain_request_wizard import (
AlternativeDomainForm,
CurrentSitesForm,
DotGovDomainForm,
AuthorizingOfficialForm,
SeniorOfficialForm,
OrganizationContactForm,
YourContactForm,
OtherContactsForm,
@ -217,9 +217,9 @@ class TestFormValidation(MockEppLib):
["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."""
form = AuthorizingOfficialForm(data={"email": "boss@boss"})
form = SeniorOfficialForm(data={"email": "boss@boss"})
self.assertEqual(
form.errors["email"],
["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:
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
for table_name in table_names:
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",
state_territory="CA",
zipcode="12345-6789",
authorizing_official=contact,
senior_official=contact,
requested_domain=domain,
submitter=contact,
purpose="Igorville rules!",
@ -179,7 +179,7 @@ class TestDomainRequest(TestCase):
address_line2="APT 1A",
state_territory="CA",
zipcode="12345-6789",
authorizing_official=contact,
senior_official=contact,
submitter=contact,
purpose="Igorville rules!",
anything_else="All of Igorville loves the dotgov program.",
@ -1209,32 +1209,25 @@ class TestUser(TestCase):
# test with a user with contact info defined
self.assertTrue(self.user.has_contact_info())
# test with a user without contact info defined
self.user.contact.title = None
self.user.contact.email = None
self.user.contact.phone = None
self.user.title = None
self.user.email = None
self.user.phone = None
self.assertFalse(self.user.has_contact_info())
class TestContact(TestCase):
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.user, _ = User.objects.get_or_create(
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.domain_request = DomainRequest.objects.create(creator=self.user, authorizing_official=self.contact_as_ao)
self.contact_as_so, _ = Contact.objects.get_or_create(email="newguy@igorville.gov")
self.domain_request = DomainRequest.objects.create(creator=self.user, senior_official=self.contact_as_so)
def tearDown(self):
super().tearDown()
@ -1242,98 +1235,15 @@ class TestContact(TestCase):
Contact.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):
"""Test the Contact model method, has_more_than_one_join"""
# test for a contact which has one user defined
self.assertFalse(self.contact.has_more_than_one_join("user"))
self.assertTrue(self.contact.has_more_than_one_join("authorizing_official"))
# 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"))
# test for a contact which is assigned as a senior official on a domain request
self.assertFalse(self.contact_as_so.has_more_than_one_join("senior_official"))
self.assertTrue(self.contact_as_so.has_more_than_one_join("submitted_domain_requests"))
def test_has_contact_info(self):
"""Test that has_contact_info properly returns"""
self.contact.title = "Title"
# test with a contact with contact info defined
self.assertTrue(self.contact.has_contact_info())
# test with a contact without contact info defined
@ -1660,7 +1570,7 @@ class TestDomainRequestIncomplete(TestCase):
self.user = get_user_model().objects.create(
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",
last_name="Meoward",
title="Chief Cat",
@ -1695,7 +1605,7 @@ class TestDomainRequestIncomplete(TestCase):
address_line1="address 1",
state_territory="CA",
zipcode="94044",
authorizing_official=ao,
senior_official=so,
requested_domain=draft_domain,
purpose="Some purpose",
submitter=you,
@ -1793,11 +1703,11 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.save()
self.assertTrue(self.domain_request._is_organization_name_and_address_complete())
def test_is_authorizing_official_complete(self):
self.assertTrue(self.domain_request._is_authorizing_official_complete())
self.domain_request.authorizing_official = None
def test_is_senior_official_complete(self):
self.assertTrue(self.domain_request._is_senior_official_complete())
self.domain_request.senior_official = None
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):
self.assertTrue(self.domain_request._is_requested_domain_complete())

File diff suppressed because it is too large Load diff

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")
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"
).get()
expected_domain_information = DomainInformation(
@ -411,7 +411,7 @@ class TestOrganizationMigration(TestCase):
city="Columbus",
state_territory="Oh",
zipcode="43268",
authorizing_official=expected_ao,
senior_official=expected_so,
domain=_domain,
)
# 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")
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"
).get()
expected_domain_information = DomainInformation(
@ -467,7 +467,7 @@ class TestOrganizationMigration(TestCase):
city="Olympus",
state_territory="MA",
zipcode="12345",
authorizing_official=expected_ao,
senior_official=expected_so,
domain=_domain,
)
# Given that these are different objects, this needs to be set

View file

@ -57,12 +57,10 @@ class TestWithUser(MockEppLib):
last_name = "Last"
email = "info@example.com"
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"
self.user.contact.title = title
self.user.contact.save()
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, title=title, email=email, phone=phone
)
username_regular_incomplete = "test_regular_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)
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")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact,
senior_official=contact,
submitter=contact_user,
)
domain_request.other_contacts.set([contact_2])
@ -392,7 +393,7 @@ class HomeTests(TestWithUser):
creator=self.user,
requested_domain=site_2,
status=DomainRequest.DomainRequestStatus.STARTED,
authorizing_official=contact_2,
senior_official=contact_2,
submitter=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")
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)
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:
edge_case = Contact.objects.filter(id=contact_2.id).get()
except Contact.DoesNotExist:
@ -446,14 +442,17 @@ class HomeTests(TestWithUser):
)
# 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")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact,
senior_official=contact,
submitter=contact_user,
)
domain_request.other_contacts.set([contact_2])
@ -464,7 +463,7 @@ class HomeTests(TestWithUser):
creator=self.user,
requested_domain=site_2,
status=DomainRequest.DomainRequestStatus.STARTED,
authorizing_official=contact_2,
senior_official=contact_2,
submitter=contact_shared,
)
domain_request_2.other_contacts.set([contact_shared])
@ -539,6 +538,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self._set_session_cookie()
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
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"""
@ -560,7 +602,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertContains(finish_setup_page, "Enter your phone number.")
# 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
finish_setup_form = finish_setup_page.form
@ -577,6 +619,49 @@ class FinishUserProfileTests(TestWithUser, WebTest):
completed_setup_page = self.app.get(reverse("home"))
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
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"""
@ -598,10 +683,11 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertContains(finish_setup_page, "Enter your phone number.")
# 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
finish_setup_form = finish_setup_page.form
finish_setup_form["first_name"] = "firstname"
finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO"
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
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)
@ -730,6 +816,8 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
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
# 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.assertNotContains(
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):
"""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")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user,
senior_official=contact_user,
submitter=contact_user,
)
with override_flag("profile_feature", active=True):
@ -884,13 +975,16 @@ class UserProfileTests(TestWithUser, WebTest):
def test_request_when_profile_feature_off(self):
"""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")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user,
senior_official=contact_user,
submitter=contact_user,
)
with override_flag("profile_feature", active=False):
@ -965,7 +1059,7 @@ class PortfoliosTests(TestWithUser, WebTest):
# Assert that we're on the right page
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
def test_no_redirect_when_org_flag_false(self):

View file

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

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator
class GetDomainsJsonTest(TestWithUser, WebTest):
@ -11,9 +12,9 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.app.set_user(self.user.username)
# Create test domains
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="active")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="inactive")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-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="dns needed")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
# Create UserDomainRoles
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()
@less_console_noise_decorator
def test_get_domains_json_unauthenticated(self):
"""for an unauthenticated user, test that the user is redirected for auth"""
self.app.reset()
@ -32,6 +34,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
response = self.client.get(reverse("get_domains_json"))
self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_domains_json_authenticated(self):
"""Test that an authenticated user gets the list of 3 domains."""
response = self.app.get(reverse("get_domains_json"))
@ -102,6 +105,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
)
self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
def test_get_domains_json_search(self):
"""Test search."""
# Define your URL variables as a dictionary
@ -131,6 +135,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
domains[0],
)
@less_console_noise_decorator
def test_pagination(self):
"""Test that pagination is correct in the response"""
response = self.app.get(reverse("get_domains_json"), {"page": 1})
@ -143,6 +148,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
@less_console_noise_decorator
def test_sorting(self):
"""test that sorting works properly in the response"""
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"]]
self.assertEqual(expiration_dates, sorted(expiration_dates))
@less_console_noise_decorator
def test_sorting_by_state_display(self):
"""test that the state_display sorting works properly"""
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
states = [domain["state_display"] for domain in data["domains"]]
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 domain request page
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
# ---- AUTHORIZING OFFICIAL PAGE ----
# ---- SENIOR OFFICIAL PAGE ----
# Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow()
ao_form = ao_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com"
so_page = org_contact_result.follow()
so_form = so_page.forms[0]
so_form["senior_official-first_name"] = "Testy ATO"
so_form["senior_official-last_name"] = "Tester ATO"
so_form["senior_official-title"] = "Chief Tester"
so_form["senior_official-email"] = "testy@town.com"
# test next button
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
domain_request = DomainRequest.objects.get() # there's only one
self.assertEqual(domain_request.authorizing_official.first_name, "Testy ATO")
self.assertEqual(domain_request.authorizing_official.last_name, "Tester ATO")
self.assertEqual(domain_request.authorizing_official.title, "Chief Tester")
self.assertEqual(domain_request.authorizing_official.email, "testy@town.com")
self.assertEqual(domain_request.senior_official.first_name, "Testy ATO")
self.assertEqual(domain_request.senior_official.last_name, "Tester ATO")
self.assertEqual(domain_request.senior_official.title, "Chief Tester")
self.assertEqual(domain_request.senior_official.email, "testy@town.com")
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(ao_result.status_code, 302)
self.assertEqual(ao_result["Location"], "/request/current_sites/")
self.assertEqual(so_result.status_code, 302)
self.assertEqual(so_result["Location"], "/request/current_sites/")
num_pages_tested += 1
# ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page
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-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 domain request page
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
# ---- AUTHORIZING OFFICIAL PAGE ----
# ---- SENIOR OFFICIAL PAGE ----
# Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow()
ao_form = ao_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com"
so_page = org_contact_result.follow()
so_form = so_page.forms[0]
so_form["senior_official-first_name"] = "Testy ATO"
so_form["senior_official-last_name"] = "Tester ATO"
so_form["senior_official-title"] = "Chief Tester"
so_form["senior_official-email"] = "testy@town.com"
# test next button
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
domain_request = DomainRequest.objects.get() # there's only one
self.assertEqual(domain_request.authorizing_official.first_name, "Testy ATO")
self.assertEqual(domain_request.authorizing_official.last_name, "Tester ATO")
self.assertEqual(domain_request.authorizing_official.title, "Chief Tester")
self.assertEqual(domain_request.authorizing_official.email, "testy@town.com")
self.assertEqual(domain_request.senior_official.first_name, "Testy ATO")
self.assertEqual(domain_request.senior_official.last_name, "Tester ATO")
self.assertEqual(domain_request.senior_official.title, "Chief Tester")
self.assertEqual(domain_request.senior_official.email, "testy@town.com")
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(ao_result.status_code, 302)
self.assertEqual(ao_result["Location"], "/request/current_sites/")
self.assertEqual(so_result.status_code, 302)
self.assertEqual(so_result["Location"], "/request/current_sites/")
num_pages_tested += 1
# ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page
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-0-website"] = "www.city.com"
@ -1576,7 +1576,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# has 1 "other contact" assigned to it
# 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",
last_name="Tester",
title="Chief Tester",
@ -1607,7 +1607,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
status="started",
@ -1703,7 +1703,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# has 2 "other contact" assigned to it
# 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",
last_name="Tester",
title="Chief Tester",
@ -1741,7 +1741,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
status="started",
@ -1784,7 +1784,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# has 1 "other contact" assigned to it
# 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",
last_name="Tester",
title="Chief Tester",
@ -1815,7 +1815,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
status="started",
@ -1861,7 +1861,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# has 1 "other contact" assigned to it
# 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",
last_name="Tester",
title="Chief Tester",
@ -1892,7 +1892,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
status="started",
@ -1937,7 +1937,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# has 1 "other contact" assigned to it
# We'll do it from scratch
ao, _ = Contact.objects.get_or_create(
so, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
@ -1968,7 +1968,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
status="started",
@ -2017,9 +2017,9 @@ class DomainRequestTests(TestWithUser, WebTest):
# Populate the database with a domain request that
# 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
ao, _ = Contact.objects.get_or_create(
so, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
@ -2043,17 +2043,17 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
submitter=you,
creator=self.user,
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
# 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
other_contact_pk = ao.id
other_contact_pk = so.id
# prime the form by visiting /edit
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]
self.assertNotEquals(other_contact_pk, other_contact.id)
self.assertEquals("Testy2", other_contact.first_name)
# assert that the authorizing official is not updated
authorizing_official = domain_request.authorizing_official
self.assertEquals("Testy", authorizing_official.first_name)
# assert that the senior official is not updated
senior_official = domain_request.senior_official
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:
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,
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
# has an authorizing_official (ao)
# has a senior_official (so)
# We'll do it from scratch
ao, _ = Contact.objects.get_or_create(
so, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
@ -2115,14 +2115,14 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
creator=self.user,
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
ao_pk = ao.id
so_pk = so.id
# prime the form by visiting /edit
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]
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)
ao_form = ao_page.forms[0]
so_form = so_page.forms[0]
# 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
ao_form["authorizing_official-first_name"] = "Testy2"
so_form["senior_official-first_name"] = "Testy2"
# Submit the updated form
ao_form.submit()
so_form.submit()
domain_request.refresh_from_db()
# assert AO is updated "in place"
updated_ao = domain_request.authorizing_official
self.assertEquals(ao_pk, updated_ao.id)
self.assertEquals("Testy2", updated_ao.first_name)
# assert SO is updated "in place"
updated_so = domain_request.senior_official
self.assertEquals(so_pk, updated_so.id)
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:
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,
the domain request is linked to a new Contact, and the new Contact is updated."""
# 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
# We'll do it from scratch
ao, _ = Contact.objects.get_or_create(
so, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
@ -2181,16 +2181,16 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
creator=self.user,
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
# and not updated, and that the new ao has a new id
ao_pk = ao.id
# and not updated, and that the new so has a new id
so_pk = so.id
# prime the form by visiting /edit
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]
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)
ao_form = ao_page.forms[0]
so_form = so_page.forms[0]
# 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
ao_form["authorizing_official-first_name"] = "Testy2"
so_form["senior_official-first_name"] = "Testy2"
# Submit the updated form
ao_form.submit()
so_form.submit()
domain_request.refresh_from_db()
# assert that the other contact is not updated
other_contacts = domain_request.other_contacts.all()
other_contact = other_contacts[0]
self.assertEquals(ao_pk, other_contact.id)
self.assertEquals(so_pk, other_contact.id)
self.assertEquals("Testy", other_contact.first_name)
# assert that the authorizing official is updated
authorizing_official = domain_request.authorizing_official
self.assertEquals("Testy2", authorizing_official.first_name)
# assert that the senior official is updated
senior_official = domain_request.senior_official
self.assertEquals("Testy2", senior_official.first_name)
def test_edit_submitter_in_place(self):
"""When you:
@ -2421,7 +2421,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# and the step is on the sidebar list.
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:"))
# django-webtest does not handle cookie-based sessions well because it keeps
# 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)
org_contact_result = org_contact_form.submit()
# ---- AO CONTACT PAGE ----
# ---- SO CONTACT PAGE ----
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow()
self.assertContains(ao_page, "Executive branch federal agencies")
so_page = org_contact_result.follow()
self.assertContains(so_page, "Executive branch federal agencies")
# Go back to organization type page and change type
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)
type_form["generic_org_type-generic_org_type"] = "city"
type_result = type_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
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)
ao_page = election_page.click(str(self.TITLES["authorizing_official"]), index=0)
self.assertContains(ao_page, "Domain requests from cities")
so_page = election_page.click(str(self.TITLES["senior_official"]), index=0)
self.assertContains(so_page, "Domain requests from cities")
def test_domain_request_dotgov_domain_dynamic_text(self):
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)
org_contact_result = org_contact_form.submit()
# ---- AO CONTACT PAGE ----
# ---- SO CONTACT PAGE ----
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
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_page = org_contact_result.follow()
ao_form = ao_page.forms[0]
ao_form["authorizing_official-first_name"] = "Testy ATO"
ao_form["authorizing_official-last_name"] = "Tester ATO"
ao_form["authorizing_official-title"] = "Chief Tester"
ao_form["authorizing_official-email"] = "testy@town.com"
so_page = org_contact_result.follow()
so_form = so_page.forms[0]
so_form["senior_official-first_name"] = "Testy ATO"
so_form["senior_official-last_name"] = "Tester ATO"
so_form["senior_official-title"] = "Chief Tester"
so_form["senior_official-email"] = "testy@town.com"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
ao_result = ao_form.submit()
so_result = so_form.submit()
# ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page
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-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.
"""
ao, _ = Contact.objects.get_or_create(
so, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
@ -2661,7 +2661,7 @@ class DomainRequestTests(TestWithUser, WebTest):
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
senior_official=so,
requested_domain=domain,
submitter=you,
creator=self.user,
@ -2699,7 +2699,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# page = self.app.get(url)
# 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)
# page = self.app.get(url)
# self.assertNotContains(page, "VALUE")
@ -2974,14 +2974,17 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
)
# 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")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
authorizing_official=contact,
senior_official=contact,
submitter=contact_user,
)
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
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
self.assertContains(detail_page, "#check_circle", count=5)
# Type of organization

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