Merge remote-tracking branch 'origin' into ab/645-implement-test-cases-registry-integration

This commit is contained in:
Alysia Broddrick 2023-08-23 07:54:04 -07:00
commit c9cadd3401
No known key found for this signature in database
GPG key ID: 03917052CD0F06B7
57 changed files with 2238 additions and 218 deletions

View file

@ -3,7 +3,7 @@ name: Developer Onboarding
about: Onboarding steps for developers.
title: 'Developer Onboarding: GH_HANDLE'
labels: dev, onboarding
assignees: loganmeetsworld
assignees: abroddrick
---
@ -16,7 +16,7 @@ assignees: loganmeetsworld
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
- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check.
- [ ] 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/)
## Access
@ -24,27 +24,27 @@ There are several tools we use locally that you will need to have.
### Steps for the onboardee
- [ ] Setup [commit signing in Github](#setting-up-commit-signing) and with git locally.
- [ ] [Create a cloud.gov account](https://cloud.gov/docs/getting-started/accounts/)
- [ ] Have an admin add you to the CISA Github organization and Dotgov Team.
- [ ] Email github@cisa.dhs.gov (cc: Cameron) to add you to the [CISA Github organization](https://github.com/getgov) and [.gov Team](https://github.com/orgs/cisagov/teams/gov).
- [ ] Ensure you can login to your cloud.gov account via the CLI
```bash
cf login -a api.fr.cloud.gov --sso
```
- [ ] Have an admin add you to cloud.gov org and set up your [sandbox developer space](#setting-up-developer-sandbox). Ensure you can deploy to your sandbox space.
- [ ] Have an admin add you to our login.gov sandbox team (`.gov registrar poc`) via the [dashboard](https://dashboard.int.identitysandbox.gov/).
- [ ] Have an admin add you to our login.gov sandbox team (`.gov Registrar`) via the [dashboard](https://dashboard.int.identitysandbox.gov/).
**Note:** As mentioned in the [Login documentation](https://developers.login.gov/testing/), the sandbox Login account is different account from your regular, production Login account. If you have not created a Login account for the sandbox before, you will need to create a new account first.
- [ ] Optional- add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does.
### Steps for the onboarder
- [ ] Add the onboardee to cloud.gov org (cisa-getgov-prototyping)
- [ ] Add the onboardee to cloud.gov org (cisa-dotgov)
- [ ] Setup a [developer specific space for the new developer](#setting-up-developer-sandbox)
- [ ] Add the onboardee to our login.gov sandbox team (`.gov registrar poc`) via the [dashboard](https://dashboard.int.identitysandbox.gov/)
- [ ] Add the onboardee to our login.gov sandbox team (`.gov Registrar`) via the [dashboard](https://dashboard.int.identitysandbox.gov/)
## Documents to Review
- [ ] [Team Charter](https://docs.google.com/document/d/1xhMKlW8bMcxyF7ipsOYxw1SQYVi-lWPkcDHSUS6miNg/edit), in particular our Github Policy
- [ ] [Team Onboarding](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?usp=sharing)
- [ ] [Architecture Decision Records](https://github.com/cisagov/dotgov/tree/main/docs/architecture/decisions)
- [ ] [Contributing Policy](https://github.com/cisagov/dotgov/tree/main/CONTRIBUTING.md)
@ -80,6 +80,15 @@ You may need to add these two lines to your shell's rc file (e.g. `.bashrc` or `
GPG_TTY=$(tty)
export GPG_TTY
```
and then
```bash
source ~/.bashrc
```
or
```bash
source ~/.zshrc
```
## Setting up developer sandbox

View file

@ -1,13 +1,134 @@
# <!-- Use the title to describe PR changes in the imperative mood --> #
## Ticket
## 🗣 Description ##
Resolves #00
<!--Reminder, when a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist.
<!-- Describe the "what" of your changes in detail. -->
<!-- Please link to any relevant issues. -->
All other changes require just a single approving review.-->
## 💭 Motivation and context ##
## Changes
<!-- Why is this change required? -->
<!-- What problem does this change solve? How did you solve it? -->
<!-- Mention any related issue(s) here using appropriate keywords such -->
<!-- as "closes" or "resolves" to auto-close them on merge. -->
<!-- What was added, updated, or removed in this PR. -->
- Change 1
- Change 2
<!--
Please add/remove/edit any of the template below to fit the needs
of this specific PR.
--->
## Context for reviewers
<!--Background context, more in-depth details of the implementation, and anything else you'd like to call out or ask reviewers. -->
## Setup
<!-- Add any steps or code to run in this section to help others run your code.
Example 1:
```sh
echo "Code goes here"
```
Example 2: If the PR was to add a new link with a redirect, this section could simply be:
-go to /path/to/start/page
-click the blue link in the <insert location>
-notice user is redirected to <proper end location>
-->
## Code Review Verification Steps
### As the original developer, I have
#### Satisfied acceptance criteria and met development standards
- [ ] Met the acceptance criteria, or will meet them in a subsequent PR
- [ ] Created/modified automated tests
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited.
#### Ensured code standards are met (Original Developer)
- [ ] All new functions and methods are commented using plain language
- [ ] Did dependency updates in Pipfile also get changed in requirements.txt?
- [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values
#### Validated user-facing changes (if applicable)
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Add at least 1 designer as PR reviewer
### As a code reviewer, I have
#### Reviewed, tested, and left feedback about the changes
- [ ] Pulled this branch locally and tested it
- [ ] Reviewed this code and left comments
- [ ] Checked that all code is adequately covered by tests
- [ ] Made it clear which comments need to be addressed before this work is merged
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited.
#### Ensured code standards are met (Code reviewer)
- [ ] All new functions and methods are commented using plain language
- [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values
- [ ] (Rarely needed) Did dependency updates in Pipfile also get changed in requirements.txt?
#### Validated user-facing changes as a developer
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability
- [ ] Meets all designs and user flows provided by design/product
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers, the suggestion is to use ones that the developer didn't (check off which ones were used)
- [ ] Chrome
- [ ] Microsoft Edge
- [ ] FireFox
- [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user
**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist
### As a designer reviewer, I have
#### Verified that the changes match the design intention
- [ ] Checked that the design translated visually
- [ ] Checked behavior
- [ ] Checked different states (empty, one, some, error)
- [ ] Checked for landmarks, page heading structure, and links
- [ ] Tried to break the intended flow
#### Validated user-facing changes as a designer
- [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers (check off which ones were used)
- [ ] Chrome
- [ ] Microsoft Edge
- [ ] FireFox
- [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user
## Screenshots
<!-- If this PR makes visible interface changes, an image of the finished interface can help reviewers
and casual observers understand the context of the changes.
A before image is optional and can be included at the submitter's discretion.
Consider using an animated image to show an entire workflow.
You may want to use [GIPHY Capture](https://giphy.com/apps/giphycapture) for this! 📸
_Please frame images to show useful context but also highlight the affected regions._
--->

View file

@ -15,6 +15,10 @@ jobs:
|| startsWith(github.head_ref, 'rb/')
|| startsWith(github.head_ref, 'ko/')
|| startsWith(github.head_ref, 'gd/')
|| startsWith(github.head_ref, 'za/')
|| startsWith(github.head_ref, 'rh/')
|| startsWith(github.head_ref, 'nl/')
|| startsWith(github.head_ref, 'dk/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"
@ -49,7 +53,7 @@ jobs:
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
push_arguments: "-f ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml"
comment:

View file

@ -36,6 +36,6 @@ jobs:
with:
cf_username: ${{ secrets.CF_STABLE_USERNAME }}
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: stable
push_arguments: "-f ops/manifests/manifest-stable.yaml"

View file

@ -36,6 +36,6 @@ jobs:
with:
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: staging
push_arguments: "-f ops/manifests/manifest-staging.yaml"

View file

@ -15,12 +15,16 @@ on:
options:
- stable
- staging
- nl
- rh
- za
- gd
- rb
- ko
- ab
- bl
- rjm
- dk
jobs:
migrate:
@ -34,6 +38,6 @@ jobs:
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"

View file

@ -16,12 +16,16 @@ on:
options:
- stable
- staging
- nl
- rh
- za
- gd
- rb
- ko
- ab
- bl
- rjm
- dk
jobs:
reset-db:
@ -35,7 +39,7 @@ jobs:
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
@ -44,7 +48,7 @@ jobs:
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
@ -53,6 +57,6 @@ jobs:
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"

View file

@ -9,6 +9,22 @@ There are a handful of things we do not commit to the repository:
- Compliance documentation that includes IP addresses
- Secrets of any kind
## Branch naming convention
For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description
Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable.
## Approvals
When a code change is made that is not user facing, then the following is required:
- a developer approves the PR
When a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist
Content or document updates require a single person to approve.
## Project Management
We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking.

View file

@ -8,7 +8,7 @@ Accepted
## Context
We need a place to run our application for the registrar. Cloud.gov is a FIMSA Moderate Fedramped solution that supports our language and framework selections.
We need a place to run our application for the registrar. Cloud.gov is a FISMA Moderate FedRAMP'd solution that supports our language and framework selections.
## Decision
@ -16,10 +16,10 @@ To use cloud.gov to host our application(s).
## Consequences
Choosing cloud.gov for our solution means we are locked into its opinionated choices for our infrastructure. It forces us to run 12-factor applications. It doesn't support brokering for services we may need like email notifications.
It also means the compliance lift is much lighter. We do not need to prove we are compliance for the majority of our infrastructure and our runtime enviornment.
* Choosing Cloud.gov for our solution means we are assisted by its opinionated choices for our infrastructure. For example, it forces us to run 12-factor applications.
* It doesn't support brokering for services we may need like email notifications.
* The compliance lift is lighter. We can inherit Cloud.gov's controls for the majority of our infrastructure and our runtime enviornment.
## Alternatives Considered
Run our application on in either CISA's Azure or AWS environment with a continerized deployment.
Run our application on in either CISA's Azure or AWS environment with a containerized deployment.

View file

@ -34,6 +34,13 @@ In contrast to building an admin interface from scratch where development activi
involve _building up_, leveraging Django Admin will require carefully _pairing back_ the functionalities available to
users such as analysts.
On accessibility: Django admin is almost fully accessible out-of-the-box, the exceptions being tables, checkboxes, and
color contrast. We have remedied the first 2 with template overrides and the 3rd with theming (see below).
On USWDS and theming: Django admin brings its own high level design framework. We have determined that theming on top of Django (scss)
is easy and worthwhile, while overwriting Django's templates with USWDS is hard and provides little return on investment
([research PR](https://github.com/cisagov/getgov/pull/831)).
While we anticipate that Django Admin will meet (or even exceed) the user needs that we are aware of today, it is still
an open question whether Django Admin will be the long-term administrator tool of choice. A pivot away from Django Admin
in the future would of course mean starting from scratch at a later date, and potentially juggling two separate admin

View file

@ -0,0 +1,23 @@
# 22. Submit Domain Request User Flow
Date: 2023-07-18
## Status
Accepted
## Context
Historically, the .gov vendor managed initial identity verification and organizational affiliation for users that request a .gov domain. With the new registrar, _any user with a valid Login.gov account_ will be able to make a request. As a primary layer of abuse prevention (i.e., DDoSing the registry program with illegitimate requests), we need a way to stop new users from submitting multiple domain requests before they are known to the .gov registry. In this case, "known" means they have at least one approved domain application or existing domain.
## Considered Options
**Option 1:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. We would add a page alert informing the user that they cannot submit their application because they have an application in one of these "3" statuses (Submitted, In Review or Action Needed). They would still be able to create and edit new applications, just not submit them. The benefits of this option are that it would allow users to have multiple applications essentially in "draft mode" that are queued up and ready for submission after they are permitted to submit.
**Option 2:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. Additionally, we would remove the ability to edit any application with the started/withdrawn/rejected status, or start a new application. The benefit of this option is that a user would not be able to begin an action (submitting an application) that they are not allowed to complete.
## Decision
We have decided to go with option 1. New users of the registrar will need to have at least one approved application OR prior registered .gov domain in order to submit another application. We chose this option because we would like to allow users be able to work on applications, even if they are unable to submit them.
A [user flow diagram](https://miro.com/app/board/uXjVM3jz3Bs=/?share_link_id=875307531981) demonstrates our decision.

View file

@ -18,6 +18,20 @@ If you're new to Django, see [Getting Started with Django](https://www.djangopro
Visit the running application at [http://localhost:8080](http://localhost:8080).
### Troubleshooting
* If you are using Windows, you may need to change your [line endings](https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings). If not, you may not be able to run manage.py.
* Unix based operating systems (like macOS or Linux) handle line separators [differently than Windows does](https://superuser.com/questions/374028/how-are-n-and-r-handled-differently-on-linux-and-windows). This can break bash scripts in particular. In the case of manage.py, it uses *#!/usr/bin/env python* to access the Python executable. Since the script is still thinking in terms of unix line seperators, it may look for the executable *python\r* rather than *python* (since Windows cannot read the carriage return on its own) - thus leading to the error `usr/bin/env: 'python\r' no such file or directory`
* If you'd rather not change this globally, add a `.gitattributes` file in the project root with `* text eol=lf` as the text content, and [refresh the repo](https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings#refreshing-a-repository-after-changing-line-endings)
* If you are using a Mac with a M1 chip, and see this error `The chromium binary is not available for arm64.` or an error involving `puppeteer`, try adding this line below into your `.bashrc` or `.zshrc`.
```
export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
When completed, don't forget to rerun `docker-compose up`!
## Branch Conventions
We use the branch convention of `initials/branch-topic` (ex: `lmm/fix-footer`). This allows for automated deployment to a developer sandbox namespaced to the initials.
@ -66,7 +80,7 @@ The endpoint /admin can be used to view and manage site content, including but n
1. Login via login.gov
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4
4. in src/registrar/fixtures.py add to the ADMINS list in that file by adding your UUID as your username along with your first and last name. See below:
4. in src/registrar/fixtures.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
```
ADMINS = [
@ -79,8 +93,32 @@ The endpoint /admin can be used to view and manage site content, including but n
]
```
5. In the browser, navigate to /admins. To verify that all is working correctly, under "domain applications" you should see fake domains with various fake statuses.
5. In the browser, navigate to /admin. To verify that all is working correctly, under "domain applications" you should see fake domains with various fake statuses.
6. Add an optional email key/value pair
### Adding an Analyst to /admin
Analysts are a variant of the admin role with limited permissions. The process for adding an Analyst is much the same as adding an admin:
1. Login via login.gov (if you already exist as an admin, you will need to create a separate login.gov account for this: i.e. first.last+1@email.com)
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4 (this will be a different UUID than the one obtained from creating an admin)
4. in src/registrar/fixtures.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
```
STAFF = [
{
"username": "<UUID here>",
"first_name": "",
"last_name": "",
},
...
]
```
5. In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only.
6. Add an optional email key/value pair
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
## Adding to CODEOWNERS (optional)
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
@ -166,6 +204,17 @@ from .common import less_console_noise
# <test code goes here>
```
### Accessibility Testing in the browser
We use the [ANDI](https://www.ssa.gov/accessibility/andi/help/install.html) browser extension
from ssa.gov for accessibility testing outside the pipeline.
ANDI will get blocked by our CSP settings, so you will need to install the
[Disable Content-Security-Policy extension](https://chrome.google.com/webstore/detail/disable-content-security/ieelmcmcagommplceebfedjlakkhpden)
and activate it for the page you'd like to test.
Note - refresh after enabling the extension on a page but before clicking ANDI.
### Accessibility Scanning
The tool `pa11y-ci` is used to scan pages for compliance with a set of
@ -203,7 +252,7 @@ Assets are stored in `registrar/assets` during development and served from `regi
We utilize the [uswds-compile tool](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) from USWDS to compile and package USWDS assets.
## Making and view style changes
## Making and viewing style changes
When you run `docker-compose up` the `node` service in the container will begin to watch for changes in the `registrar/assets` folder, and will recompile once any changes are made.

View file

@ -18,3 +18,4 @@ auditlog | log entry | can view log entry
registrar | contact | can view contact
registrar | domain application | can change domain application
registrar | domain | can view domain
registrar | user | can view user

View file

@ -0,0 +1,29 @@
---
applications:
- name: getgov-dk
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# Public site base URL
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
routes:
- route: getgov-dk.app.cloud.gov
services:
- getgov-credentials
- getgov-dk-database

View file

@ -0,0 +1,29 @@
---
applications:
- name: getgov-nl
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
routes:
- route: getgov-nl.app.cloud.gov
services:
- getgov-credentials
- getgov-nl-database

View file

@ -0,0 +1,29 @@
---
applications:
- name: getgov-rh
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
routes:
- route: getgov-rh.app.cloud.gov
services:
- getgov-credentials
- getgov-rh-database

View file

@ -0,0 +1,29 @@
---
applications:
- name: getgov-za
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-za.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
routes:
- route: getgov-za.app.cloud.gov
services:
- getgov-credentials
- getgov-za-database

View file

@ -21,9 +21,9 @@ then
git checkout -b new-dev-sandbox-$1
fi
cf target -o cisa-getgov-prototyping
cf target -o cisa-dotgov
read -p "Are you logged in to the cisa-getgov-prototyping CF org above? (y/n) " -n 1 -r
read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]
then
@ -49,9 +49,9 @@ sed -i '' '/getgov-staging.app.cloud.gov/ {a\
echo "Creating new cloud.gov space for $1..."
cf create-space $1
cf target -o "cisa-getgov-prototyping" -s $1
cf bind-security-group public_networks_egress cisa-getgov-prototyping --space $1
cf bind-security-group trusted_local_networks_egress cisa-getgov-prototyping --space $1
cf target -o "cisa-dotgov" -s $1
cf bind-security-group public_networks_egress cisa-dotgov --space $1
cf bind-security-group trusted_local_networks_egress cisa-dotgov --space $1
echo "Creating new cloud.gov DB for $1. This usually takes about 5 minutes..."
cf create-service aws-rds micro-psql getgov-$1-database
@ -91,7 +91,7 @@ cd ..
cf push getgov-$1 -f ops/manifests/manifest-$1.yaml
read -p "Please provide the email of the space developer: " -r
cf set-space-role $REPLY cisa-getgov-prototyping $1 SpaceDeveloper
cf set-space-role $REPLY cisa-dotgov $1 SpaceDeveloper
read -p "Should we run migrations? (y/n) " -n 1 -r
echo

View file

@ -4,7 +4,7 @@
../ops/scripts/build.sh
# Deploy to sandbox
cf target -o cisa-getgov-prototyping -s $1
cf target -o cisa-dotgov -s $1
cf push getgov-$1 -f ../ops/manifests/manifest-$1.yaml
# migrations need to be run manually. Developers can use this command

View file

@ -20,9 +20,9 @@ then
git checkout -b remove-dev-sandbox-$1
fi
cf target -o cisa-getgov-prototyping -s $1
cf target -o cisa-dotgov -s $1
read -p "Are you logged in to the cisa-getgov-prototyping CF org above? (y/n) " -n 1 -r
read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]
then

View file

@ -9,8 +9,8 @@ if [ -z "$1" ]; then
exit 1
fi
cf target -o cisa-getgov-prototyping -s $1
read -p "Are you logged in to the cisa-getgov-prototyping CF org above and targeting the correct space? (y/n) " -n 1 -r
cf target -o cisa-dotgov -s $1
read -p "Are you logged in to the cisa-dotgov CF org above and targeting the correct space? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]
then

12
src/package-lock.json generated
View file

@ -4086,9 +4086,9 @@
}
},
"node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
@ -10148,9 +10148,9 @@
},
"dependencies": {
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true
}
}

View file

@ -1,16 +1,16 @@
import logging
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from registrar.models.utility.admin_sort_fields import AdminSortFields
from . import models
logger = logging.getLogger(__name__)
class AuditedAdmin(admin.ModelAdmin):
class AuditedAdmin(admin.ModelAdmin, AdminSortFields):
"""Custom admin to make auditing easier."""
def history_view(self, request, object_id, extra_context=None):
@ -23,9 +23,13 @@ class AuditedAdmin(admin.ModelAdmin):
)
)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Used to sort dropdown fields alphabetically but can be expanded upon"""
form_field = super().formfield_for_foreignkey(db_field, request, **kwargs)
return self.form_field_order_helper(form_field, db_field)
class ListHeaderAdmin(AuditedAdmin):
"""Custom admin to add a descriptive subheader to list views."""
def changelist_view(self, request, extra_context=None):
@ -93,12 +97,29 @@ class UserContactInline(admin.StackedInline):
model = models.Contact
class MyUserAdmin(UserAdmin):
class MyUserAdmin(BaseUserAdmin):
"""Custom user admin class to use our inlines."""
inlines = [UserContactInline]
def get_list_display(self, request):
if not request.user.is_superuser:
# Customize the list display for staff users
return ("email", "first_name", "last_name", "is_staff", "is_superuser")
# Use the default list display for non-staff users
return super().get_list_display(request)
def get_fieldsets(self, request, obj=None):
if not request.user.is_superuser:
# If the user doesn't have permission to change the model,
# show a read-only fieldset
return ((None, {"fields": []}),)
# If the user has permission to change the model, show all fields
return super().get_fieldsets(request, obj)
class HostIPInline(admin.StackedInline):
@ -224,7 +245,6 @@ class DomainAdmin(ListHeaderAdmin):
class ContactAdmin(ListHeaderAdmin):
"""Custom contact admin class to add search."""
search_fields = ["email", "first_name", "last_name"]
@ -336,18 +356,27 @@ class DomainApplicationAdmin(ListHeaderAdmin):
pass
elif obj.status == models.DomainApplication.SUBMITTED:
# This is an fsm in model which will throw an error if the
# transition condition is violated, so we call it on the
# original object which has the right status value, and pass
# the updated object which contains the up-to-date data
# for the side effects (like an email send). Same
# comment applies to original_obj method calls below.
original_obj.submit(updated_domain_application=obj)
elif obj.status == models.DomainApplication.INVESTIGATING:
original_obj.in_review(updated_domain_application=obj)
# transition condition is violated, so we roll back the
# status to what it was before the admin user changed it and
# let the fsm method set it. Same comment applies to
# transition method calls below.
obj.status = original_obj.status
obj.submit()
elif obj.status == models.DomainApplication.IN_REVIEW:
obj.status = original_obj.status
obj.in_review()
elif obj.status == models.DomainApplication.ACTION_NEEDED:
obj.status = original_obj.status
obj.action_needed()
elif obj.status == models.DomainApplication.APPROVED:
original_obj.approve(updated_domain_application=obj)
obj.status = original_obj.status
obj.approve()
elif obj.status == models.DomainApplication.WITHDRAWN:
original_obj.withdraw()
obj.status = original_obj.status
obj.withdraw()
elif obj.status == models.DomainApplication.REJECTED:
obj.status = original_obj.status
obj.reject()
else:
logger.warning("Unknown status selected in django admin")

View file

@ -0,0 +1,172 @@
@use "cisa_colors" as *;
@use "uswds-core" as *;
// We'll use Django's CSS vars: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#theming-support
// and assign USWDS theme vars whenever possible
// If needed (see below), we'll use the USWDS hex value
// As a last resort, we'll use CISA colors to supplement the palette
:root,
html[data-theme="light"] {
--primary: #{$theme-color-primary};
--secondary: #{$theme-color-primary-darkest};
--accent: #{$theme-color-accent-cool};
// --primary-fg: #fff;
// USWDS theme vars that are set to a token, such as #{$theme-color-base-darker}
// would interpolate to 'gray-cool-70' and output invalid CSS, so we use the hex
// source value instead: https://designsystem.digital.gov/design-tokens/color/system-tokens/
--body-fg: #3d4551;
// --body-bg: #fff;
--body-quiet-color: #{$theme-color-base-dark};
// --body-loud-color: #000;
--header-color: var( --primary-fg);
--header-branding-color: var( --primary-fg);
// --header-bg: var(--secondary);
// --header-link-color: var(--primary-fg);
--breadcrumbs-fg: #{$theme-color-accent-cool-lightest};
// --breadcrumbs-link-fg: var(--body-bg);
--breadcrumbs-bg: #{$theme-color-primary-dark};
// #{$theme-link-color} would interpolate to 'primary', so we use the source value instead
--link-fg: #{$theme-color-primary};
--link-hover-color: #{$theme-color-primary-darker};
// $theme-link-visited-color - violet-70v
--link-selected-fg: #54278f;
--hairline-color: #{$dhs-gray-15};
// $theme-color-base-lightest - gray-5
--border-color: #f0f0f0;
--error-fg: #{$theme-color-error};
--message-success-bg: #{$theme-color-success-lighter};
// $theme-color-warning-lighter - yellow-5
--message-warning-bg: #faf3d1;
--message-error-bg: #{$theme-color-error-lighter};
--darkened-bg: #{$dhs-gray-15}; /* A bit darker than --body-bg */
--selected-bg: var(--border-color); /* E.g. selected table cells */
--selected-row: var(--message-warning-bg);
// --button-fg: #fff;
// --button-bg: var(--secondary);
--button-hover-bg: #{$theme-color-primary-darker};
--default-button-bg: #{$theme-color-primary-dark};
--default-button-hover-bg: #{$theme-color-primary-darkest};
// #{$theme-color-base} - 'gray-cool-50'
--close-button-bg: #71767a;
// #{$theme-color-base-darker} - 'gray-cool-70'
--close-button-hover-bg: #3d4551;
--delete-button-bg: #{$theme-color-error};
--delete-button-hover-bg: #{$theme-color-error-dark};
// --object-tools-fg: var(--button-fg);
// --object-tools-bg: var(--close-button-bg);
// --object-tools-hover-bg: var(--close-button-hover-bg);
}
// Fold dark theme settings into our main CSS
// https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#theming-support > dark theme note
@media (prefers-color-scheme: dark) {
:root,
html[data-theme="dark"] {
// Edit the primary to meet accessibility requ.
--primary: #23485a;
--primary-fg: #f7f7f7;
--body-fg: #eeeeee;
--body-bg: #121212;
--body-quiet-color: #e0e0e0;
--body-loud-color: #ffffff;
--breadcrumbs-link-fg: #e0e0e0;
--breadcrumbs-bg: var(--primary);
--link-fg: #81d4fa;
--link-hover-color: #4ac1f7;
--link-selected-fg: #6f94c6;
--hairline-color: #272727;
--border-color: #353535;
--error-fg: #e35f5f;
--message-success-bg: #006b1b;
--message-warning-bg: #583305;
--message-error-bg: #570808;
--darkened-bg: #212121;
--selected-bg: #1b1b1b;
--selected-row: #00363a;
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
}
// Dark mode django (bug due to scss cascade) and USWDS tables
.change-list .usa-table,
.change-list .usa-table--striped tbody tr:nth-child(odd) td,
.change-list .usa-table--borderless thead th,
.change-list .usa-table thead td,
.change-list .usa-table thead th,
body.dashboard,
body.change-list,
body.change-form {
color: var(--body-fg);
}
}
// Firefox needs this to be specifically set
html[data-theme="dark"] {
.change-list .usa-table,
.change-list .usa-table--striped tbody tr:nth-child(odd) td,
.change-list .usa-table--borderless thead th,
.change-list .usa-table thead td,
.change-list .usa-table thead th,
body.dashboard,
body.change-list,
body.change-form {
color: var(--body-fg);
}
}
#branding h1 a:link, #branding h1 a:visited {
color: var(--primary-fg);
}
#branding h1,
h1, h2, h3 {
font-weight: font-weight('bold');
}
table > caption > a {
font-weight: font-weight('bold');
text-transform: none;
}
.change-list {
.usa-table--striped tbody tr:nth-child(odd) td,
.usa-table--striped tbody tr:nth-child(odd) th,
.usa-table td,
.usa-table th {
background-color: transparent;
}
}
#nav-sidebar {
padding-top: 20px;
}
// 'Delete button' layout bug
.submit-row a.deletelink {
height: auto!important;
}
// Keep th from collapsing
.min-width-25 {
min-width: 25px;
}
.min-width-81 {
min-width: 81px;
}

View file

@ -9,3 +9,7 @@
/*--------------------------------------------------
--- Custom Styles ---------------------------------*/
@forward "uswds-theme-custom-styles";
/*--------------------------------------------------
--- Admin ---------------------------------*/
@forward "admin";

View file

@ -571,12 +571,16 @@ SECURE_SSL_REDIRECT = True
ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-nl.app.cloud.gov",
"getgov-rh.app.cloud.gov",
"getgov-za.app.cloud.gov",
"getgov-gd.app.cloud.gov",
"getgov-rb.app.cloud.gov",
"getgov-ko.app.cloud.gov",
"getgov-ab.app.cloud.gov",
"getgov-bl.app.cloud.gov",
"getgov-rjm.app.cloud.gov",
"getgov-dk.app.cloud.gov",
"get.gov",
]

View file

@ -4,7 +4,6 @@ For more information see:
https://docs.djangoproject.com/en/4.0/topics/http/urls/
"""
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
@ -45,6 +44,10 @@ for step, view in [
urlpatterns = [
path("", views.index, name="home"),
path(
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
path("admin/", admin.site.urls),
path(
"application/<id>/edit/",
@ -114,20 +117,6 @@ urlpatterns = [
),
]
if not settings.DEBUG:
urlpatterns += [
# redirect to login.gov
path(
"admin/login/", RedirectView.as_view(pattern_name="login", permanent=False)
),
# redirect to login.gov
path(
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
]
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG
# was actually True. Instead, let's add these URLs any time we are able to

View file

@ -57,6 +57,21 @@ class UserFixture:
"first_name": "Ryan",
"last_name": "Brooks",
},
{
"username": "30001ee7-0467-4df2-8db2-786e79606060",
"first_name": "Zander",
"last_name": "Adkinson",
},
{
"username": "bb21f687-c773-4df3-9243-111cfd4c0be4",
"first_name": "Paul",
"last_name": "Kuykendall",
},
{
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca",
"last_name": "Hsieh",
},
]
STAFF = [
@ -64,12 +79,28 @@ class UserFixture:
"username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
"first_name": "Rachid-Analyst",
"last_name": "Mrad-Analyst",
"email": "rachid.mrad@gmail.com",
},
{
"username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
"first_name": "Alysia-Analyst",
"last_name": "Alysia-Analyst",
},
{
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
"first_name": "Zander-Analyst",
"last_name": "Adkinson-Analyst",
},
{
"username": "57ab5847-7789-49fe-a2f9-21d38076d699",
"first_name": "Paul-Analyst",
"last_name": "Kuykendall-Analyst",
},
{
"username": "e474e7a9-71ca-449d-833c-8a6e094dd117",
"first_name": "Rebecca-Analyst",
"last_name": "Hsieh-Analyst",
},
]
STAFF_PERMISSIONS = [
@ -85,6 +116,7 @@ class UserFixture:
"permissions": ["change_domainapplication"],
},
{"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]},
{"app_label": "registrar", "model": "user", "permissions": ["view_user"]},
]
@classmethod
@ -98,6 +130,8 @@ class UserFixture:
user.is_superuser = True
user.first_name = admin["first_name"]
user.last_name = admin["last_name"]
if "email" in admin.keys():
user.email = admin["email"]
user.is_staff = True
user.is_active = True
user.save()
@ -115,6 +149,8 @@ class UserFixture:
user.is_superuser = False
user.first_name = staff["first_name"]
user.last_name = staff["last_name"]
if "email" in admin.keys():
user.email = admin["email"]
user.is_staff = True
user.is_active = True
@ -201,11 +237,11 @@ class DomainApplicationFixture:
"organization_name": "Example - Submitted but pending Investigation",
},
{
"status": "investigating",
"status": "in review",
"organization_name": "Example - In Investigation",
},
{
"status": "investigating",
"status": "in review",
"organization_name": "Example - Approved",
},
{
@ -377,9 +413,9 @@ class DomainFixture(DomainApplicationFixture):
return
for user in users:
# approve one of each users investigating status domains
# approve one of each users in review status domains
application = DomainApplication.objects.filter(
creator=user, status=DomainApplication.INVESTIGATING
creator=user, status=DomainApplication.IN_REVIEW
).last()
logger.debug(f"Approving {application} for {user}")
application.approve()

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.2 on 2023-07-12 21:31
# Generated by Django 4.2.2 on 2023-07-13 17:56
# hand merged
from django.db import migrations
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0027_alter_domaininformation_address_line1_and_more"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="status",
field=django_fsm.FSMField(
choices=[
("started", "started"),
("submitted", "submitted"),
("in review", "in review"),
("action needed", "action needed"),
("approved", "approved"),
("withdrawn", "withdrawn"),
("rejected", "rejected"),
],
default="started",
max_length=50,
),
),
]

View file

@ -18,18 +18,22 @@ class DomainApplication(TimeStampedModel):
"""A registrant's application for a new domain."""
# #### Contants for choice fields ####
# #### Constants for choice fields ####
STARTED = "started"
SUBMITTED = "submitted"
INVESTIGATING = "investigating"
IN_REVIEW = "in review"
ACTION_NEEDED = "action needed"
APPROVED = "approved"
WITHDRAWN = "withdrawn"
REJECTED = "rejected"
STATUS_CHOICES = [
(STARTED, STARTED),
(SUBMITTED, SUBMITTED),
(INVESTIGATING, INVESTIGATING),
(IN_REVIEW, IN_REVIEW),
(ACTION_NEEDED, ACTION_NEEDED),
(APPROVED, APPROVED),
(WITHDRAWN, WITHDRAWN),
(REJECTED, REJECTED),
]
class StateTerritoryChoices(models.TextChoices):
@ -497,16 +501,13 @@ class DomainApplication(TimeStampedModel):
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)
@transition(field="status", source=[STARTED, WITHDRAWN], target=SUBMITTED)
def submit(self, updated_domain_application=None):
@transition(
field="status", source=[STARTED, ACTION_NEEDED, WITHDRAWN], target=SUBMITTED
)
def submit(self):
"""Submit an application that is started.
As a side effect, an email notification is sent.
This method is called in admin.py on the original application
which has the correct status value, but is passed the changed
application which has the up-to-date data that we'll use
in the email."""
As a side effect, an email notification is sent."""
# check our conditions here inside the `submit` method so that we
# can raise more informative exceptions
@ -522,53 +523,46 @@ class DomainApplication(TimeStampedModel):
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
if updated_domain_application is not None:
# A DomainApplication is being passed to this method (ie from admin)
updated_domain_application._send_status_update_email(
"submission confirmation",
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
)
else:
# Or this method is called with the right application
# for context, ie from views/application.py
self._send_status_update_email(
"submission confirmation",
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
)
@transition(field="status", source=SUBMITTED, target=INVESTIGATING)
def in_review(self, updated_domain_application):
@transition(field="status", source=SUBMITTED, target=IN_REVIEW)
def in_review(self):
"""Investigate an application that has been submitted.
As a side effect, an email notification is sent.
As a side effect, an email notification is sent."""
This method is called in admin.py on the original application
which has the correct status value, but is passed the changed
application which has the up-to-date data that we'll use
in the email."""
updated_domain_application._send_status_update_email(
self._send_status_update_email(
"application in review",
"emails/status_change_in_review.txt",
"emails/status_change_in_review_subject.txt",
)
@transition(field="status", source=[SUBMITTED, INVESTIGATING], target=APPROVED)
def approve(self, updated_domain_application=None):
@transition(field="status", source=[IN_REVIEW, REJECTED], target=ACTION_NEEDED)
def action_needed(self):
"""Send back an application that is under investigation or rejected.
As a side effect, an email notification is sent."""
self._send_status_update_email(
"action needed",
"emails/status_change_action_needed.txt",
"emails/status_change_action_needed_subject.txt",
)
@transition(
field="status", source=[SUBMITTED, IN_REVIEW, REJECTED], target=APPROVED
)
def approve(self):
"""Approve an application that has been submitted.
This has substantial side-effects because it creates another database
object for the approved Domain and makes the user who created the
application into an admin on that domain. It also triggers an email
notification.
This method is called in admin.py on the original application
which has the correct status value, but is passed the changed
application which has the up-to-date data that we'll use
in the email.
"""
notification."""
# create the domain
Domain = apps.get_model("registrar.Domain")
@ -587,24 +581,28 @@ class DomainApplication(TimeStampedModel):
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.ADMIN
)
if updated_domain_application is not None:
# A DomainApplication is being passed to this method (ie from admin)
updated_domain_application._send_status_update_email(
"application approved",
"emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt",
)
else:
self._send_status_update_email(
"application approved",
"emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt",
)
@transition(field="status", source=[SUBMITTED, INVESTIGATING], target=WITHDRAWN)
@transition(field="status", source=[SUBMITTED, IN_REVIEW], target=WITHDRAWN)
def withdraw(self):
"""Withdraw an application that has been submitted."""
@transition(field="status", source=[IN_REVIEW, APPROVED], target=REJECTED)
def reject(self):
"""Reject an application that has been submitted.
As a side effect, an email notification is sent, similar to in_review"""
self._send_status_update_email(
"action needed",
"emails/status_change_rejected.txt",
"emails/status_change_rejected_subject.txt",
)
# ## Form policies ###
#
# These methods control what questions need to be answered by applicants

View file

@ -0,0 +1,63 @@
import logging
from typing import Dict
from django.forms import ModelChoiceField
logger = logging.getLogger(__name__)
class SortingDict:
"""Stores a sorting dictionary object"""
_sorting_dict: Dict[type, type] = {}
def __init__(self, model_list, sort_list):
self._sorting_dict = {
"dropDownSelected": self.convert_list_to_dict(model_list),
"sortBy": sort_list,
}
# Used in __init__ for model_list for performance reasons
def convert_list_to_dict(self, value_list):
"""Used internally to convert model_list to a dictionary"""
return {item: item for item in value_list}
def get_dict(self):
"""Grabs the associated dictionary item,
has two fields: 'dropDownSelected': model_list and 'sortBy': sort_list"""
# This should never happen so we need to log this
if self._sorting_dict is None:
raise ValueError("_sorting_dict was None")
return self._sorting_dict
class AdminFormOrderHelper:
"""A helper class to order a dropdown field in Django Admin,
takes the fields you want to order by as an array"""
# Used to keep track of how we want to order_by certain FKs
_sorting_list: list[SortingDict] = []
def __init__(self, sort: list[SortingDict]):
self._sorting_list = sort
def get_ordered_form_field(self, form_field, db_field) -> ModelChoiceField | None:
"""Orders the queryset for a ModelChoiceField
based on the order_by_dict dictionary"""
_order_by_list = []
for item in self._sorting_list:
item_dict = item.get_dict()
drop_down_selected = item_dict.get("dropDownSelected")
sort_by = item_dict.get("sortBy")
if db_field.name in drop_down_selected:
_order_by_list = sort_by
# Exit loop when order_by_list is found
break
# Only order if we choose to do so
# noqa for the linter... reduces readability otherwise
if _order_by_list is not None and _order_by_list != []: # noqa
form_field.queryset = form_field.queryset.order_by(*_order_by_list)
return form_field

View file

@ -0,0 +1,27 @@
from registrar.models.utility.admin_form_order_helper import (
AdminFormOrderHelper,
SortingDict,
)
class AdminSortFields:
# Used to keep track of how we want to order_by certain FKs
foreignkey_orderby_dict: list[SortingDict] = [
# foreign_key - order_by
# Handles fields that are sorted by 'first_name / last_name
SortingDict(
["submitter", "authorizing_official", "investigator", "creator", "user"],
["first_name", "last_name"],
),
# Handles fields that are sorted by 'name'
SortingDict(["domain", "requested_domain"], ["name"]),
SortingDict(["domain_application"], ["requested_domain__name"]),
]
# For readability purposes, but can be replaced with a one liner
def form_field_order_helper(self, form_field, db_field):
"""A shorthand for AdminFormOrderHelper(foreignkey_orderby_dict)
.get_ordered_form_field(form_field, db_field)"""
form = AdminFormOrderHelper(self.foreignkey_orderby_dict)
return form.get_ordered_form_field(form_field, db_field)

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% if app_list %}
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table>
<caption>
<a href="{{ app.app_url }}" class="section" title="{% blocktranslate with name=app.name %}Models in the {{ name }} application{% endblocktranslate %}">{{ app.name }}</a>
</caption>
{# .gov override #}
<thead>
<tr>
<th scope="col">Model</th>
<th><span class="display-inline-block min-width-25">Add</span></th>
{% if show_changelinks %}
<th>
<span class="display-inline-block min-width-81">
{% translate 'View/Change' %}</th>
</span>
{% endif %}
</tr>
</thead>
{# end .gov override #}
{% for model in app.models %}
<tr class="model-{{ model.object_name|lower }}{% if model.admin_url in request.path|urlencode %} current-model{% endif %}">
{% if model.admin_url %}
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}>{{ model.name }}</a></th>
{% else %}
<th scope="row">{{ model.name }}</th>
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td>
{% endif %}
{% elif show_changelinks %}
<td></td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
{% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %}

View file

@ -0,0 +1,38 @@
{% extends "admin/base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "css/styles.css" %}" />
{% endblock %}
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">.gov admin</a></h1>
{% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %}
{% endif %}
{% endblock %}
{% comment %}
This was copied from the 'userlinks' template, with a few minor changes.
You can find that here:
https://github.com/django/django/blob/d25f3892114466d689fd6936f79f3bd9a9acc30e/django/contrib/admin/templates/admin/base.html#L59
{% endcomment %}
{% block userlinks %}
{% if site_url %}
<a href="{{ site_url }}">{% translate 'View site' %}</a> /
{% endif %}
{% if user.is_active and user.is_staff %}
{% url 'django-admindocs-docroot' as docsroot %}
{% if docsroot %}
<a href="{{ docsroot }}">{% translate 'Documentation' %}</a> /
{% endif %}
{% endif %}
{% if user.has_usable_password %}
<a href="{% url 'admin:password_change' %}">{% translate 'Change password' %}</a> /
{% endif %}
<a href="{% url 'admin:logout' %}" id="admin-logout-button">{% translate 'Log out' %}</a>
{% include "admin/color_theme_toggle.html" %}
{% endblock %}
{% block nav-global %}{% endblock %}

View file

@ -0,0 +1,112 @@
{% load i18n static %}
{% comment %}
.gov override
Load our custom filters to extract info from the django generated markup.
{% endcomment %}
{% load custom_filters %}
{% if result_hidden_fields %}
<div class="hiddenfields">{# DIV for HTML validation #}
{% for item in result_hidden_fields %}{{ item }}{% endfor %}
</div>
{% endif %}
{% if results %}
<div class="results override-change_list_results">
<table id="result_list" class="usa-table usa-table--borderless usa-table--striped">
<thead>
<tr>
{% if results.0.form %}
{# .gov - hardcode the select all checkbox #}
<th scope="col" class="action-checkbox-column" title="Toggle all">
<div class="text">
<span>
<input type="checkbox" name="_selected_action" id="action-toggle">
<label for="action-toggle" class="usa-sr-only">Toggle all</label>
</span>
</div>
<div class="clear"></div>
</th>
{# .gov - don't let django generate the select all checkbox #}
{% for header in result_headers|slice:"1:" %}
<th scope="col"{{ header.class_attrib }}>
{% if header.sortable %}
{% if header.sort_priority > 0 %}
<div class="sortoptions">
<a class="sortremove" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}"></a>
{% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %}
<a href="{{ header.url_toggle }}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
</div>
{% endif %}
{% endif %}
<div class="text">{% if header.sortable %}<a href="{{ header.url_primary }}">{{ header.text|capfirst }}</a>{% else %}<span>{{ header.text|capfirst }}</span>{% endif %}</div>
<div class="clear"></div>
</th>{% endfor %}
</tr>
</thead>
<tbody>
{% comment %}
.gov - hardcode the row checkboxes using the custom filters to extract
the value attribute's value, and a label based on the anchor elements's
text. Then edit the for loop to keep django from generating the row select
checkboxes.
{% endcomment %}
{% for result in results %}
{% if result.form.non_field_errors %}
<tr><td colspan="{{ result|length }}">{{ result.form.non_field_errors }}</td></tr>
{% endif %}
<tr>
{% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %}
<td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_label|default:result_value }}" class="action-select">
<label class="usa-sr-only" for="{{ result_label|default:result_value }}">{{ result_label|default:'label' }}</label>
</td>
{% endwith %}
{% endwith %}
{% for item in result|slice:"1:" %}
{{ item }}
{% endfor %}
</tr>
{% endfor %}
{% else %} {# results doesn't have a form as its first element #}
{% for header in result_headers %}
<th scope="col"{{ header.class_attrib }}>
{% if header.sortable %}
{% if header.sort_priority > 0 %}
<div class="sortoptions">
<a class="sortremove" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}"></a>
{% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %}
<a href="{{ header.url_toggle }}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
</div>
{% endif %}
{% endif %}
<div class="text">{% if header.sortable %}<a href="{{ header.url_primary }}">{{ header.text|capfirst }}</a>{% else %}<span>{{ header.text|capfirst }}</span>{% endif %}</div>
<div class="clear"></div>
</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for result in results %}
{% if result.form.non_field_errors %}
<tr><td colspan="{{ result|length }}">{{ result.form.non_field_errors }}</td></tr>
{% endif %}
<tr>{% for item in result %}{{ item }}{% endfor %}</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
{% endif %}

View file

@ -6,13 +6,13 @@
Who is the authorizing official for your organization?
</h2>
<p>Your authorizing official is the person within your organization who can authorize your domain request. This is generally the highest-ranking or highest-elected official in your organization.</p>
<p>Your authorizing official is the 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>
<div class="ao_example">
{% include "includes/ao_example.html" %}
</div>
<p>We might contact your authorizing official, or their office, to double check that they approve this request. Read more about <a 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>We typically dont reach out to the authorizing official, but if contact is necessary, our practice is to coordinate first with you, the requestor. Read more about <a 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>
{% endblock %}

View file

@ -6,7 +6,10 @@
Is your organization an election office? <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</h2>
<p>Answer “yes” if the primary purpose of your organization is to manage elections.</p>
<p>An election office is a government entity whose <em>primary</em> responsibility is overseeing elections and/or conducting voter registration.</p>
<p>Answer “yes” only if the <em>main purpose</em> of your organization is to serve as an election office.</p>
{% endblock %}
{% block form_required_fields_help_text %}

View file

@ -2,7 +2,13 @@
{% load static field_helpers %}
{% block form_instructions %}
<p>Wed like to contact other employees in your organization about your domain request. For example, they could be involved in managing your organization or its technical infrastructure. <strong>This information will help us assess your eligibility for a .gov domain.</strong> These contacts should be in addition to you and your authorizing official. They should be employees of your organization.</p>
<p>To help us assess your eligibility for a .gov domain, please provide contact information for other employees from your organization.
<ul class="usa-list">
<li>They should be clearly and publicly affiliated with your organization and familiar with your domain request. </li>
<li>They don't need to be involved with the technical management of your domain (although they can be). </li>
<li>We typically dont reach out to these employees, but if contact is necessary, our practice is to coordinate first with you, the requestor. </li>
</ul>
</p>
{% endblock %}

View file

@ -20,8 +20,9 @@
Status:
</span>
{% if domainapplication.status == 'approved' %} Approved
{% elif domainapplication.status == 'investigating' %} In Review
{% elif domainapplication.status == 'submitted' %} Received
{% elif domainapplication.status == 'in review' %} In Review
{% elif domainapplication.status == 'rejected' %} Rejected
{% elif domainapplication.status == 'submitted' %} Submitted
{% else %}ERROR Please contact technical support/dev
{% endif %}
</p>

View file

@ -3,7 +3,17 @@
{% block form_fields %}
{% with sublabel_text="Please include the entire name of your tribe as recognized by the Bureau of Indian Affairs." %}
{% with link_text="Bureau of Indian Affairs" %}
{% with link_href="https://www.federalregister.gov/documents/2023/01/12/2023-00504/indian-entities-recognized-by-and-eligible-to-receive-services-from-the-united-states-bureau-of" %}
{% with target_blank="true" %}
{% input_with_errors forms.0.tribe_name %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
<fieldset class="usa-fieldset">
<legend class="usa-legend">
<p>Is your organization a federally-recognized tribe or a state-recognized tribe? Check all that apply.

View file

@ -0,0 +1,42 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi {{ application.submitter.first_name }}.
We've identified an action needed to complete the review of your .gov domain request.
DOMAIN REQUESTED: {{ application.requested_domain.name }}
REQUEST RECEIVED ON: {{ application.updated_at|date }}
REQUEST #: {{ application.id }}
STATUS: Action needed
NEED TO MAKE CHANGES?
If you need to change your request you have to first withdraw it. Once you
withdraw the request you can edit it and submit it again. Changing your request
might add to the wait time. Learn more about withdrawing your request.
<https://get.gov/help/domain-requests/#withdraw-your-domain-request>.
NEXT STEPS
- You will receive a separate email from our team that provides details about the action needed.
You may need to update your application or provide additional information.
- If you do not receive a separate email with these details within one business day, please contact us:
<https://forms.office.com/pages/responsepage.aspx?id=bOfNPG2UEkq7evydCEI1SqHke9Gh6wJEl3kQ5EjWUKlUQzRJWDlBNTBCQUxTTzBaNlhTWURSSTBLTC4u>
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for
requesting a .gov domain.
----------------------------------------------------------------
{% include 'emails/includes/application_summary.txt' %}
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Visit <https://get.gov>
{% endautoescape %}

View file

@ -0,0 +1 @@
Action needed for your .gov domain request

View file

@ -0,0 +1,32 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi {{ application.submitter.first_name }}.
Your .gov domain request has been rejected.
DOMAIN REQUESTED: {{ application.requested_domain.name }}
REQUEST RECEIVED ON: {{ application.updated_at|date }}
REQUEST #: {{ application.id }}
STATUS: Rejected
YOU CAN SUBMIT A NEW REQUEST
The details of your request are included below. If your organization is eligible for a .gov
domain and you meet our other requirements, you can submit a new request. Learn
more about .gov domains <https://get.gov/help/domains/>.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for
requesting a .gov domain.
----------------------------------------------------------------
{% include 'emails/includes/application_summary.txt' %}
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Visit <https://get.gov>
{% endautoescape %}

View file

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

View file

@ -33,6 +33,8 @@
</thead>
<tbody>
{% for domain in domains %}
{% comment %} ticket 796
{% if domain.application_status == "approved" or (domain.application does not exist) %} {% endcomment %}
<tr>
<th th scope="row" role="rowheader" data-label="Domain name">
{{ domain.name }}
@ -88,7 +90,7 @@
<td data-sort-value="{{ application.created_at|date:"U" }}" data-label="Date created">{{ application.created_at|date }}</td>
<td data-label="Status">{{ application.status|title }}</td>
<td>
{% if application.status == "started" or application.status == "withdrawn" %}
{% if application.status == "started" or application.status == "action needed" or application.status == "withdrawn" %}
<a href="{% url 'edit-application' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use>
@ -123,13 +125,14 @@
<p>You don't have any archived domains</p>
</section>
<section class="tablet:grid-col-11 desktop:grid-col-10">
<!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
<p>Download a list of your domains and their statuses as a csv file.</p>
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>
</section>
</section> -->
</div>

View file

@ -3,6 +3,8 @@ Template include for form fields with classes and their corresponding
error messages, if necessary.
{% endcomment %}
{% load custom_filters %}
{% load widget_tweaks %}
{% if widget.attrs.maxlength %}
@ -29,7 +31,18 @@ error messages, if necessary.
{% endif %}
{% if sublabel_text %}
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1">{{ sublabel_text }}</p>
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1">
{% comment %} If the link_text appears more than once, the first instance will be a link and the other instances will be ignored {% endcomment %}
{% if link_text and link_text in sublabel_text %}
{% with link_index=sublabel_text|find_index:link_text %}
{{ sublabel_text|slice:link_index }}
{% comment %} HTML will convert a new line into a space, resulting with a space before the fullstop in case link_text is at the end of sublabel_text, hence the unfortunate line below {% endcomment %}
<a {% if target_blank == "true" %}target="_blank" {% endif %}href="{{ link_href }}">{{ link_text }}</a>{% with sublabel_part_after=sublabel_text|slice_after:link_text %}{{ sublabel_part_after }}{% endwith %}
{% endwith %}
{% else %}
{{ sublabel_text }}
{% endif %}
</p>
{% endif %}
{% if field.errors %}

View file

@ -0,0 +1,42 @@
from django import template
import re
register = template.Library()
@register.filter(name="extract_value")
def extract_value(html_input):
match = re.search(r'value="([^"]*)"', html_input)
if match:
return match.group(1)
return ""
@register.filter
def extract_a_text(value):
# Use regex to extract the text within the <a> tag
pattern = r"<a\b[^>]*>(.*?)</a>"
match = re.search(pattern, value)
if match:
extracted_text = match.group(1)
else:
extracted_text = ""
return extracted_text
@register.filter
def find_index(haystack, needle):
try:
return haystack.index(needle)
except ValueError:
return -1
@register.filter
def slice_after(value, substring):
index = value.find(substring)
if index != -1:
result = value[index + len(substring) :]
return result
return value

View file

@ -2,13 +2,26 @@ import os
import logging
from contextlib import contextmanager
import random
from string import ascii_uppercase
from unittest.mock import Mock
from typing import List, Dict
from django.conf import settings
from django.contrib.auth import get_user_model, login
from registrar.models import Contact, DraftDomain, Website, DomainApplication, User
from registrar.models import (
Contact,
DraftDomain,
Website,
DomainApplication,
DomainInvitation,
User,
DomainInformation,
Domain,
)
logger = logging.getLogger(__name__)
def get_handlers():
@ -88,6 +101,339 @@ class MockSESClient(Mock):
self.EMAILS_SENT.append({"args": args, "kwargs": kwargs})
class AuditedAdminMockData:
"""Creates simple data mocks for AuditedAdminTest.
Can likely be more generalized, but the primary purpose of this class is to simplify
mock data creation, especially for lists of items,
by making the assumption that for most use cases we don't have to worry about
data 'accuracy' ('testy 2' is not an accurate first_name for example), we just care about
implementing some kind of patterning, especially with lists of items.
Two variables are used across multiple functions:
*item_name* - Used in patterning. Will be appended en masse to multiple str fields,
like first_name. For example, item_name 'egg' will return a user object of:
first_name: 'egg first_name:user',
last_name: 'egg last_name:user',
username: 'egg username:user'
where 'user' is the short_hand
*short_hand* - Used in patterning. Certain fields will have ':{shorthand}' appended to it,
as a way to optionally include metadata in the str itself. Can be further expanded on.
Came from a bug where different querysets used in testing would effectively be 'anonymized', wherein
it would only display a list of types, but not include the variable name.
""" # noqa
# Constants for different domain object types
INFORMATION = "information"
APPLICATION = "application"
INVITATION = "invitation"
def dummy_user(self, item_name, short_hand):
"""Creates a dummy user object,
but with a shorthand and support for multiple"""
user = User.objects.get_or_create(
first_name="{} first_name:{}".format(item_name, short_hand),
last_name="{} last_name:{}".format(item_name, short_hand),
username="{} username:{}".format(item_name, short_hand),
)[0]
return user
def dummy_contact(self, item_name, short_hand):
"""Creates a dummy contact object"""
contact = Contact.objects.get_or_create(
first_name="{} first_name:{}".format(item_name, short_hand),
last_name="{} last_name:{}".format(item_name, short_hand),
title="{} title:{}".format(item_name, short_hand),
email="{}testy@town.com".format(item_name),
phone="(555) 555 5555",
)[0]
return contact
def dummy_draft_domain(self, item_name, prebuilt=False):
"""
Creates a dummy DraftDomain object
Args:
item_name (str): Value for 'name' in a DraftDomain object.
prebuilt (boolean): Determines return type.
Returns:
DraftDomain: Where name = 'item_name'. If prebuilt = True, then
name will be "city{}.gov".format(item_name).
"""
if prebuilt:
item_name = "city{}.gov".format(item_name)
return DraftDomain.objects.get_or_create(name=item_name)[0]
def dummy_domain(self, item_name, prebuilt=False):
"""
Creates a dummy domain object
Args:
item_name (str): Value for 'name' in a Domain object.
prebuilt (boolean): Determines return type.
Returns:
Domain: Where name = 'item_name'. If prebuilt = True, then
domain name will be "city{}.gov".format(item_name).
"""
if prebuilt:
item_name = "city{}.gov".format(item_name)
return Domain.objects.get_or_create(name=item_name)[0]
def dummy_website(self, item_name):
"""
Creates a dummy website object
Args:
item_name (str): Value for 'website' in a Website object.
Returns:
Website: Where website = 'item_name'.
"""
return Website.objects.get_or_create(website=item_name)[0]
def dummy_alt(self, item_name):
"""
Creates a dummy website object for alternates
Args:
item_name (str): Value for 'website' in a Website object.
Returns:
Website: Where website = "cityalt{}.gov".format(item_name).
"""
return self.dummy_website(item_name="cityalt{}.gov".format(item_name))
def dummy_current(self, item_name):
"""
Creates a dummy website object for current
Args:
item_name (str): Value for 'website' in a Website object.
prebuilt (boolean): Determines return type.
Returns:
Website: Where website = "city{}.gov".format(item_name)
"""
return self.dummy_website(item_name="city{}.com".format(item_name))
def get_common_domain_arg_dictionary(
self,
item_name,
org_type="federal",
federal_type="executive",
purpose="Purpose of the site",
):
"""
Generates a generic argument dict for most domains
Args:
item_name (str): A shared str value appended to first_name, last_name,
organization_name, address_line1, address_line2,
title, email, and username.
org_type (str - optional): Sets a domains org_type
federal_type (str - optional): Sets a domains federal_type
purpose (str - optional): Sets a domains purpose
Returns:
Dictionary: {
organization_type: str,
federal_type: str,
purpose: str,
organization_name: str = "{} organization".format(item_name),
address_line1: str = "{} address_line1".format(item_name),
address_line2: str = "{} address_line2".format(item_name),
is_policy_acknowledged: boolean = True,
state_territory: str = "NY",
zipcode: str = "10002",
type_of_work: str = "e-Government",
anything_else: str = "There is more",
authorizing_official: Contact = self.dummy_contact(item_name, "authorizing_official"),
submitter: Contact = self.dummy_contact(item_name, "submitter"),
creator: User = self.dummy_user(item_name, "creator"),
}
""" # noqa
common_args = dict(
organization_type=org_type,
federal_type=federal_type,
purpose=purpose,
organization_name="{} organization".format(item_name),
address_line1="{} address_line1".format(item_name),
address_line2="{} address_line2".format(item_name),
is_policy_acknowledged=True,
state_territory="NY",
zipcode="10002",
type_of_work="e-Government",
anything_else="There is more",
authorizing_official=self.dummy_contact(item_name, "authorizing_official"),
submitter=self.dummy_contact(item_name, "submitter"),
creator=self.dummy_user(item_name, "creator"),
)
return common_args
def dummy_kwarg_boilerplate(
self,
domain_type,
item_name,
status=DomainApplication.STARTED,
org_type="federal",
federal_type="executive",
purpose="Purpose of the site",
):
"""
Returns a prebuilt kwarg dictionary for DomainApplication,
DomainInformation, or DomainInvitation.
Args:
domain_type (str): is either 'application', 'information',
or 'invitation'.
item_name (str): A shared str value appended to first_name, last_name,
organization_name, address_line1, address_line2,
title, email, and username.
status (str - optional): Defines the status for DomainApplication,
e.g. DomainApplication.STARTED
org_type (str - optional): Sets a domains org_type
federal_type (str - optional): Sets a domains federal_type
purpose (str - optional): Sets a domains purpose
Returns:
dict: Returns a dictionary structurally consistent with the expected input
of either DomainApplication, DomainInvitation, or DomainInformation
based on the 'domain_type' field.
""" # noqa
common_args = self.get_common_domain_arg_dictionary(
item_name, org_type, federal_type, purpose
)
full_arg_dict = None
match domain_type:
case self.APPLICATION:
full_arg_dict = dict(
**common_args,
requested_domain=self.dummy_draft_domain(item_name),
investigator=self.dummy_user(item_name, "investigator"),
status=status,
)
case self.INFORMATION:
domain_app = self.create_full_dummy_domain_application(item_name)
full_arg_dict = dict(
**common_args,
domain=self.dummy_domain(item_name, True),
domain_application=domain_app,
)
case self.INVITATION:
full_arg_dict = dict(
email="test_mail@mail.com",
domain=self.dummy_domain(item_name, True),
status=DomainInvitation.INVITED,
)
return full_arg_dict
def create_full_dummy_domain_application(
self, item_name, status=DomainApplication.STARTED
):
"""Creates a dummy domain application object"""
domain_application_kwargs = self.dummy_kwarg_boilerplate(
self.APPLICATION, item_name, status
)
application = DomainApplication.objects.get_or_create(
**domain_application_kwargs
)[0]
return application
def create_full_dummy_domain_information(
self, item_name, status=DomainApplication.STARTED
):
"""Creates a dummy domain information object"""
domain_application_kwargs = self.dummy_kwarg_boilerplate(
self.INFORMATION, item_name, status
)
application = DomainInformation.objects.get_or_create(
**domain_application_kwargs
)[0]
return application
def create_full_dummy_domain_invitation(
self, item_name, status=DomainApplication.STARTED
):
"""Creates a dummy domain invitation object"""
domain_application_kwargs = self.dummy_kwarg_boilerplate(
self.INVITATION, item_name, status
)
application = DomainInvitation.objects.get_or_create(
**domain_application_kwargs
)[0]
return application
def create_full_dummy_domain_object(
self,
domain_type,
item_name,
has_other_contacts=True,
has_current_website=True,
has_alternative_gov_domain=True,
status=DomainApplication.STARTED,
):
"""A helper to create a dummy domain application object"""
application = None
match domain_type:
case self.APPLICATION:
application = self.create_full_dummy_domain_application(
item_name, status
)
case self.INVITATION:
application = self.create_full_dummy_domain_invitation(
item_name, status
)
case self.INFORMATION:
application = self.create_full_dummy_domain_information(
item_name, status
)
case _:
raise ValueError("Invalid domain_type, must conform to given constants")
if has_other_contacts and domain_type != self.INVITATION:
other = self.dummy_contact(item_name, "other")
application.other_contacts.add(other)
if has_current_website and domain_type == self.APPLICATION:
current = self.dummy_current(item_name)
application.current_websites.add(current)
if has_alternative_gov_domain and domain_type == self.APPLICATION:
alt = self.dummy_alt(item_name)
application.alternative_domains.add(alt)
return application
def mock_user():
"""A simple user."""
user_kwargs = dict(
id=4,
first_name="Rachid",
last_name="Mrad",
)
mock_user, _ = User.objects.get_or_create(**user_kwargs)
return mock_user
def create_superuser():
User = get_user_model()
p = "adminpass"
return User.objects.create_superuser(
username="superuser",
email="admin@example.com",
password=p,
)
def create_user():
User = get_user_model()
p = "userpass"
return User.objects.create_user(
username="staffuser",
email="user@example.com",
password=p,
)
def completed_application(
has_other_contacts=True,
has_current_website=True,
@ -111,15 +457,15 @@ def completed_application(
alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create(
first_name="Testy you",
last_name="Tester you",
first_name="Testy2",
last_name="Tester2",
title="Admin Tester",
email="mayor@igorville.gov",
phone="(555) 555 5556",
)
other, _ = Contact.objects.get_or_create(
first_name="Testy2",
last_name="Tester2",
first_name="Testy",
last_name="Tester",
title="Another Tester",
email="testy2@town.com",
phone="(555) 555 5557",
@ -159,14 +505,16 @@ def completed_application(
return application
def mock_user():
"""A simple user."""
user_kwargs = dict(
id=4,
first_name="Rachid",
last_name="Mrad",
)
def multiple_unalphabetical_domain_objects(
domain_type=AuditedAdminMockData.APPLICATION,
):
"""Returns a list of generic domain objects for testing purposes"""
applications = []
list_of_letters = list(ascii_uppercase)
random.shuffle(list_of_letters)
user, _ = User.objects.get_or_create(**user_kwargs)
return user
mock = AuditedAdminMockData()
for object_name in list_of_letters:
application = mock.create_full_dummy_domain_object(domain_type, object_name)
applications.append(application)
return applications

View file

@ -1,34 +1,40 @@
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
from registrar.admin import DomainApplicationAdmin, ListHeaderAdmin
from registrar.models import DomainApplication, DomainInformation, User
from .common import completed_application, mock_user
from registrar.admin import (
DomainApplicationAdmin,
ListHeaderAdmin,
MyUserAdmin,
AuditedAdmin,
)
from registrar.models import (
DomainApplication,
DomainInformation,
User,
DomainInvitation,
)
from .common import (
completed_application,
mock_user,
create_superuser,
create_user,
multiple_unalphabetical_domain_objects,
)
from django.contrib.auth import get_user_model
from django.conf import settings
from unittest.mock import MagicMock
import boto3_mocking # type: ignore
import logging
logger = logging.getLogger(__name__)
class TestDomainApplicationAdmin(TestCase):
def setUp(self):
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=DomainApplication, admin_site=None)
self.client = Client(HTTP_HOST="localhost:8080")
username = "admin"
first_name = "First"
last_name = "Last"
email = "info@example.com"
p = "adminpassword"
User = get_user_model()
self.superuser = User.objects.create_superuser(
username=username,
first_name=first_name,
last_name=last_name,
email=email,
password=p,
)
@boto3_mocking.patching
def test_save_model_sends_submitted_email(self):
@ -76,9 +82,6 @@ class TestDomainApplicationAdmin(TestCase):
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
# Cleanup
application.delete()
@boto3_mocking.patching
def test_save_model_sends_in_review_email(self):
# make sure there is no user with this email
@ -101,7 +104,7 @@ class TestDomainApplicationAdmin(TestCase):
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
# Modify the application's property
application.status = DomainApplication.INVESTIGATING
application.status = DomainApplication.IN_REVIEW
# Use the model admin's save_model method
model_admin.save_model(request, application, form=None, change=True)
@ -125,9 +128,6 @@ class TestDomainApplicationAdmin(TestCase):
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
# Cleanup
application.delete()
@boto3_mocking.patching
def test_save_model_sends_approved_email(self):
# make sure there is no user with this email
@ -139,7 +139,7 @@ class TestDomainApplicationAdmin(TestCase):
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.INVESTIGATING)
application = completed_application(status=DomainApplication.IN_REVIEW)
# Create a mock request
request = self.factory.post(
@ -174,15 +174,146 @@ class TestDomainApplicationAdmin(TestCase):
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
# Cleanup
if DomainInformation.objects.get(id=application.pk) is not None:
DomainInformation.objects.get(id=application.pk).delete()
application.delete()
def test_save_model_sets_approved_domain(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
# Create a sample application
application = completed_application(status=DomainApplication.IN_REVIEW)
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
)
# Create an instance of the model admin
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
# Modify the application's property
application.status = DomainApplication.APPROVED
# Use the model admin's save_model method
model_admin.save_model(request, application, form=None, change=True)
# Test that approved domain exists and equals requested domain
self.assertEqual(
application.requested_domain.name, application.approved_domain.name
)
@boto3_mocking.patching
def test_save_model_sends_action_needed_email(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.IN_REVIEW)
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
)
# Create an instance of the model admin
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
# Modify the application's property
application.status = DomainApplication.ACTION_NEEDED
# Use the model admin's save_model method
model_admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
expected_string = (
"We've identified an action needed to complete the "
"review of your .gov domain request."
)
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
@boto3_mocking.patching
def test_save_model_sends_rejected_email(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.IN_REVIEW)
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
)
# Create an instance of the model admin
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
# Modify the application's property
application.status = DomainApplication.REJECTED
# Use the model admin's save_model method
model_admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
expected_string = "Your .gov domain request has been rejected."
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
def tearDown(self):
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
User.objects.all().delete()
class ListHeaderAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=DomainApplication, admin_site=None)
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
def test_changelist_view(self):
# Have to get creative to get past linter
p = "adminpassword"
self.client.login(username="admin", password=p)
p = "adminpass"
self.client.login(username="superuser", password=p)
# Mock a user
user = mock_user()
@ -241,6 +372,267 @@ class TestDomainApplicationAdmin(TestCase):
def tearDown(self):
# delete any applications too
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
User.objects.all().delete()
self.superuser.delete()
class MyUserAdminTest(TestCase):
def setUp(self):
admin_site = AdminSite()
self.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site)
def test_list_display_without_username(self):
request = self.client.request().wsgi_request
request.user = create_user()
list_display = self.admin.get_list_display(request)
expected_list_display = (
"email",
"first_name",
"last_name",
"is_staff",
"is_superuser",
)
self.assertEqual(list_display, expected_list_display)
self.assertNotIn("username", list_display)
def test_get_fieldsets_superuser(self):
request = self.client.request().wsgi_request
request.user = create_superuser()
fieldsets = self.admin.get_fieldsets(request)
expected_fieldsets = super(MyUserAdmin, self.admin).get_fieldsets(request)
self.assertEqual(fieldsets, expected_fieldsets)
def test_get_fieldsets_non_superuser(self):
request = self.client.request().wsgi_request
request.user = create_user()
fieldsets = self.admin.get_fieldsets(request)
expected_fieldsets = ((None, {"fields": []}),)
self.assertEqual(fieldsets, expected_fieldsets)
def tearDown(self):
User.objects.all().delete()
class AuditedAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()
self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080")
def order_by_desired_field_helper(
self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names
):
formatted_sort_fields = []
for obj in obj_names:
formatted_sort_fields.append("{}__{}".format(field_name, obj))
ordered_list = list(
obj_to_sort.get_queryset(request)
.order_by(*formatted_sort_fields)
.values_list(*formatted_sort_fields)
)
return ordered_list
def test_alphabetically_sorted_fk_fields_domain_application(self):
tested_fields = [
DomainApplication.authorizing_official.field,
DomainApplication.submitter.field,
DomainApplication.investigator.field,
DomainApplication.creator.field,
DomainApplication.requested_domain.field,
]
# Creates multiple domain applications - review status does not matter
applications = multiple_unalphabetical_domain_objects("application")
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainapplication/{}/change/".format(applications[0].pk)
)
model_admin = AuditedAdmin(DomainApplication, self.site)
sorted_fields = []
# Typically we wouldn't want two nested for fields,
# but both fields are of a fixed length.
# For test case purposes, this should be performant.
for field in tested_fields:
isNamefield: bool = field == DomainApplication.requested_domain.field
if isNamefield:
sorted_fields = ["name"]
else:
sorted_fields = ["first_name", "last_name"]
# We want both of these to be lists, as it is richer test wise.
desired_order = self.order_by_desired_field_helper(
model_admin, request, field.name, *sorted_fields
)
current_sort_order = list(
model_admin.formfield_for_foreignkey(field, request).queryset
)
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
for contact in current_sort_order:
if not isNamefield:
first = contact.first_name
last = contact.last_name
else:
first = contact.name
last = None
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
if name_tuple is not None:
current_sort_order_coerced_type.append(name_tuple)
self.assertEqual(
desired_order,
current_sort_order_coerced_type,
"{} is not ordered alphabetically".format(field.name),
)
def test_alphabetically_sorted_fk_fields_domain_information(self):
tested_fields = [
DomainInformation.authorizing_official.field,
DomainInformation.submitter.field,
DomainInformation.creator.field,
(DomainInformation.domain.field, ["name"]),
(DomainInformation.domain_application.field, ["requested_domain__name"]),
]
# Creates multiple domain applications - review status does not matter
applications = multiple_unalphabetical_domain_objects("information")
# Create a mock request
request = self.factory.post(
"/admin/registrar/domaininformation/{}/change/".format(applications[0].pk)
)
model_admin = AuditedAdmin(DomainInformation, self.site)
sorted_fields = []
# Typically we wouldn't want two nested for fields,
# but both fields are of a fixed length.
# For test case purposes, this should be performant.
for field in tested_fields:
isOtherOrderfield: bool = isinstance(field, tuple)
field_obj = None
if isOtherOrderfield:
sorted_fields = field[1]
field_obj = field[0]
else:
sorted_fields = ["first_name", "last_name"]
field_obj = field
# We want both of these to be lists, as it is richer test wise.
desired_order = self.order_by_desired_field_helper(
model_admin, request, field_obj.name, *sorted_fields
)
current_sort_order = list(
model_admin.formfield_for_foreignkey(field_obj, request).queryset
)
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
for obj in current_sort_order:
last = None
if not isOtherOrderfield:
first = obj.first_name
last = obj.last_name
elif field_obj == DomainInformation.domain.field:
first = obj.name
elif field_obj == DomainInformation.domain_application.field:
first = obj.requested_domain.name
name_tuple = self.coerced_fk_field_helper(
first, last, field_obj.name, ":"
)
if name_tuple is not None:
current_sort_order_coerced_type.append(name_tuple)
self.assertEqual(
desired_order,
current_sort_order_coerced_type,
"{} is not ordered alphabetically".format(field_obj.name),
)
def test_alphabetically_sorted_fk_fields_domain_invitation(self):
tested_fields = [DomainInvitation.domain.field]
# Creates multiple domain applications - review status does not matter
applications = multiple_unalphabetical_domain_objects("invitation")
# Create a mock request
request = self.factory.post(
"/admin/registrar/domaininvitation/{}/change/".format(applications[0].pk)
)
model_admin = AuditedAdmin(DomainInvitation, self.site)
sorted_fields = []
# Typically we wouldn't want two nested for fields,
# but both fields are of a fixed length.
# For test case purposes, this should be performant.
for field in tested_fields:
sorted_fields = ["name"]
# We want both of these to be lists, as it is richer test wise.
desired_order = self.order_by_desired_field_helper(
model_admin, request, field.name, *sorted_fields
)
current_sort_order = list(
model_admin.formfield_for_foreignkey(field, request).queryset
)
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
for contact in current_sort_order:
first = contact.name
last = None
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
if name_tuple is not None:
current_sort_order_coerced_type.append(name_tuple)
self.assertEqual(
desired_order,
current_sort_order_coerced_type,
"{} is not ordered alphabetically".format(field.name),
)
def coerced_fk_field_helper(
self, first_name, last_name, field_name, queryset_shorthand
):
"""Handles edge cases for test cases"""
if first_name is None:
raise ValueError("Invalid value for first_name, must be defined")
returned_tuple = (first_name, last_name)
# Handles edge case for names - structured strangely
if last_name is None:
return (first_name,)
if first_name.split(queryset_shorthand)[1] == field_name:
return returned_tuple
else:
return None
def tearDown(self):
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
DomainInvitation.objects.all().delete()

View file

@ -144,11 +144,11 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.submit()
def test_transition_not_allowed_investigating_submitted(self):
"""Create an application with status investigating and call submit
def test_transition_not_allowed_in_review_submitted(self):
"""Create an application with status in review and call submit
against transition rules"""
application = completed_application(status=DomainApplication.INVESTIGATING)
application = completed_application(status=DomainApplication.IN_REVIEW)
with self.assertRaises(TransitionNotAllowed):
application.submit()
@ -162,7 +162,16 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.submit()
def test_transition_not_allowed_started_investigating(self):
def test_transition_not_allowed_rejected_submitted(self):
"""Create an application with status rejected and call submit
against transition rules"""
application = completed_application(status=DomainApplication.REJECTED)
with self.assertRaises(TransitionNotAllowed):
application.submit()
def test_transition_not_allowed_started_in_review(self):
"""Create an application with status started and call in_review
against transition rules"""
@ -171,16 +180,16 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_investigating_investigating(self):
"""Create an application with status investigating and call in_review
def test_transition_not_allowed_in_review_in_review(self):
"""Create an application with status in review and call in_review
against transition rules"""
application = completed_application(status=DomainApplication.INVESTIGATING)
application = completed_application(status=DomainApplication.IN_REVIEW)
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_approved_investigating(self):
def test_transition_not_allowed_approved_in_review(self):
"""Create an application with status approved and call in_review
against transition rules"""
@ -189,7 +198,25 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_withdrawn_investigating(self):
def test_transition_not_allowed_action_needed_in_review(self):
"""Create an application with status action needed and call in_review
against transition rules"""
application = completed_application(status=DomainApplication.ACTION_NEEDED)
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_rejected_in_review(self):
"""Create an application with status rejected and call in_review
against transition rules"""
application = completed_application(status=DomainApplication.REJECTED)
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_withdrawn_in_review(self):
"""Create an application with status withdrawn and call in_review
against transition rules"""
@ -198,6 +225,51 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.in_review()
def test_transition_not_allowed_started_action_needed(self):
"""Create an application with status started and call action_needed
against transition rules"""
application = completed_application(status=DomainApplication.STARTED)
with self.assertRaises(TransitionNotAllowed):
application.action_needed()
def test_transition_not_allowed_submitted_action_needed(self):
"""Create an application with status submitted and call action_needed
against transition rules"""
application = completed_application(status=DomainApplication.SUBMITTED)
with self.assertRaises(TransitionNotAllowed):
application.action_needed()
def test_transition_not_allowed_action_needed_action_needed(self):
"""Create an application with status action needed and call action_needed
against transition rules"""
application = completed_application(status=DomainApplication.ACTION_NEEDED)
with self.assertRaises(TransitionNotAllowed):
application.action_needed()
def test_transition_not_allowed_approved_action_needed(self):
"""Create an application with status approved and call action_needed
against transition rules"""
application = completed_application(status=DomainApplication.APPROVED)
with self.assertRaises(TransitionNotAllowed):
application.action_needed()
def test_transition_not_allowed_withdrawn_action_needed(self):
"""Create an application with status withdrawn and call action_needed
against transition rules"""
application = completed_application(status=DomainApplication.WITHDRAWN)
with self.assertRaises(TransitionNotAllowed):
application.action_needed()
def test_transition_not_allowed_started_approved(self):
"""Create an application with status started and call approve
against transition rules"""
@ -216,6 +288,15 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.approve()
def test_transition_not_allowed_action_needed_approved(self):
"""Create an application with status action needed and call approve
against transition rules"""
application = completed_application(status=DomainApplication.ACTION_NEEDED)
with self.assertRaises(TransitionNotAllowed):
application.approve()
def test_transition_not_allowed_withdrawn_approved(self):
"""Create an application with status withdrawn and call approve
against transition rules"""
@ -243,6 +324,24 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.withdraw()
def test_transition_not_allowed_action_needed_withdrawn(self):
"""Create an application with status action needed and call withdraw
against transition rules"""
application = completed_application(status=DomainApplication.ACTION_NEEDED)
with self.assertRaises(TransitionNotAllowed):
application.withdraw()
def test_transition_not_allowed_rejected_withdrawn(self):
"""Create an application with status rejected and call withdraw
against transition rules"""
application = completed_application(status=DomainApplication.REJECTED)
with self.assertRaises(TransitionNotAllowed):
application.withdraw()
def test_transition_not_allowed_withdrawn_withdrawn(self):
"""Create an application with status withdrawn and call withdraw
against transition rules"""
@ -252,6 +351,51 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed):
application.withdraw()
def test_transition_not_allowed_started_rejected(self):
"""Create an application with status started and call reject
against transition rules"""
application = completed_application(status=DomainApplication.STARTED)
with self.assertRaises(TransitionNotAllowed):
application.reject()
def test_transition_not_allowed_submitted_rejected(self):
"""Create an application with status submitted and call reject
against transition rules"""
application = completed_application(status=DomainApplication.SUBMITTED)
with self.assertRaises(TransitionNotAllowed):
application.reject()
def test_transition_not_allowed_action_needed_rejected(self):
"""Create an application with status action needed and call reject
against transition rules"""
application = completed_application(status=DomainApplication.ACTION_NEEDED)
with self.assertRaises(TransitionNotAllowed):
application.reject()
def test_transition_not_allowed_withdrawn_rejected(self):
"""Create an application with status withdrawn and call reject
against transition rules"""
application = completed_application(status=DomainApplication.WITHDRAWN)
with self.assertRaises(TransitionNotAllowed):
application.reject()
def test_transition_not_allowed_rejected_rejected(self):
"""Create an application with status rejected and call reject
against transition rules"""
application = completed_application(status=DomainApplication.REJECTED)
with self.assertRaises(TransitionNotAllowed):
application.reject()
class TestPermissions(TestCase):

View file

@ -3,6 +3,12 @@
from django.conf import settings
from django.test import TestCase
from django.template import Context, Template
from registrar.templatetags.custom_filters import (
extract_value,
extract_a_text,
find_index,
slice_after,
)
class TestTemplateTags(TestCase):
@ -29,3 +35,51 @@ class TestTemplateTags(TestCase):
self.assertTrue(result.startswith(settings.GETGOV_PUBLIC_SITE_URL))
# slash-slash host slash directory slash page
self.assertEqual(result.count("/"), 4)
class CustomFiltersTestCase(TestCase):
def test_extract_value_filter(self):
html_input = (
'<input type="checkbox" name="_selected_action" value="123" '
'id="label_123" class="action-select">'
)
result = extract_value(html_input)
self.assertEqual(result, "123")
html_input = (
'<input type="checkbox" name="_selected_action" value="abc" '
'id="label_123" class="action-select">'
)
result = extract_value(html_input)
self.assertEqual(result, "abc")
def test_extract_a_text_filter(self):
input_text = '<a href="#">Link Text</a>'
result = extract_a_text(input_text)
self.assertEqual(result, "Link Text")
input_text = '<a href="/example">Another Link</a>'
result = extract_a_text(input_text)
self.assertEqual(result, "Another Link")
def test_find_index(self):
haystack = "Hello, World!"
needle = "lo"
result = find_index(haystack, needle)
self.assertEqual(result, 3)
needle = "XYZ"
result = find_index(haystack, needle)
self.assertEqual(result, -1)
def test_slice_after(self):
value = "Hello, World!"
substring = "lo"
result = slice_after(value, substring)
self.assertEqual(result, ", World!")
substring = "XYZ"
result = slice_after(value, substring)
self.assertEqual(
result, value
) # Should return the original value if substring not found

View file

@ -145,7 +145,6 @@ class DomainApplicationTests(TestWithUser, WebTest):
# ---- TYPE PAGE ----
type_form = type_page.form
type_form["organization_type-organization_type"] = "federal"
# test next button and validate data
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_result = type_page.form.submit()
@ -161,6 +160,7 @@ class DomainApplicationTests(TestWithUser, WebTest):
# ---- FEDERAL BRANCH PAGE ----
# Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
federal_page = type_result.follow()
federal_form = federal_page.form
federal_form["organization_federal-federal_type"] = "executive"

View file

@ -67,7 +67,7 @@ class ApplicationWizard(TemplateView):
URL_NAMESPACE = "application"
# name for accessing /application/<id>/edit
EDIT_URL_NAME = "edit-application"
NEW_URL_NAME = "/register/"
# We need to pass our human-readable step titles as context to the templates.
TITLES = {
Step.ORGANIZATION_TYPE: _("Type of organization"),
@ -144,6 +144,7 @@ class ApplicationWizard(TemplateView):
self._application = DomainApplication.objects.create(
creator=self.request.user, # type: ignore
)
self.storage["application_id"] = self._application.id
return self._application
@ -195,7 +196,6 @@ class ApplicationWizard(TemplateView):
def get(self, request, *args, **kwargs):
"""This method handles GET requests."""
current_url = resolve(request.path_info).url_name
# if user visited via an "edit" url, associate the id of the
@ -213,12 +213,15 @@ class ApplicationWizard(TemplateView):
# send users "to the application wizard" without needing to
# know which view is first in the list of steps.
if self.__class__ == ApplicationWizard:
# if starting a new application, clear the storage
if request.path_info == self.NEW_URL_NAME:
del self.storage
return self.goto(self.steps.first)
self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms()
return render(request, self.template_name, context)
def get_all_forms(self, **kwargs) -> list:
@ -242,7 +245,6 @@ class ApplicationWizard(TemplateView):
and from the database if `use_db` is True (provided that record exists).
An empty form will be provided if neither of those are true.
"""
kwargs = {
"files": files,
"prefix": self.steps.current,

View file

@ -24,6 +24,12 @@ class DomainPermission(PermissionsLoginMixin):
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
# ticket 806
# if self.request.user is staff or admin and
# domain.application__status = 'approved' or 'rejected' or 'action needed'
# return True
if not self.request.user.is_authenticated:
return False
@ -33,6 +39,10 @@ class DomainPermission(PermissionsLoginMixin):
).exists():
return False
# ticket 796
# if domain.application__status != 'approved'
# return false
# if we need to check more about the nature of role, do it here.
return True

View file

@ -3,15 +3,15 @@ asgiref==3.7.2 ; python_version >= '3.7'
boto3==1.26.145
botocore==1.29.145 ; python_version >= '3.7'
cachetools==5.3.1
certifi==2023.5.7 ; python_version >= '3.6'
certifi==2023.7.22 ; python_version >= '3.6'
cfenv==0.5.3
cffi==1.15.1
charset-normalizer==3.1.0 ; python_full_version >= '3.7.0'
cryptography==41.0.1 ; python_version >= '3.7'
cryptography==41.0.3 ; python_version >= '3.7'
defusedxml==0.7.1 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
dj-database-url==2.0.0
dj-email-url==1.0.6
django==4.2.1
django==4.2.3
django-allow-cidr==0.6.0
django-auditlog==2.3.0
django-cache-url==3.4.4

View file

@ -31,6 +31,8 @@
10027 OUTOFSCOPE http://app:8080/public/js/uswds-init.min.js
# get-gov.js contains suspicious word "from" as in `Array.from()`
10027 OUTOFSCOPE http://app:8080/public/js/get-gov.js
# Ignore wording of "TODO"
10027 OUTOFSCOPE http://app:8080.*$
10028 FAIL (Open Redirect - Passive/beta)
10029 FAIL (Cookie Poisoning - Passive/beta)
10030 FAIL (User Controllable Charset - Passive/beta)