diff --git a/docs/architecture/diagrams/model_timeline.md b/docs/architecture/diagrams/model_timeline.md
index f6e7bbb91..967c63eb2 100644
--- a/docs/architecture/diagrams/model_timeline.md
+++ b/docs/architecture/diagrams/model_timeline.md
@@ -2,7 +2,7 @@
This diagram connects the data models along with various workflow stages.
-1. The applicant starts the process at `/register` interacting with the
+1. The applicant starts the process at `/request` interacting with the
`DomainApplication` object.
2. The analyst approves the application using the `DomainApplication`'s
@@ -139,7 +139,7 @@ DomainInvitation -- Domain
DomainInvitation .[#green].> UserDomainRole : User.on_each_login()
actor applicant #Red
-applicant -d-> DomainApplication : **/register**
+applicant -d-> DomainApplication : **/request**
actor analyst #Blue
analyst -[#blue]-> DomainApplication : **approve()**
diff --git a/docs/developer/generating-emails-guide.md b/docs/developer/generating-emails-guide.md
index 383fc31ac..0a97a8bc6 100644
--- a/docs/developer/generating-emails-guide.md
+++ b/docs/developer/generating-emails-guide.md
@@ -21,28 +21,6 @@
- Notes: Subject line of the "Domain Request Withdrawn" email
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/domain_request_withdrawn_subject.txt)
-## Status Change Action Needed
-- Starting Location: Django Admin
-- Workflow: Analyst Admin
-- Workflow Step: Click "Domain applications" -> Click an application with a status of "in review" or "rejected" -> Click status dropdown -> (select "action needed") -> click "Save"
-- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create an application, set the status to either "in review" or "rejected" (and click save), then set the status to "action needed". This will send you an email.
-- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_action_needed.txt)
-
-### Status Change Action Needed Subject
-- Notes: Subject line of the "Status Change Action Needed" email
-- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_action_needed_subject.txt)
-
-## Status Change in Review
-- Starting Location: Django Admin
-- Workflow: Analyst Admin
-- Workflow Step: Click "Domain applications" -> Click an application with a status of "submitted" -> Click status dropdown -> (select "In review") -> click "Save"
-- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create an application, then set the status to "In review". This will send you an email.
-- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_approved.txt)
-
-### Status Change in Review Subject
-- Notes: This is the subject line of the "Status Change In Review" email
-- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_in_review_subject.txt)
-
## Status Change Approved
- Starting Location: Django Admin
- Workflow: Analyst Admin
diff --git a/docs/developer/migration-troubleshooting.md b/docs/developer/migration-troubleshooting.md
index 25187ef13..b90c02ae3 100644
--- a/docs/developer/migration-troubleshooting.md
+++ b/docs/developer/migration-troubleshooting.md
@@ -41,11 +41,11 @@ This happens when you swap branches on your sandbox that contain diverging leave
- `cf login -a api.fr.cloud.gov --sso`
- `cf ssh getgov-`
- `/tmp/lifecycle/shell`
-- `cf run-task getgov- --wait --command 'python manage.py migrate registrar 39_previous_miration --fake' --name migrate`
-- `cf run-task getgov- --wait --command 'python manage.py migrate registrar 41_example_migration' --name migrate`
-- `cf run-task getgov- --wait --command 'python manage.py migrate registrar 45_last_migration --fake' --name migrate`
-
-Then, navigate to and delete the offending migration. In this case, it is 0041_example_migration.
+- Find the conflicting migrations: `./manage.py showmigrations`
+- Delete one of them: `rm registrar/migrations/0041_example.py`
+- `/manage.py showmigrations`
+- `/manage.py makemigrations`
+- `/manage.py migrate`
### Scenario 3: Migrations ran incorrectly, and migrate no longer works (sandbox)
@@ -63,7 +63,7 @@ To diagnose this issue, you will have to manually delete tables using the psql s
1. `cf login -a api.fr.cloud.gov --sso`
2. Run `cf connect-to-service -no-client getgov-{environment_name} getgov-{environment_name}-database` to open a SSH tunnel
3. Run `psql -h localhost -p {port} -U {username} -d {broker_name}`
-4. Open a new terminal window and run `cf ssh getgov{environment_name}`
+4. Open a new terminal window and run `cf ssh getgov-{environment_name}`
5. Within that window, run `tmp/lifecycle/shell`
6. Within that window, run `./manage.py migrate` and observe which tables are duplicates
@@ -102,7 +102,7 @@ Example: there are extra columns created on a table by an old migration long sin
Example: You are able to log in and access the /admin page, but when you arrive at the registrar you keep getting 500 errors and your log-ins any API calls you make via the UI does not show up in the log stream. And you feel like you’re starting to lose your marbles.
In the CLI, run the command `cf routes`
-If you notice that your route of `getgov-.app.cloud.gov` is pointing two apps, then that is probably the major issue of the 500 error. (ie mine was pointing at `getgov-.app.cloud.gov` AND `cisa-dotgov`
+If you notice that your route of `getgov-.app.cloud.gov` is pointing two apps, then that is probably the major issue of the 500 error. (ie mine was pointing at `getgov-.app.cloud.gov` AND `cisa-dotgov`)
In the CLI, run the command `cf apps` to check that it has an app running called `cisa-dotgov`. If so, there’s the error!
Essentially this shows that your requests were being handled by two completely separate applications and that’s why some requests aren’t being located.
To resolve this issue, remove the app named `cisa-dotgov` from this space.
@@ -117,7 +117,7 @@ https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1697810600723069
### Scenario 8: Can’t log into sandbox, permissions do not exist
-- Fake migrate the migration that’s before the last data creation migration
-- Run the last data creation migration (AND ONLY THAT ONE)
-- Fake migrate the last migration in the migration list
-- Rerun fixtures
+1. `./manage.py migrate --fake model_name_here file_name_BEFORE_the_most_recent_CREATE_migration` (fake migrate the migration that’s before the last data creation migration -- look for number_create, and then copy the file BEFORE it)
+2. `./manage.py migrate model_name_here file_name_WITH_create` (run the last data creation migration AND ONLY THAT ONE)
+3. `./manage.py migrate --fake model_name_here most_recent_file_name` (fake migrate the last migration in the migration list)
+4. `./manage.py load` (rerun fixtures)
diff --git a/docs/operations/README.md b/docs/operations/README.md
index 0629608dd..0e7b7a432 100644
--- a/docs/operations/README.md
+++ b/docs/operations/README.md
@@ -35,17 +35,55 @@ Binding the database in `manifest-.json` automatically inserts the
# Deploy
-We have three types of environments: developer "sandboxes", `staging` and `stable`. Developers can deploy locally to their sandbox whenever they want. However, only our CD service can deploy to `staging` and `stable`, and it does so when we make tagged releases of `main`. For `staging`, this is done to ensure there is a non-production level test envirornment that can be used for user testing or for testing code before it is pushed to `stable`. `Staging` can be especially helpful when testing database changes or migrations that could have adververse affects in `stable`. On the other hand, `stable` is used to ensure that we have a "golden" environment to point to. We can refer to `stable` as our production environment and `staging` as our pre-production (pre-prod) environment. As such, code on main should always be tagged for `staging` before it is tagged for `stable`.
+We have four types of environments: developer "sandboxes", `development`, `staging` and `stable`. Developers can deploy locally to their sandbox whenever they want. However, only our CD service can deploy to `development`, `staging` and `stable`.
-You should make sure all of the USWDS assets are compiled and collected before deploying to your sandbox. To deploy locally to `sandbox`:
+For staging and stable our CD service completes this deploy when we make tagged releases from specifc branch. For `staging`, this is done to ensure there is a non-production level test environment that can be used for user testing or for testing code before it is pushed to `stable`. `Staging` can be especially helpful when testing database changes or migrations that could have adververse affects in `stable`. When deploying to staging, the branch used is often just `main`. On the other hand, `stable` is used to ensure that we have a "golden" environment to point to. We can refer to `stable` as our production environment and `staging` as our pre-production (pre-prod) environment. As such, code on main should always be tagged for `staging` before it is tagged for `stable`. Thus the branch used in `stable` releases is usually the tagged branch used for the last staging commit.
+
+The `development` environment, is one that auto deploys on any push to main via our CD service. This is to ensure we have an environment that is identical to what we have on the `main` branch. This should not be confused with the "sandboxes" given to developers and designers for ticket development.
+
+When deploying to your personal sandbox, you should make sure all of the USWDS assets are compiled and collected before deploying to your sandbox. To deploy locally to `sandbox`:
For ease of use, you can run the `deploy.sh ` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh ` script to just compile and collect the assets without deploying.
-Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions [here](../../.github/ISSUE_TEMPLATE/developer-onboarding.md#setting-up-developer-sandbox).
+Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below.
+
+## Creating a sandbox or new environment
+
+When possible all developers and designers should have their own sandboxes as this provides them a space to test out changes in an isolated environment. All sandboxes are still accessible on the web, just like `staging`, `stable`, and `development`.
+
+1. Make sure you have admin access to the cloud.gov organization, have admin access on github, and make sure you are targeting your own workspace in cloudfoundry
+2. Make sure you are on `main` and your local code is up to date with the repo
+3. Open the terminal to the root project directory
+4. run [creating a developer sandbox shell script](../../ops/scripts/create_dev_sandbox.sh) by typing the path to the script followed by the name of the sandbox you wish to create. Use initials for the sandbox name. If John Doe is the name of a developer you wish to make a sandbox for you would then do:
+
+```
+./ops/scripts/create_dev_sandbox.sh jd
+```
+
+5. Follow the prompts that appear in the terminal, if on `main`, make sure to click yes to switching to a new branch. Clicking anything besides `Y` or `y` will count as a no.
+6. When the database is being set up it can take 5 mins or longer, don't close the window.
+7. The last prompt asks if you want to make a PR, this will generate a PR for you but you may need to double check against similiar PRs to make sure everything was changed correctly. To do this go to github Pull Requests and search for closed PRs with the word infrastructure.
+
+## Once the sandbox or new environment is made
+
+Once this is made, the new owner of the sandbox has a few steps they should follow. This is already in [onboarding documents](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.6dw0iz1u56ox), but is worth re-iterating here:
+
+1. Run fixtures if desired. Refer to the [onboarding guide](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.6dw0iz1u56ox) for how to do this and helpful hints
+2. add environment variables for registrar-registry communication (EPP), see [the application secrets readme](./runbooks/rotate_application_secrets.md)
+
+
+## Creating a new environment
+If we ever need a new environment to replace `development`, `staging` or `stable` we need to follow similiar steps but not identical ones to the instructions for making a sandbox.
+
+1. Just like making a sandbox make sure you have admin access to the cloud.gov organization, have admin access on github, and make sure you are targeting your own workspace in cloudfoundry. Make sure you are on `main` and your local code is up to date with the repo
+2. Open the terminal to the root project directory.
+3. Instead of running [the script for creating a sandbox](../../ops/scripts/create_dev_sandbox.sh), you will manually copy over which commands you want directly into the terminal. Don't run the prompts in terminal, as you will be figuring out what you need to do based on your needs. All the prompts, denoted with `echo`, tell you what the following commands are doing. When uncertain look at the cloudfoundry documentation for any of the `cf` commands.
+4. In most cases, the setup will be almost identical to making a sandbox. The main difference will be deployment and determining if you want workflows like reset, deploy, and migrate to work for it. You will manually update these yaml files if you want the workflows included.
+5. Often it is the manifest file that needs to change as well, either with different environment variables, number of instances, or so on. Copy whichever manifest is closest to what you wish to do and tailor it to your specific needs. See cloudfoundry's and docker's documentation if you need assistance.
## Stable and Staging Release Rules
-Releases will be made for staging and stable every week starting on the first day of the sprint (Wednesday), with the second release of the sprint occuring halfway through the sprint. With the exception of first time going into production, these releases will NOT have the same code. The release to stable will be the same commit that was tagged for staging one week prior, making stable one week behind staging. Further, this means staging can be up to a week behind the main branch of code.
+Releases will be made for staging and stable twice a week, ideally Tuesday and Thursday, but can be adjusted if needed. Code on `main` will be released to `staging`, and then on the following Tuesday/Thursday this `staging` release will become the new `stable` release. This means every release day, a release will be made to `stable` containing the last `staging` code. On this same day a new `staging` release will be made that contains the most up-to-date code on main. Thus, `staging` can be a few days behind the main branch, and `stable` will be a few days behind the code on `staging`.
If a bug fix or feature needs to be made to stable out of the normal cycle, this can only be done at the product owner's request.
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index a45e27982..b7e413c05 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -524,3 +524,65 @@ Example: `cf ssh getgov-za`
| 2 | **debug** | Increases logging detail. Defaults to False. |
| 3 | **limitParse** | Determines how many domains to parse. Defaults to all. |
| 4 | **disableIdempotentCheck** | Boolean that determines if we should check for idempotence or not. Compares the proposed extension date to the value in TransitionDomains. Defaults to False. |
+
+
+## Populate First Ready
+This section outlines how to run the populate_first_ready script
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Running the script
+```./manage.py populate_first_ready --debug```
+
+### Running locally
+```docker-compose exec app ./manage.py populate_first_ready --debug```
+
+##### Optional parameters
+| | Parameter | Description |
+|:-:|:-------------------------- |:----------------------------------------------------------------------------|
+| 1 | **debug** | Increases logging detail. Defaults to False. |
+
+
+## Patch Federal Agency Info
+This section outlines how to use `patch_federal_agency_info.py`
+
+### Running on sandboxes
+
+#### Step 1: Grab the latest `current-full.csv` file from the dotgov-data repo
+Download the csv from [here](https://github.com/cisagov/dotgov-data/blob/main/current-full.csv) and place this file under the `src/migrationdata/` directory.
+
+#### Step 2: Transfer the `current-full.csv` file to your sandbox
+[Click here to go to the section about transferring data to sandboxes](#step-1-transfer-data-to-sandboxes)
+
+#### Step 3: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 4: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 5: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 6: Patch agency info
+```./manage.py patch_federal_agency_info migrationdata/current-full.csv --debug```
+
+### Running locally
+```docker-compose exec app ./manage.py patch_federal_agency_info migrationdata/current-full.csv --debug```
+
+##### Optional parameters
+| | Parameter | Description |
+|:-:|:-------------------------- |:----------------------------------------------------------------------------|
+| 1 | **debug** | Increases logging detail. Defaults to False. |
diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml
index 38109bdcb..3ca800392 100644
--- a/ops/manifests/manifest-ab.yaml
+++ b/ops/manifests/manifest-ab.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-backup.yaml b/ops/manifests/manifest-backup.yaml
index c4615d1d5..ab9e36d68 100644
--- a/ops/manifests/manifest-backup.yaml
+++ b/ops/manifests/manifest-backup.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-development.yaml b/ops/manifests/manifest-development.yaml
index 0a1f30ffa..08244cf08 100644
--- a/ops/manifests/manifest-development.yaml
+++ b/ops/manifests/manifest-development.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-dk.yaml b/ops/manifests/manifest-dk.yaml
index 256beeda2..071efb416 100644
--- a/ops/manifests/manifest-dk.yaml
+++ b/ops/manifests/manifest-dk.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-es.yaml b/ops/manifests/manifest-es.yaml
index 47c78ce1b..7fd19b7a0 100644
--- a/ops/manifests/manifest-es.yaml
+++ b/ops/manifests/manifest-es.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-gd.yaml b/ops/manifests/manifest-gd.yaml
index 0c4b2535f..89a7c2169 100644
--- a/ops/manifests/manifest-gd.yaml
+++ b/ops/manifests/manifest-gd.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-ko.yaml b/ops/manifests/manifest-ko.yaml
index cc6a09337..a69493f9b 100644
--- a/ops/manifests/manifest-ko.yaml
+++ b/ops/manifests/manifest-ko.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-ky.yaml b/ops/manifests/manifest-ky.yaml
index 31d67cfb3..f416d7385 100644
--- a/ops/manifests/manifest-ky.yaml
+++ b/ops/manifests/manifest-ky.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-nl.yaml b/ops/manifests/manifest-nl.yaml
index ca6fb4693..d74174e7d 100644
--- a/ops/manifests/manifest-nl.yaml
+++ b/ops/manifests/manifest-nl.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-rb.yaml b/ops/manifests/manifest-rb.yaml
index 62f243513..570b49dde 100644
--- a/ops/manifests/manifest-rb.yaml
+++ b/ops/manifests/manifest-rb.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-rh.yaml b/ops/manifests/manifest-rh.yaml
index 4985f3261..f44894ce8 100644
--- a/ops/manifests/manifest-rh.yaml
+++ b/ops/manifests/manifest-rh.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-rjm.yaml b/ops/manifests/manifest-rjm.yaml
index 7d72e7835..048b44e95 100644
--- a/ops/manifests/manifest-rjm.yaml
+++ b/ops/manifests/manifest-rjm.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml
index d8502c625..a70035445 100644
--- a/ops/manifests/manifest-stable.yaml
+++ b/ops/manifests/manifest-stable.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Which OIDC provider to use
OIDC_ACTIVE_PROVIDER: login.gov production
# Flag to disable/enable features in prod environments
diff --git a/ops/manifests/manifest-staging.yaml b/ops/manifests/manifest-staging.yaml
index b616973ac..38099cf17 100644
--- a/ops/manifests/manifest-staging.yaml
+++ b/ops/manifests/manifest-staging.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/manifests/manifest-za.yaml b/ops/manifests/manifest-za.yaml
index 1b84a74a1..271f49da9 100644
--- a/ops/manifests/manifest-za.yaml
+++ b/ops/manifests/manifest-za.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/ops/scripts/manifest-sandbox-template-migrate.yaml b/ops/scripts/manifest-sandbox-template-migrate.yaml
index dfebed766..9054e9494 100644
--- a/ops/scripts/manifest-sandbox-template-migrate.yaml
+++ b/ops/scripts/manifest-sandbox-template-migrate.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# use a non-default route to avoid conflicts
routes:
- route: getgov-ENVIRONMENT-migrate.app.cloud.gov
diff --git a/ops/scripts/manifest-sandbox-template.yaml b/ops/scripts/manifest-sandbox-template.yaml
index 8cdb8d71b..f0aee9664 100644
--- a/ops/scripts/manifest-sandbox-template.yaml
+++ b/ops/scripts/manifest-sandbox-template.yaml
@@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
- GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
diff --git a/src/.pa11yci b/src/.pa11yci
index 0ab3f4dd7..12b76cd90 100644
--- a/src/.pa11yci
+++ b/src/.pa11yci
@@ -6,19 +6,19 @@
"urls": [
"http://localhost:8080/",
"http://localhost:8080/health/",
- "http://localhost:8080/register/",
- "http://localhost:8080/register/organization/",
- "http://localhost:8080/register/org_federal/",
- "http://localhost:8080/register/org_election/",
- "http://localhost:8080/register/org_contact/",
- "http://localhost:8080/register/authorizing_official/",
- "http://localhost:8080/register/current_sites/",
- "http://localhost:8080/register/dotgov_domain/",
- "http://localhost:8080/register/purpose/",
- "http://localhost:8080/register/your_contact/",
- "http://localhost:8080/register/other_contacts/",
- "http://localhost:8080/register/anything_else/",
- "http://localhost:8080/register/requirements/",
- "http://localhost:8080/register/finished/"
+ "http://localhost:8080/request/",
+ "http://localhost:8080/request/organization/",
+ "http://localhost:8080/request/org_federal/",
+ "http://localhost:8080/request/org_election/",
+ "http://localhost:8080/request/org_contact/",
+ "http://localhost:8080/request/authorizing_official/",
+ "http://localhost:8080/request/current_sites/",
+ "http://localhost:8080/request/dotgov_domain/",
+ "http://localhost:8080/request/purpose/",
+ "http://localhost:8080/request/your_contact/",
+ "http://localhost:8080/request/other_contacts/",
+ "http://localhost:8080/request/anything_else/",
+ "http://localhost:8080/request/requirements/",
+ "http://localhost:8080/request/finished/"
]
}
diff --git a/src/api/views.py b/src/api/views.py
index 3071712a7..f9fa2d1ea 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -1,10 +1,11 @@
"""Internal API views"""
from django.apps import apps
from django.views.decorators.http import require_http_methods
-from django.http import HttpResponse, JsonResponse
+from django.http import HttpResponse
from django.utils.safestring import mark_safe
from registrar.templatetags.url_helpers import public_site_url
+from registrar.utility.enums import ValidationReturnType
from registrar.utility.errors import GenericError, GenericErrorCodes
import requests
@@ -71,6 +72,7 @@ def check_domain_available(domain):
a match. If check fails, throws a RegistryError.
"""
Domain = apps.get_model("registrar.Domain")
+
if domain.endswith(".gov"):
return Domain.available(domain)
else:
@@ -86,22 +88,14 @@ def available(request, domain=""):
Response is a JSON dictionary with the key "available" and value true or
false.
"""
+ Domain = apps.get_model("registrar.Domain")
domain = request.GET.get("domain", "")
- DraftDomain = apps.get_model("registrar.DraftDomain")
- # validate that the given domain could be a domain name and fail early if
- # not.
- if not (DraftDomain.string_could_be_domain(domain) or DraftDomain.string_could_be_domain(domain + ".gov")):
- return JsonResponse({"available": False, "code": "invalid", "message": DOMAIN_API_MESSAGES["invalid"]})
- # a domain is available if it is NOT in the list of current domains
- try:
- if check_domain_available(domain):
- return JsonResponse({"available": True, "code": "success", "message": DOMAIN_API_MESSAGES["success"]})
- else:
- return JsonResponse(
- {"available": False, "code": "unavailable", "message": DOMAIN_API_MESSAGES["unavailable"]}
- )
- except Exception:
- return JsonResponse({"available": False, "code": "error", "message": DOMAIN_API_MESSAGES["error"]})
+
+ _, json_response = Domain.validate_and_handle_errors(
+ domain=domain,
+ return_type=ValidationReturnType.JSON_RESPONSE,
+ )
+ return json_response
@require_http_methods(["GET"])
diff --git a/src/docker-compose.yml b/src/docker-compose.yml
index c9b78fd8e..ba6530674 100644
--- a/src/docker-compose.yml
+++ b/src/docker-compose.yml
@@ -32,7 +32,7 @@ services:
# Is this a production environment
- IS_PRODUCTION
# Public site URL link
- - GETGOV_PUBLIC_SITE_URL=https://beta.get.gov
+ - GETGOV_PUBLIC_SITE_URL=https://get.gov
# Set a username for accessing the registry
- REGISTRY_CL_ID=nothing
# Set a password for accessing the registry
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 18eb4119c..8d3b1d29f 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -401,6 +401,8 @@ class HostIPInline(admin.StackedInline):
class MyHostAdmin(AuditedAdmin):
"""Custom host admin class to use our inlines."""
+ search_fields = ["name", "domain__name"]
+ search_help_text = "Search by domain or hostname."
inlines = [HostIPInline]
@@ -1252,7 +1254,7 @@ admin.site.register(models.Domain, DomainAdmin)
admin.site.register(models.DraftDomain, DraftDomainAdmin)
# Host and HostIP removed from django admin because changes in admin
# do not propagate to registry and logic not applied
-# admin.site.register(models.Host, MyHostAdmin)
+admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin)
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index a2a99e104..de7ef6172 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -134,10 +134,19 @@ function _checkDomainAvailability(el) {
const callback = (response) => {
toggleInputValidity(el, (response && response.available), msg=response.message);
announce(el.id, response.message);
+
+ // Determines if we ignore the field if it is just blank
+ ignore_blank = el.classList.contains("blank-ok")
if (el.validity.valid) {
el.classList.add('usa-input--success');
// use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration
inlineToast(el.parentElement, el.id, SUCCESS, response.message);
+ } else if (ignore_blank && response.code == "required"){
+ // Visually remove the error
+ error = "usa-input--error"
+ if (el.classList.contains(error)){
+ el.classList.remove(error)
+ }
} else {
inlineToast(el.parentElement, el.id, ERROR, response.message);
}
@@ -229,99 +238,210 @@ function handleValidationClick(e) {
}
})();
+/**
+ * Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data)
+ *
+ */
+function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
+ let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
+ let formToRemove = e.target.closest(".repeatable-form");
+ formToRemove.remove();
+ let forms = document.querySelectorAll(".repeatable-form");
+ totalForms.setAttribute('value', `${forms.length}`);
+
+ let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
+ let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
+ // For the example on Nameservers
+ let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g');
+
+ forms.forEach((form, index) => {
+ // Iterate over child nodes of the current element
+ Array.from(form.querySelectorAll('label, input, select')).forEach((node) => {
+ // Iterate through the attributes of the current node
+ Array.from(node.attributes).forEach((attr) => {
+ // Check if the attribute value matches the regex
+ if (formNumberRegex.test(attr.value)) {
+ // Replace the attribute value with the updated value
+ attr.value = attr.value.replace(formNumberRegex, `form-${index}-`);
+ }
+ });
+ });
+
+ // h2 and legend for DS form, label for nameservers
+ Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
+
+ let innerSpan = node.querySelector('span')
+ if (innerSpan) {
+ innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
+ } else {
+ node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
+ node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
+ }
+
+ // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
+ // inject the USWDS required markup and make sure the INPUT is required
+ if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
+
+ // Remove the word optional
+ innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, '');
+
+ // Create a new element
+ const newElement = document.createElement('abbr');
+ newElement.textContent = '*';
+ newElement.setAttribute("title", "required");
+ newElement.classList.add("usa-hint", "usa-hint--required");
+
+ // Append the new element to the label
+ node.appendChild(newElement);
+ // Find the next sibling that is an input element
+ let nextInputElement = node.nextElementSibling;
+
+ while (nextInputElement) {
+ if (nextInputElement.tagName === 'INPUT') {
+ // Found the next input element
+ nextInputElement.setAttribute("required", "")
+ break;
+ }
+ nextInputElement = nextInputElement.nextElementSibling;
+ }
+ nextInputElement.required = true;
+ }
+
+
+
+ });
+
+ // Display the add more button if we have less than 13 forms
+ if (isNameserversForm && forms.length <= 13) {
+ addButton.removeAttribute("disabled");
+ }
+
+ if (isNameserversForm && forms.length < 3) {
+ // Hide the delete buttons on the remaining nameservers
+ Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
+ deleteButton.setAttribute("disabled", "true");
+ });
+ }
+
+ });
+}
/**
- * Prepare the namerservers and DS data forms delete buttons
- * We will call this on the forms init, and also every time we add a form
+ * Delete method for formsets using the DJANGO DELETE widget (Other Contacts)
+ *
+ */
+function markForm(e, formLabel){
+ // Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget
+ let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
+
+ if (totalShownForms == 1) {
+ // toggle the radio buttons
+ let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]');
+ radioButton.checked = true;
+ // Trigger the change event
+ let event = new Event('change');
+ radioButton.dispatchEvent(event);
+ } else {
+
+ // Grab the hidden delete input and assign a value DJANGO will look for
+ let formToRemove = e.target.closest(".repeatable-form");
+ if (formToRemove) {
+ let deleteInput = formToRemove.querySelector('input[class="deletion"]');
+ if (deleteInput) {
+ deleteInput.value = 'on';
+ }
+ }
+
+ // Set display to 'none'
+ formToRemove.style.display = 'none';
+ }
+
+ // Update h2s on the visible forms only. We won't worry about the forms' identifiers
+ let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`);
+ let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
+ shownForms.forEach((form, index) => {
+ // Iterate over child nodes of the current element
+ Array.from(form.querySelectorAll('h2')).forEach((node) => {
+ node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
+ });
+ });
+}
+
+/**
+ * Prepare the namerservers, DS data and Other Contacts formsets' delete button
+ * for the last added form. We call this from the Add function
+ *
+ */
+function prepareNewDeleteButton(btn, formLabel) {
+ let formIdentifier = "form"
+ let isNameserversForm = document.querySelector(".nameservers-form");
+ let isOtherContactsForm = document.querySelector(".other-contacts-form");
+ let addButton = document.querySelector("#add-form");
+
+ if (isOtherContactsForm) {
+ formIdentifier = "other_contacts";
+ // We will mark the forms for deletion
+ btn.addEventListener('click', function(e) {
+ markForm(e, formLabel);
+ });
+ } else {
+ // We will remove the forms and re-order the formset
+ btn.addEventListener('click', function(e) {
+ removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
+ });
+ }
+}
+
+/**
+ * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons
+ * We will call this on the forms init
*
*/
function prepareDeleteButtons(formLabel) {
+ let formIdentifier = "form"
let deleteButtons = document.querySelectorAll(".delete-record");
- let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
- let isNameserversForm = document.title.includes("DNS name servers |");
+ let isNameserversForm = document.querySelector(".nameservers-form");
+ let isOtherContactsForm = document.querySelector(".other-contacts-form");
let addButton = document.querySelector("#add-form");
-
+ if (isOtherContactsForm) {
+ formIdentifier = "other_contacts";
+ }
+
// Loop through each delete button and attach the click event listener
deleteButtons.forEach((deleteButton) => {
- deleteButton.addEventListener('click', removeForm);
+ if (isOtherContactsForm) {
+ // We will mark the forms for deletion
+ deleteButton.addEventListener('click', function(e) {
+ markForm(e, formLabel);
+ });
+ } else {
+ // We will remove the forms and re-order the formset
+ deleteButton.addEventListener('click', function(e) {
+ removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
+ });
+ }
});
+}
- function removeForm(e){
- let formToRemove = e.target.closest(".repeatable-form");
- formToRemove.remove();
- let forms = document.querySelectorAll(".repeatable-form");
- totalForms.setAttribute('value', `${forms.length}`);
+/**
+ * DJANGO formset's DELETE widget
+ * On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion'
+ * with value='on'
+ */
+function hideDeletedForms() {
+ let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]');
- let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
- let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g');
- // For the example on Nameservers
- let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g');
-
- forms.forEach((form, index) => {
- // Iterate over child nodes of the current element
- Array.from(form.querySelectorAll('label, input, select')).forEach((node) => {
- // Iterate through the attributes of the current node
- Array.from(node.attributes).forEach((attr) => {
- // Check if the attribute value matches the regex
- if (formNumberRegex.test(attr.value)) {
- // Replace the attribute value with the updated value
- attr.value = attr.value.replace(formNumberRegex, `form-${index}-`);
- }
- });
- });
-
- // h2 and legend for DS form, label for nameservers
- Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
-
- // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
- // inject the USWDS required markup and make sure the INPUT is required
- if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
- // Create a new element
- const newElement = document.createElement('abbr');
- newElement.textContent = '*';
- newElement.setAttribute("title", "required");
- newElement.classList.add("usa-hint", "usa-hint--required");
-
- // Append the new element to the label
- node.appendChild(newElement);
- // Find the next sibling that is an input element
- let nextInputElement = node.nextElementSibling;
-
- while (nextInputElement) {
- if (nextInputElement.tagName === 'INPUT') {
- // Found the next input element
- nextInputElement.setAttribute("required", "")
- break;
- }
- nextInputElement = nextInputElement.nextElementSibling;
- }
- nextInputElement.required = true;
- }
-
- let innerSpan = node.querySelector('span')
- if (innerSpan) {
- innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
- } else {
- node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
- node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
- }
- });
-
- // Display the add more button if we have less than 13 forms
- if (isNameserversForm && forms.length <= 13) {
- console.log('remove disabled');
- addButton.removeAttribute("disabled");
+ // Iterating over the NodeList of hidden inputs
+ hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) {
+ // Finding the closest parent element with class "repeatable-form" for each hidden input
+ var repeatableFormToHide = hiddenInput.closest('.repeatable-form');
+
+ // Checking if a matching parent element is found for each hidden input
+ if (repeatableFormToHide) {
+ // Setting the display property to "none" for each matching parent element
+ repeatableFormToHide.style.display = 'none';
}
-
- if (isNameserversForm && forms.length < 3) {
- // Hide the delete buttons on the remaining nameservers
- Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
- deleteButton.setAttribute("disabled", "true");
- });
- }
-
- });
- }
+ });
}
/**
@@ -331,25 +451,38 @@ function prepareDeleteButtons(formLabel) {
* it everywhere.
*/
(function prepareFormsetsForms() {
+ let formIdentifier = "form"
let repeatableForm = document.querySelectorAll(".repeatable-form");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-form");
- let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
let cloneIndex = 0;
let formLabel = '';
- let isNameserversForm = document.title.includes("DNS name servers |");
+ let isNameserversForm = document.querySelector(".nameservers-form");
+ let isOtherContactsForm = document.querySelector(".other-contacts-form");
+ let isDsDataForm = document.querySelector(".ds-data-form");
+ // The Nameservers formset features 2 required and 11 optionals
if (isNameserversForm) {
cloneIndex = 2;
formLabel = "Name server";
- } else if ((document.title.includes("DS Data |")) || (document.title.includes("Key Data |"))) {
- formLabel = "DS Data record";
+ // DNSSEC: DS Data
+ } else if (isDsDataForm) {
+ formLabel = "DS data record";
+ // The Other Contacts form
+ } else if (isOtherContactsForm) {
+ formLabel = "Organization contact";
+ container = document.querySelector("#other-employees");
+ formIdentifier = "other_contacts"
}
+ let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
// On load: Disable the add more button if we have 13 forms
if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) {
addButton.setAttribute("disabled", "true");
}
+ // Hide forms which have previously been deleted
+ hideDeletedForms()
+
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons(formLabel);
@@ -360,7 +493,7 @@ function prepareDeleteButtons(formLabel) {
let forms = document.querySelectorAll(".repeatable-form");
let formNum = forms.length;
let newForm = repeatableForm[cloneIndex].cloneNode(true);
- let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
+ let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g');
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
@@ -393,16 +526,35 @@ function prepareDeleteButtons(formLabel) {
}
formNum++;
- newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
- newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
+
+ newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`);
+ if (isOtherContactsForm) {
+ // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
+ // since the form on the backend employs Django's DELETE widget.
+ let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
+ newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`);
+ } else {
+ // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional
+ // if indices 0 or 1 have been deleted
+ let containsOptional = newForm.innerHTML.includes('(optional)');
+ if (isNameserversForm && !containsOptional) {
+ newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`);
+ } else {
+ newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
+ }
+ }
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`);
+ newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters
+ newForm.innerHTML = newForm.innerHTML.replace(/>\s*<'); // Remove spaces between tags
container.insertBefore(newForm, addButton);
+ newForm.style.display = 'block';
+
let inputs = newForm.querySelectorAll("input");
// Reset the values of each input to blank
inputs.forEach((input) => {
input.classList.remove("usa-input--error");
- if (input.type === "text" || input.type === "number" || input.type === "password") {
+ if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") {
input.value = ""; // Set the value to an empty string
} else if (input.type === "checkbox" || input.type === "radio") {
@@ -439,7 +591,8 @@ function prepareDeleteButtons(formLabel) {
totalForms.setAttribute('value', `${formNum}`);
// Attach click event listener on the delete buttons of the new form
- prepareDeleteButtons(formLabel);
+ let newDeleteButton = newForm.querySelector(".delete-record");
+ prepareNewDeleteButton(newDeleteButton, formLabel);
// Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
@@ -483,3 +636,58 @@ function prepareDeleteButtons(formLabel) {
}, 50);
}
})();
+
+// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle
+function toggleTwoDomElements(ele1, ele2, index) {
+ let element1 = document.getElementById(ele1);
+ let element2 = document.getElementById(ele2);
+ if (element1 && element2) {
+ // Toggle display based on the index
+ element1.style.display = index === 1 ? 'block' : 'none';
+ element2.style.display = index === 2 ? 'block' : 'none';
+ } else {
+ console.error('One or both elements not found.');
+ }
+}
+
+/**
+ * An IIFE that listens to the other contacts radio form on DAs and toggles the contacts/no other contacts forms
+ *
+ */
+(function otherContactsFormListener() {
+ // Get the radio buttons
+ let radioButtons = document.querySelectorAll('input[name="other_contacts-has_other_contacts"]');
+
+ function handleRadioButtonChange() {
+ // Check the value of the selected radio button
+ // Attempt to find the radio button element that is checked
+ let radioButtonChecked = document.querySelector('input[name="other_contacts-has_other_contacts"]:checked');
+
+ // Check if the element exists before accessing its value
+ let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
+
+ switch (selectedValue) {
+ case 'True':
+ toggleTwoDomElements('other-employees', 'no-other-employees', 1);
+ break;
+
+ case 'False':
+ toggleTwoDomElements('other-employees', 'no-other-employees', 2);
+ break;
+
+ default:
+ toggleTwoDomElements('other-employees', 'no-other-employees', 0);
+ }
+ }
+
+ if (radioButtons.length) {
+ // Add event listener to each radio button
+ radioButtons.forEach(function (radioButton) {
+ radioButton.addEventListener('change', handleRadioButtonChange);
+ });
+
+ // initialize
+ handleRadioButtonChange();
+ }
+})();
+
diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss
index 1d936a255..b6d13cee3 100644
--- a/src/registrar/assets/sass/_theme/_base.scss
+++ b/src/registrar/assets/sass/_theme/_base.scss
@@ -4,6 +4,10 @@
.sr-only {
@include sr-only;
}
+
+.clear-both {
+ clear: both;
+}
* {
-webkit-font-smoothing: antialiased;
diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss
index d0bfbee67..94407f88d 100644
--- a/src/registrar/assets/sass/_theme/_forms.scss
+++ b/src/registrar/assets/sass/_theme/_forms.scss
@@ -31,3 +31,10 @@
padding-left: 0;
border-left: none;
}
+
+legend.float-left-tablet + button.float-right-tablet {
+ margin-top: .5rem;
+ @include at-media('tablet') {
+ margin-top: 1rem;
+ }
+}
diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss
index 4fc2bb819..cc0d39a5b 100644
--- a/src/registrar/assets/sass/_theme/_typography.scss
+++ b/src/registrar/assets/sass/_theme/_typography.scss
@@ -22,3 +22,9 @@ h2 {
margin: units(4) 0 units(1);
color: color('primary-darker');
}
+
+// Normalize typography in forms
+.usa-form,
+.usa-form fieldset {
+ font-size: 1rem;
+}
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index f037d4901..372434887 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -335,7 +335,7 @@ CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
# Cross-Origin Resource Sharing (CORS) configuration
# Sets clients that allow access control to manage.get.gov
# TODO: remove :8080 to see if we can have all localhost access
-CORS_ALLOWED_ORIGINS = ["http://localhost:8080", "https://beta.get.gov"]
+CORS_ALLOWED_ORIGINS = ["http://localhost:8080", "https://beta.get.gov", "https://get.gov"]
CORS_ALLOWED_ORIGIN_REGEXES = [r"https://[\w-]+\.sites\.pages\.cloud\.gov"]
# Content-Length header is set by django.middleware.common.CommonMiddleware
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 607bf5f61..a01707faa 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -39,7 +39,6 @@ for step, view in [
(Step.PURPOSE, views.Purpose),
(Step.YOUR_CONTACT, views.YourContact),
(Step.OTHER_CONTACTS, views.OtherContacts),
- (Step.NO_OTHER_CONTACTS, views.NoOtherContacts),
(Step.ANYTHING_ELSE, views.AnythingElse),
(Step.REQUIREMENTS, views.Requirements),
(Step.REVIEW, views.Review),
@@ -77,7 +76,7 @@ urlpatterns = [
),
path("health/", views.health),
path("openid/", include("djangooidc.urls")),
- path("register/", include((application_urls, APPLICATION_NAMESPACE))),
+ path("request/", include((application_urls, APPLICATION_NAMESPACE))),
path("api/v1/available/", available, name="available"),
path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"),
path("api/v1/get-report/current-full", get_current_full, name="get-current-full"),
diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py
index 2802b1893..ae6188133 100644
--- a/src/registrar/forms/application_wizard.py
+++ b/src/registrar/forms/application_wizard.py
@@ -2,17 +2,17 @@ from __future__ import annotations # allows forward references in annotations
from itertools import zip_longest
import logging
from typing import Callable
+from api.views import DOMAIN_API_MESSAGES
from phonenumber_field.formfields import PhoneNumberField # type: ignore
from django import forms
from django.core.validators import RegexValidator, MaxLengthValidator
from django.utils.safestring import mark_safe
-
-from api.views import DOMAIN_API_MESSAGES
+from django.db.models.fields.related import ForeignObjectRel
from registrar.models import Contact, DomainApplication, DraftDomain, Domain
from registrar.templatetags.url_helpers import public_site_url
-from registrar.utility import errors
+from registrar.utility.enums import ValidationReturnType
logger = logging.getLogger(__name__)
@@ -115,26 +115,45 @@ class RegistrarFormSet(forms.BaseFormSet):
query = getattr(obj, join).order_by("created_at").all() # order matters
+ # get the related name for the join defined for the db_obj for this form.
+ # the related name will be the reference on a related object back to db_obj
+ related_name = ""
+ field = obj._meta.get_field(join)
+ if isinstance(field, ForeignObjectRel) and callable(field.related_query_name):
+ related_name = field.related_query_name()
+ elif hasattr(field, "related_query_name") and callable(field.related_query_name):
+ related_name = field.related_query_name()
+
# the use of `zip` pairs the forms in the formset with the
# related objects gotten from the database -- there should always be
# at least as many forms as database entries: extra forms means new
# entries, but fewer forms is _not_ the correct way to delete items
# (likely a client-side error or an attempt at data tampering)
-
for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None):
cleaned = post_data.cleaned_data if post_data is not None else {}
# matching database object exists, update it
if db_obj is not None and cleaned:
if should_delete(cleaned):
- db_obj.delete()
- continue
+ if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name):
+ # Remove the specific relationship without deleting the object
+ getattr(db_obj, related_name).remove(self.application)
+ else:
+ # If there are no other relationships, delete the object
+ db_obj.delete()
else:
- pre_update(db_obj, cleaned)
- db_obj.save()
+ if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name):
+ # create a new db_obj and disconnect existing one
+ getattr(db_obj, related_name).remove(self.application)
+ kwargs = pre_create(db_obj, cleaned)
+ getattr(obj, join).create(**kwargs)
+ else:
+ pre_update(db_obj, cleaned)
+ db_obj.save()
# no matching database object, create it
- elif db_obj is None and cleaned:
+ # make sure not to create a database object if cleaned has 'delete' attribute
+ elif db_obj is None and cleaned and not cleaned.get("DELETE", False):
kwargs = pre_create(db_obj, cleaned)
getattr(obj, join).create(**kwargs)
@@ -170,7 +189,7 @@ class TribalGovernmentForm(RegistrarForm):
)
tribe_name = forms.CharField(
- label="What is the name of the tribe you represent?",
+ label="Name of tribe",
error_messages={"required": "Enter the tribe you represent."},
)
@@ -308,13 +327,18 @@ class AboutYourOrganizationForm(RegistrarForm):
class AuthorizingOfficialForm(RegistrarForm):
+ JOIN = "authorizing_official"
+
def to_database(self, obj):
if not self.is_valid():
return
contact = getattr(obj, "authorizing_official", None)
- if contact is not None:
+ if contact is not None and not contact.has_more_than_one_join("authorizing_official"):
+ # if contact exists in the database and is not joined to other entities
super().to_database(contact)
else:
+ # no contact exists OR contact exists which is joined also to other entities;
+ # in either case, create a new contact and update it
contact = Contact()
super().to_database(contact)
obj.authorizing_official = contact
@@ -366,6 +390,8 @@ class BaseCurrentSitesFormSet(RegistrarFormSet):
return website.strip() == ""
def to_database(self, obj: DomainApplication):
+ # If we want to test against multiple joins for a website object, replace the empty array
+ # and change the JOIN in the models to allow for reverse references
self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
@classmethod
@@ -384,17 +410,12 @@ CurrentSitesFormSet = forms.formset_factory(
class AlternativeDomainForm(RegistrarForm):
def clean_alternative_domain(self):
"""Validation code for domain names."""
- try:
- requested = self.cleaned_data.get("alternative_domain", None)
- validated = DraftDomain.validate(requested, blank_ok=True)
- except errors.ExtraDotsError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
- except errors.DomainUnavailableError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
- except errors.RegistrySystemError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
- except ValueError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
+ requested = self.cleaned_data.get("alternative_domain", None)
+ validated, _ = DraftDomain.validate_and_handle_errors(
+ domain=requested,
+ return_type=ValidationReturnType.FORM_VALIDATION_ERROR,
+ blank_ok=True,
+ )
return validated
alternative_domain = forms.CharField(
@@ -423,6 +444,8 @@ class BaseAlternativeDomainFormSet(RegistrarFormSet):
return {}
def to_database(self, obj: DomainApplication):
+ # If we want to test against multiple joins for a website object, replace the empty array and
+ # change the JOIN in the models to allow for reverse references
self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
@classmethod
@@ -469,22 +492,19 @@ class DotGovDomainForm(RegistrarForm):
def clean_requested_domain(self):
"""Validation code for domain names."""
- try:
- requested = self.cleaned_data.get("requested_domain", None)
- validated = DraftDomain.validate(requested)
- except errors.BlankValueError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["required"], code="required")
- except errors.ExtraDotsError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
- except errors.DomainUnavailableError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
- except errors.RegistrySystemError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
- except ValueError:
- raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
+ requested = self.cleaned_data.get("requested_domain", None)
+ validated, _ = DraftDomain.validate_and_handle_errors(
+ domain=requested,
+ return_type=ValidationReturnType.FORM_VALIDATION_ERROR,
+ )
return validated
- requested_domain = forms.CharField(label="What .gov domain do you want?")
+ requested_domain = forms.CharField(
+ label="What .gov domain do you want?",
+ error_messages={
+ "required": DOMAIN_API_MESSAGES["required"],
+ },
+ )
class PurposeForm(RegistrarForm):
@@ -497,18 +517,23 @@ class PurposeForm(RegistrarForm):
message="Response must be less than 1000 characters.",
)
],
- error_messages={"required": "Describe how you'll use the .gov domain you’re requesting."},
+ error_messages={"required": "Describe how you’ll use the .gov domain you’re requesting."},
)
class YourContactForm(RegistrarForm):
+ JOIN = "submitter"
+
def to_database(self, obj):
if not self.is_valid():
return
contact = getattr(obj, "submitter", None)
- if contact is not None:
+ if contact is not None and not contact.has_more_than_one_join("submitted_applications"):
+ # if contact exists in the database and is not joined to other entities
super().to_database(contact)
else:
+ # no contact exists OR contact exists which is joined also to other entities;
+ # in either case, create a new contact and update it
contact = Contact()
super().to_database(contact)
obj.submitter = contact
@@ -547,6 +572,30 @@ class YourContactForm(RegistrarForm):
)
+class OtherContactsYesNoForm(RegistrarForm):
+ def __init__(self, *args, **kwargs):
+ """Extend the initialization of the form from RegistrarForm __init__"""
+ super().__init__(*args, **kwargs)
+ # set the initial value based on attributes of application
+ if self.application and self.application.has_other_contacts():
+ initial_value = True
+ elif self.application and self.application.has_rationale():
+ initial_value = False
+ else:
+ # No pre-selection for new applications
+ initial_value = None
+
+ self.fields["has_other_contacts"] = forms.TypedChoiceField(
+ coerce=lambda x: x.lower() == "true" if x is not None else None, # coerce strings to bool, excepting None
+ choices=((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")),
+ initial=initial_value,
+ widget=forms.RadioSelect,
+ error_messages={
+ "required": "This question is required.",
+ },
+ )
+
+
class OtherContactsForm(RegistrarForm):
first_name = forms.CharField(
label="First name / given name",
@@ -570,7 +619,10 @@ class OtherContactsForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
- error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
+ error_messages={
+ "required": ("Enter an email address in the required format, like name@example.com."),
+ "invalid": ("Enter an email address in the required format, like name@example.com."),
+ },
)
phone = PhoneNumberField(
label="Phone",
@@ -580,25 +632,164 @@ class OtherContactsForm(RegistrarForm):
},
)
+ def __init__(self, *args, **kwargs):
+ """
+ Override the __init__ method for RegistrarForm.
+ Set form_data_marked_for_deletion to false.
+ Empty_permitted set to False, as this is overridden in certain circumstances by
+ Django's BaseFormSet, and results in empty forms being allowed and field level
+ errors not appropriately raised. This works with code in the view which appropriately
+ displays required attributes on fields.
+ """
+ self.form_data_marked_for_deletion = False
+ super().__init__(*args, **kwargs)
+ self.empty_permitted = False
+
+ def mark_form_for_deletion(self):
+ self.form_data_marked_for_deletion = True
+
def clean(self):
"""
This method overrides the default behavior for forms.
This cleans the form after field validation has already taken place.
- In this override, allow for a form which is empty to be considered
- valid even though certain required fields have not passed field
- validation
+ In this override, allow for a form which is deleted by user or marked for
+ deletion by formset to be considered valid even though certain required fields have
+ not passed field validation
+ """
+ if self.form_data_marked_for_deletion or self.cleaned_data.get("DELETE"):
+ # clear any errors raised by the form fields
+ # (before this clean() method is run, each field
+ # performs its own clean, which could result in
+ # errors that we wish to ignore at this point)
+ #
+ # NOTE: we cannot just clear() the errors list.
+ # That causes problems.
+ for field in self.fields:
+ if field in self.errors:
+ del self.errors[field]
+ # return empty object with only 'delete' attribute defined.
+ # this will prevent _to_database from creating an empty
+ # database object
+ return {"DELETE": True}
+
+ return self.cleaned_data
+
+
+class BaseOtherContactsFormSet(RegistrarFormSet):
+ """
+ FormSet for Other Contacts
+
+ There are two conditions by which a form in the formset can be marked for deletion.
+ One is if the user clicks 'DELETE' button, and this is submitted in the form. The
+ other is if the YesNo form, which is submitted with this formset, is set to No; in
+ this case, all forms in formset are marked for deletion. Both of these conditions
+ must co-exist.
+ Also, other_contacts have db relationships to multiple db objects. When attempting
+ to delete an other_contact from an application, those db relationships must be
+ tested and handled.
+ """
+
+ JOIN = "other_contacts"
+
+ def get_deletion_widget(self):
+ return forms.HiddenInput(attrs={"class": "deletion"})
+
+ def __init__(self, *args, **kwargs):
+ """
+ Override __init__ for RegistrarFormSet.
+ """
+ self.formset_data_marked_for_deletion = False
+ self.application = kwargs.pop("application", None)
+ super(RegistrarFormSet, self).__init__(*args, **kwargs)
+ # quick workaround to ensure that the HTML `required`
+ # attribute shows up on required fields for the first form
+ # in the formset plus those that have data already.
+ for index in range(max(self.initial_form_count(), 1)):
+ self.forms[index].use_required_attribute = True
+
+ def should_delete(self, cleaned):
+ """
+ Implements should_delete method from BaseFormSet.
+ """
+ return self.formset_data_marked_for_deletion or cleaned.get("DELETE", False)
+
+ def pre_create(self, db_obj, cleaned):
+ """Code to run before an item in the formset is created in the database."""
+ # remove DELETE from cleaned
+ if "DELETE" in cleaned:
+ cleaned.pop("DELETE")
+ return cleaned
+
+ def to_database(self, obj: DomainApplication):
+ self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
+
+ @classmethod
+ def from_database(cls, obj):
+ return super().from_database(obj, cls.JOIN, cls.on_fetch)
+
+ def mark_formset_for_deletion(self):
+ """Mark other contacts formset for deletion.
+ Updates forms in formset as well to mark them for deletion.
+ This has an effect on validity checks and to_database methods.
+ """
+ self.formset_data_marked_for_deletion = True
+ for form in self.forms:
+ form.mark_form_for_deletion()
+
+ def is_valid(self):
+ """Extend is_valid from RegistrarFormSet. When marking this formset for deletion, set
+ validate_min to false so that validation does not attempt to enforce a minimum
+ number of other contacts when contacts marked for deletion"""
+ if self.formset_data_marked_for_deletion:
+ self.validate_min = False
+ return super().is_valid()
+
+
+OtherContactsFormSet = forms.formset_factory(
+ OtherContactsForm,
+ extra=0,
+ absolute_max=1500, # django default; use `max_num` to limit entries
+ min_num=1,
+ can_delete=True,
+ validate_min=True,
+ formset=BaseOtherContactsFormSet,
+)
+
+
+class NoOtherContactsForm(RegistrarForm):
+ no_other_contacts_rationale = forms.CharField(
+ required=True,
+ # label has to end in a space to get the label_suffix to show
+ label=("No other employees rationale"),
+ widget=forms.Textarea(),
+ validators=[
+ MaxLengthValidator(
+ 1000,
+ message="Response must be less than 1000 characters.",
+ )
+ ],
+ error_messages={"required": ("Rationale for no other employees is required.")},
+ )
+
+ def __init__(self, *args, **kwargs):
+ self.form_data_marked_for_deletion = False
+ super().__init__(*args, **kwargs)
+
+ def mark_form_for_deletion(self):
+ """Marks no_other_contacts form for deletion.
+ This changes behavior of validity checks and to_database
+ methods."""
+ self.form_data_marked_for_deletion = True
+
+ def clean(self):
+ """
+ This method overrides the default behavior for forms.
+ This cleans the form after field validation has already taken place.
+ In this override, remove errors associated with the form if form data
+ is marked for deletion.
"""
- # Set form_is_empty to True initially
- form_is_empty = True
- for name, field in self.fields.items():
- # get the value of the field from the widget
- value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
- # if any field in the submitted form is not empty, set form_is_empty to False
- if value is not None and value != "":
- form_is_empty = False
-
- if form_is_empty:
+ if self.form_data_marked_for_deletion:
# clear any errors raised by the form fields
# (before this clean() method is run, each field
# performs its own clean, which could result in
@@ -612,46 +803,22 @@ class OtherContactsForm(RegistrarForm):
return self.cleaned_data
-
-class BaseOtherContactsFormSet(RegistrarFormSet):
- JOIN = "other_contacts"
-
- def should_delete(self, cleaned):
- empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values())
- return all(empty)
-
- def to_database(self, obj: DomainApplication):
- self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
-
- @classmethod
- def from_database(cls, obj):
- return super().from_database(obj, cls.JOIN, cls.on_fetch)
-
-
-OtherContactsFormSet = forms.formset_factory(
- OtherContactsForm,
- extra=1,
- absolute_max=1500, # django default; use `max_num` to limit entries
- formset=BaseOtherContactsFormSet,
-)
-
-
-class NoOtherContactsForm(RegistrarForm):
- no_other_contacts_rationale = forms.CharField(
- required=True,
- # label has to end in a space to get the label_suffix to show
- label=(
- "Please explain why there are no other employees from your organization "
- "we can contact to help us assess your eligibility for a .gov domain."
- ),
- widget=forms.Textarea(),
- validators=[
- MaxLengthValidator(
- 1000,
- message="Response must be less than 1000 characters.",
- )
- ],
- )
+ def to_database(self, obj):
+ """
+ This method overrides the behavior of RegistrarForm.
+ If form data is marked for deletion, set relevant fields
+ to None before saving.
+ Do nothing if form is not valid.
+ """
+ if not self.is_valid():
+ return
+ if self.form_data_marked_for_deletion:
+ for field_name, _ in self.fields.items():
+ setattr(obj, field_name, None)
+ else:
+ for name, value in self.cleaned_data.items():
+ setattr(obj, name, value)
+ obj.save()
class AnythingElseForm(RegistrarForm):
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index 17616df4b..1669774ae 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -210,6 +210,8 @@ class ContactForm(forms.ModelForm):
class AuthorizingOfficialContactForm(ContactForm):
"""Form for updating authorizing official contacts."""
+ JOIN = "authorizing_official"
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -230,6 +232,29 @@ class AuthorizingOfficialContactForm(ContactForm):
self.fields["email"].error_messages = {
"required": "Enter an email address in the required format, like name@example.com."
}
+ self.domainInfo = None
+
+ def set_domain_info(self, domainInfo):
+ """Set the domain information for the form.
+ The form instance is associated with the contact itself. In order to access the associated
+ domain information object, this needs to be set in the form by the view."""
+ self.domainInfo = domainInfo
+
+ def save(self, commit=True):
+ """Override the save() method of the BaseModelForm."""
+
+ # Get the Contact object from the db for the Authorizing Official
+ db_ao = Contact.objects.get(id=self.instance.id)
+ if self.domainInfo and db_ao.has_more_than_one_join("information_authorizing_official"):
+ # Handle the case where the domain information object is available and the AO Contact
+ # has more than one joined object.
+ # In this case, create a new Contact, and update the new Contact with form data.
+ # Then associate with domain information object as the authorizing_official
+ data = dict(self.cleaned_data.items())
+ self.domainInfo.authorizing_official = Contact.objects.create(**data)
+ self.domainInfo.save()
+ else:
+ super().save()
class DomainSecurityEmailForm(forms.Form):
diff --git a/src/registrar/management/commands/disclose_security_emails.py b/src/registrar/management/commands/disclose_security_emails.py
new file mode 100644
index 000000000..62989e4c0
--- /dev/null
+++ b/src/registrar/management/commands/disclose_security_emails.py
@@ -0,0 +1,69 @@
+""""
+Converts all ready and DNS needed domains with a non-default public contact
+to disclose their public contact. Created for Issue#1535 to resolve
+ disclose issue of domains with missing security emails.
+"""
+
+import logging
+import copy
+
+from django.core.management import BaseCommand
+from registrar.models import Domain
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Disclose all nondefault domain security emails."
+
+ def __init__(self):
+ """Sets global variables for code tidiness"""
+ super().__init__()
+ # domains with errors, which are not successfully updated to disclose
+ self.domains_with_errors: list[str] = []
+ # domains that are successfully disclosed
+ self.disclosed_domain_contacts_count = 0
+ # domains that skip disclose due to having contact registrar@dotgov.gov
+ self.skipped_domain_contacts_count = 0
+
+ def handle(self, **options):
+ """
+ Converts all ready and DNS needed domains with a non-default public contact
+ to disclose their public contact.
+ """
+ logger.info("Updating security emails to public")
+
+ # Initializes domains that need to be disclosed
+
+ statuses = [Domain.State.READY, Domain.State.DNS_NEEDED]
+ domains = Domain.objects.filter(state__in=statuses)
+
+ logger.info(f"Found {len(domains)} domains with status Ready or DNS Needed.")
+
+ # Update EPP contact for domains with a security contact
+ for domain in domains:
+ try:
+ contact = domain.security_contact # noqa on these items as we only want to call security_contact
+ logger.info(f"Domain {domain.name} security contact: {domain.security_contact.email}")
+ if domain.security_contact.email != "registrar@dotgov.gov":
+ domain._update_epp_contact(contact=domain.security_contact)
+ self.disclosed_domain_contacts_count += 1
+ else:
+ logger.info(
+ f"Skipping disclose for {domain.name} security contact {domain.security_contact.email}."
+ )
+ self.skipped_domain_contacts_count += 1
+ except Exception as err:
+ # error condition if domain not in database
+ self.domains_with_errors.append(copy.deepcopy(domain.name))
+ logger.error(f"error retrieving domain {domain.name} contact {domain.security_contact}: {err}")
+
+ # Inform user how many contacts were disclosed, skipped, and errored
+ logger.info(f"Updated {self.disclosed_domain_contacts_count} contacts to disclosed.")
+ logger.info(
+ f"Skipped disclosing {self.skipped_domain_contacts_count} contacts with security email "
+ f"registrar@dotgov.gov."
+ )
+ logger.info(
+ f"Error disclosing the following {len(self.domains_with_errors)} contacts: {self.domains_with_errors}"
+ )
diff --git a/src/registrar/management/commands/patch_federal_agency_info.py b/src/registrar/management/commands/patch_federal_agency_info.py
new file mode 100644
index 000000000..35642c1bf
--- /dev/null
+++ b/src/registrar/management/commands/patch_federal_agency_info.py
@@ -0,0 +1,262 @@
+"""Loops through each valid DomainInformation object and updates its agency value"""
+import argparse
+import csv
+import logging
+import os
+from typing import List
+
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
+from registrar.models.domain_information import DomainInformation
+from django.db.models import Q
+
+from registrar.models.transition_domain import TransitionDomain
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Loops through each valid DomainInformation object and updates its agency value"
+
+ def __init__(self):
+ super().__init__()
+ self.di_to_update: List[DomainInformation] = []
+ self.di_failed_to_update: List[DomainInformation] = []
+ self.di_skipped: List[DomainInformation] = []
+
+ def add_arguments(self, parser):
+ """Adds command line arguments"""
+ parser.add_argument(
+ "current_full_filepath",
+ help="TBD",
+ )
+ parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
+ parser.add_argument("--sep", default=",", help="Delimiter character")
+
+ def handle(self, current_full_filepath, **kwargs):
+ """Loops through each valid DomainInformation object and updates its agency value"""
+ debug = kwargs.get("debug")
+ separator = kwargs.get("sep")
+
+ # Check if the provided file path is valid
+ if not os.path.isfile(current_full_filepath):
+ raise argparse.ArgumentTypeError(f"Invalid file path '{current_full_filepath}'")
+
+ # === Update the "federal_agency" field === #
+ was_success = self.patch_agency_info(debug)
+
+ # === Try to process anything that was skipped === #
+ # We should only correct skipped records if the previous step was successful.
+ # If something goes wrong, then we risk corrupting data, so skip this step.
+ if len(self.di_skipped) > 0 and was_success:
+ # Flush out the list of DomainInformations to update
+ self.di_to_update.clear()
+ self.process_skipped_records(current_full_filepath, separator, debug)
+
+ # Clear the old skipped list, and log the run summary
+ self.di_skipped.clear()
+ self.log_script_run_summary(debug)
+ elif not was_success:
+ # This code should never execute. This can only occur if bulk_update somehow fails,
+ # which may indicate some sort of data corruption.
+ logger.error(
+ f"{TerminalColors.FAIL}"
+ "Could not automatically patch skipped records. The initial update failed."
+ "An error was encountered when running this script, please inspect the following "
+ f"records for accuracy and completeness: {self.di_failed_to_update}"
+ f"{TerminalColors.ENDC}"
+ )
+
+ def patch_agency_info(self, debug):
+ """
+ Updates the federal_agency field of each valid DomainInformation object based on the corresponding
+ TransitionDomain object. Skips the update if the TransitionDomain object does not exist or its
+ federal_agency field is None. Logs the update, skip, and failure actions if debug mode is on.
+ After all updates, logs a summary of the results.
+ """
+
+ # Grab all DomainInformation objects (and their associated TransitionDomains)
+ # that need to be updated
+ empty_agency_query = Q(federal_agency=None) | Q(federal_agency="")
+ domain_info_to_fix = DomainInformation.objects.filter(empty_agency_query)
+
+ domain_names = domain_info_to_fix.values_list("domain__name", flat=True)
+ transition_domains = TransitionDomain.objects.filter(domain_name__in=domain_names).exclude(empty_agency_query)
+
+ # Get the domain names from TransitionDomain
+ td_agencies = transition_domains.values_list("domain_name", "federal_agency").distinct()
+
+ human_readable_domain_names = list(domain_names)
+ # Code execution will stop here if the user prompts "N"
+ TerminalHelper.prompt_for_execution(
+ system_exit_on_terminate=True,
+ info_to_inspect=f"""
+ ==Proposed Changes==
+ Number of DomainInformation objects to change: {len(human_readable_domain_names)}
+ The following DomainInformation objects will be modified: {human_readable_domain_names}
+ """,
+ prompt_title="Do you wish to patch federal_agency data?",
+ )
+ logger.info("Updating...")
+
+ # Create a dictionary mapping of domain_name to federal_agency
+ td_dict = dict(td_agencies)
+
+ for di in domain_info_to_fix:
+ domain_name = di.domain.name
+ federal_agency = td_dict.get(domain_name)
+ log_message = None
+
+ # If agency exists on a TransitionDomain, update the related DomainInformation object
+ if domain_name in td_dict:
+ di.federal_agency = federal_agency
+ self.di_to_update.append(di)
+ log_message = f"{TerminalColors.OKCYAN}Updated {di}{TerminalColors.ENDC}"
+ else:
+ self.di_skipped.append(di)
+ log_message = f"{TerminalColors.YELLOW}Skipping update for {di}{TerminalColors.ENDC}"
+
+ # Log the action if debug mode is on
+ if debug and log_message is not None:
+ logger.info(log_message)
+
+ # Bulk update the federal agency field in DomainInformation objects
+ DomainInformation.objects.bulk_update(self.di_to_update, ["federal_agency"])
+
+ # Get a list of each domain we changed
+ corrected_domains = DomainInformation.objects.filter(domain__name__in=domain_names)
+
+ # After the update has happened, do a sweep of what we get back.
+ # If the fields we expect to update are still None, then something is wrong.
+ for di in corrected_domains:
+ if di not in self.di_skipped and di.federal_agency is None:
+ logger.info(f"{TerminalColors.FAIL}Failed to update {di}{TerminalColors.ENDC}")
+ self.di_failed_to_update.append(di)
+
+ # === Log results and return data === #
+ self.log_script_run_summary(debug)
+ # Tracks if this script was successful. If any errors are found, something went very wrong.
+ was_success = len(self.di_failed_to_update) == 0
+ return was_success
+
+ def process_skipped_records(self, file_path, separator, debug):
+ """If we encounter any DomainInformation records that do not have data in the associated
+ TransitionDomain record, then check the associated current-full.csv file for this
+ information."""
+
+ # Code execution will stop here if the user prompts "N"
+ TerminalHelper.prompt_for_execution(
+ system_exit_on_terminate=True,
+ info_to_inspect=f"""
+ ==File location==
+ current-full.csv filepath: {file_path}
+
+ ==Proposed Changes==
+ Number of DomainInformation objects to change: {len(self.di_skipped)}
+ The following DomainInformation objects will be modified if agency data exists in file: {self.di_skipped}
+ """,
+ prompt_title="Do you wish to patch skipped records?",
+ )
+ logger.info("Updating...")
+
+ file_data = self.read_current_full(file_path, separator)
+ for di in self.di_skipped:
+ domain_name = di.domain.name
+ row = file_data.get(domain_name)
+ fed_agency = None
+ if row is not None and "agency" in row:
+ fed_agency = row.get("agency")
+
+ # Determine if we should update this record or not.
+ # If we don't get any data back, something went wrong.
+ if fed_agency is not None:
+ di.federal_agency = fed_agency
+ self.di_to_update.append(di)
+ if debug:
+ logger.info(f"{TerminalColors.OKCYAN}" f"Updating {di}" f"{TerminalColors.ENDC}")
+ else:
+ self.di_failed_to_update.append(di)
+ logger.error(
+ f"{TerminalColors.FAIL}" f"Could not update {di}. No information found." f"{TerminalColors.ENDC}"
+ )
+
+ # Bulk update the federal agency field in DomainInformation objects
+ DomainInformation.objects.bulk_update(self.di_to_update, ["federal_agency"])
+
+ def read_current_full(self, file_path, separator):
+ """Reads the current-full.csv file and stores it in a dictionary"""
+ with open(file_path, "r") as requested_file:
+ old_reader = csv.DictReader(requested_file, delimiter=separator)
+ # Some variants of current-full.csv have key casing differences for fields
+ # such as "Domain name" or "Domain Name". This corrects that.
+ reader = self.lowercase_fieldnames(old_reader)
+ # Return a dictionary with the domain name as the key,
+ # and the row information as the value
+ dict_data = {}
+ for row in reader:
+ domain_name = row.get("domain name")
+ if domain_name is not None:
+ domain_name = domain_name.lower()
+ dict_data[domain_name] = row
+
+ return dict_data
+
+ def lowercase_fieldnames(self, reader):
+ """Lowercases all field keys in a dictreader to account for potential casing differences"""
+ for row in reader:
+ yield {k.lower(): v for k, v in row.items()}
+
+ def log_script_run_summary(self, debug):
+ """Prints success, failed, and skipped counts, as well as
+ all affected objects."""
+ update_success_count = len(self.di_to_update)
+ update_failed_count = len(self.di_failed_to_update)
+ update_skipped_count = len(self.di_skipped)
+
+ # Prepare debug messages
+ debug_messages = {
+ "success": (f"{TerminalColors.OKCYAN}Updated: {self.di_to_update}{TerminalColors.ENDC}\n"),
+ "skipped": (f"{TerminalColors.YELLOW}Skipped: {self.di_skipped}{TerminalColors.ENDC}\n"),
+ "failed": (f"{TerminalColors.FAIL}Failed: {self.di_failed_to_update}{TerminalColors.ENDC}\n"),
+ }
+
+ # Print out a list of everything that was changed, if we have any changes to log.
+ # Otherwise, don't print anything.
+ TerminalHelper.print_conditional(
+ debug,
+ f"{debug_messages.get('success') if update_success_count > 0 else ''}"
+ f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
+ f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
+ )
+
+ if update_failed_count == 0 and update_skipped_count == 0:
+ logger.info(
+ f"""{TerminalColors.OKGREEN}
+ ============= FINISHED ===============
+ Updated {update_success_count} DomainInformation entries
+ {TerminalColors.ENDC}
+ """
+ )
+ elif update_failed_count == 0:
+ logger.warning(
+ f"""{TerminalColors.YELLOW}
+ ============= FINISHED ===============
+ Updated {update_success_count} DomainInformation entries
+
+ ----- SOME AGENCY DATA WAS NONE (WILL BE PATCHED AUTOMATICALLY) -----
+ Skipped updating {update_skipped_count} DomainInformation entries
+ {TerminalColors.ENDC}
+ """
+ )
+ else:
+ logger.error(
+ f"""{TerminalColors.FAIL}
+ ============= FINISHED ===============
+ Updated {update_success_count} DomainInformation entries
+
+ ----- UPDATE FAILED -----
+ Failed to update {update_failed_count} DomainInformation entries,
+ Skipped updating {update_skipped_count} DomainInformation entries
+ {TerminalColors.ENDC}
+ """
+ )
diff --git a/src/registrar/management/commands/populate_first_ready.py b/src/registrar/management/commands/populate_first_ready.py
new file mode 100644
index 000000000..9636476c2
--- /dev/null
+++ b/src/registrar/management/commands/populate_first_ready.py
@@ -0,0 +1,68 @@
+import argparse
+import logging
+from typing import List
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper, ScriptDataHelper
+from registrar.models import Domain
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Loops through each valid Domain object and updates its first_created value"
+
+ def __init__(self):
+ super().__init__()
+ self.to_update: List[Domain] = []
+ self.failed_to_update: List[Domain] = []
+ self.skipped: List[Domain] = []
+
+ def add_arguments(self, parser):
+ """Adds command line arguments"""
+ parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
+
+ def handle(self, **kwargs):
+ """Loops through each valid Domain object and updates its first_created value"""
+ debug = kwargs.get("debug")
+ # Get all valid domains
+ valid_states = [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]
+ domains = Domain.objects.filter(first_ready=None, state__in=valid_states)
+
+ # Code execution will stop here if the user prompts "N"
+ TerminalHelper.prompt_for_execution(
+ system_exit_on_terminate=True,
+ info_to_inspect=f"""
+ ==Proposed Changes==
+ Number of Domain objects to change: {len(domains)}
+ """,
+ prompt_title="Do you wish to patch first_ready data?",
+ )
+ logger.info("Updating...")
+
+ for domain in domains:
+ try:
+ self.update_first_ready_for_domain(domain, debug)
+ except Exception as err:
+ self.failed_to_update.append(domain)
+ logger.error(err)
+ logger.error(f"{TerminalColors.FAIL}" f"Failed to update {domain}" f"{TerminalColors.ENDC}")
+
+ # Do a bulk update on the first_ready field
+ ScriptDataHelper.bulk_update_fields(Domain, self.to_update, ["first_ready"])
+
+ # Log what happened
+ TerminalHelper.log_script_run_summary(self.to_update, self.failed_to_update, self.skipped, debug)
+
+ def update_first_ready_for_domain(self, domain: Domain, debug: bool):
+ """Grabs the created_at field and associates it with the first_ready column.
+ Appends the result to the to_update list."""
+ created_at = domain.created_at
+ if created_at is not None:
+ domain.first_ready = domain.created_at
+ self.to_update.append(domain)
+ if debug:
+ logger.info(f"Updating {domain}")
+ else:
+ self.skipped.append(domain)
+ if debug:
+ logger.warning(f"Skipped updating {domain}")
diff --git a/src/registrar/management/commands/utility/extra_transition_domain_helper.py b/src/registrar/management/commands/utility/extra_transition_domain_helper.py
index 54f68d5c8..755c9b98a 100644
--- a/src/registrar/management/commands/utility/extra_transition_domain_helper.py
+++ b/src/registrar/management/commands/utility/extra_transition_domain_helper.py
@@ -11,6 +11,7 @@ import os
import sys
from typing import Dict, List
from django.core.paginator import Paginator
+from registrar.utility.enums import LogCode
from registrar.models.transition_domain import TransitionDomain
from registrar.management.commands.utility.load_organization_error import (
LoadOrganizationError,
@@ -28,7 +29,8 @@ from .epp_data_containers import (
)
from .transition_domain_arguments import TransitionDomainArguments
-from .terminal_helper import TerminalColors, TerminalHelper, LogCode
+from .terminal_helper import TerminalColors, TerminalHelper
+
logger = logging.getLogger(__name__)
diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py
index 85bfc8193..49ab89b9a 100644
--- a/src/registrar/management/commands/utility/terminal_helper.py
+++ b/src/registrar/management/commands/utility/terminal_helper.py
@@ -1,29 +1,12 @@
-from enum import Enum
import logging
import sys
+from django.core.paginator import Paginator
from typing import List
+from registrar.utility.enums import LogCode
logger = logging.getLogger(__name__)
-class LogCode(Enum):
- """Stores the desired log severity
-
- Overview of error codes:
- - 1 ERROR
- - 2 WARNING
- - 3 INFO
- - 4 DEBUG
- - 5 DEFAULT
- """
-
- ERROR = 1
- WARNING = 2
- INFO = 3
- DEBUG = 4
- DEFAULT = 5
-
-
class TerminalColors:
"""Colors for terminal outputs
(makes reading the logs WAY easier)"""
@@ -41,7 +24,94 @@ class TerminalColors:
BackgroundLightYellow = "\033[103m"
+class ScriptDataHelper:
+ """Helper method with utilities to speed up development of scripts that do DB operations"""
+
+ @staticmethod
+ def bulk_update_fields(model_class, update_list, fields_to_update, batch_size=1000):
+ """
+ This function performs a bulk update operation on a specified Django model class in batches.
+ It uses Django's Paginator to handle large datasets in a memory-efficient manner.
+
+ Parameters:
+ model_class: The Django model class that you want to perform the bulk update on.
+ This should be the actual class, not a string of the class name.
+
+ update_list: A list of model instances that you want to update. Each instance in the list
+ should already have the updated values set on the instance.
+
+ batch_size: The maximum number of model instances to update in a single database query.
+ Defaults to 1000. If you're dealing with models that have a large number of fields,
+ or large field values, you may need to decrease this value to prevent out-of-memory errors.
+
+ fields_to_update: Specifies which fields to update.
+
+ Usage:
+ bulk_update_fields(Domain, page.object_list, ["first_ready"])
+ """
+ # Create a Paginator object. Bulk_update on the full dataset
+ # is too memory intensive for our current app config, so we can chunk this data instead.
+ paginator = Paginator(update_list, batch_size)
+ for page_num in paginator.page_range:
+ page = paginator.page(page_num)
+ model_class.objects.bulk_update(page.object_list, fields_to_update)
+
+
class TerminalHelper:
+ @staticmethod
+ def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool):
+ """Prints success, failed, and skipped counts, as well as
+ all affected objects."""
+ update_success_count = len(to_update)
+ update_failed_count = len(failed_to_update)
+ update_skipped_count = len(skipped)
+
+ # Prepare debug messages
+ debug_messages = {
+ "success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"),
+ "skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped}{TerminalColors.ENDC}\n"),
+ "failed": (f"{TerminalColors.FAIL}Failed: {failed_to_update}{TerminalColors.ENDC}\n"),
+ }
+
+ # Print out a list of everything that was changed, if we have any changes to log.
+ # Otherwise, don't print anything.
+ TerminalHelper.print_conditional(
+ debug,
+ f"{debug_messages.get('success') if update_success_count > 0 else ''}"
+ f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
+ f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
+ )
+
+ if update_failed_count == 0 and update_skipped_count == 0:
+ logger.info(
+ f"""{TerminalColors.OKGREEN}
+ ============= FINISHED ===============
+ Updated {update_success_count} entries
+ {TerminalColors.ENDC}
+ """
+ )
+ elif update_failed_count == 0:
+ logger.warning(
+ f"""{TerminalColors.YELLOW}
+ ============= FINISHED ===============
+ Updated {update_success_count} entries
+ ----- SOME DATA WAS INVALID (NEEDS MANUAL PATCHING) -----
+ Skipped updating {update_skipped_count} entries
+ {TerminalColors.ENDC}
+ """
+ )
+ else:
+ logger.error(
+ f"""{TerminalColors.FAIL}
+ ============= FINISHED ===============
+ Updated {update_success_count} entries
+ ----- UPDATE FAILED -----
+ Failed to update {update_failed_count} entries,
+ Skipped updating {update_skipped_count} entries
+ {TerminalColors.ENDC}
+ """
+ )
+
@staticmethod
def query_yes_no(question: str, default="yes"):
"""Ask a yes/no question via raw_input() and return their answer.
diff --git a/src/registrar/migrations/0062_alter_host_name.py b/src/registrar/migrations/0062_alter_host_name.py
new file mode 100644
index 000000000..9bdb72209
--- /dev/null
+++ b/src/registrar/migrations/0062_alter_host_name.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2024-01-09 02:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0061_domain_security_contact_registry_id"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="host",
+ name="name",
+ field=models.CharField(default=None, help_text="Fully qualified domain name", max_length=253),
+ ),
+ ]
diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py
index 06cf83887..ff7389780 100644
--- a/src/registrar/models/contact.py
+++ b/src/registrar/models/contact.py
@@ -54,6 +54,47 @@ class Contact(TimeStampedModel):
db_index=True,
)
+ def _get_all_relations(self):
+ """Returns an array of all fields which are relations"""
+ return [f.name for f in self._meta.get_fields() if f.is_relation]
+
+ def has_more_than_one_join(self, expected_relation):
+ """Helper for finding whether an object is joined more than once.
+ expected_relation is the one relation with one expected join"""
+ # all_relations is the list of all_relations (from contact) to be checked for existing joins
+ all_relations = self._get_all_relations()
+ return any(self._has_more_than_one_join_per_relation(rel, expected_relation) for rel in all_relations)
+
+ def _has_more_than_one_join_per_relation(self, relation, expected_relation):
+ """Helper for finding whether an object is joined more than once."""
+ # threshold is the number of related objects that are acceptable
+ # when determining if related objects exist. threshold is 0 for most
+ # relationships. if the relationship is expected_relation, we know that
+ # there is already exactly 1 acceptable relationship (the one we are
+ # attempting to delete), so the threshold is 1
+ threshold = 1 if relation == expected_relation else 0
+
+ # Raise a KeyError if rel is not a defined field on the db_obj model
+ # This will help catch any errors in relation passed.
+ if relation not in [field.name for field in self._meta.get_fields()]:
+ raise KeyError(f"{relation} is not a defined field on the {self._meta.model_name} model.")
+
+ # if attr rel in db_obj is not None, then test if reference object(s) exist
+ if getattr(self, relation) is not None:
+ field = self._meta.get_field(relation)
+ if isinstance(field, models.OneToOneField):
+ # if the rel field is a OneToOne field, then we have already
+ # determined that the object exists (is not None)
+ # so return True unless the relation being tested is the expected_relation
+ is_not_expected_relation = relation != expected_relation
+ return is_not_expected_relation
+ elif isinstance(field, models.ForeignObjectRel):
+ # if the rel field is a ManyToOne or ManyToMany, then we need
+ # to determine if the count of related objects is greater than
+ # the threshold
+ return getattr(self, relation).count() > threshold
+ return False
+
def get_formatted_name(self):
"""Returns the contact's name in Western order."""
names = [n for n in [self.first_name, self.middle_name, self.last_name] if n]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 001937b89..1a581a4ec 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1396,11 +1396,13 @@ class Domain(TimeStampedModel, DomainHelper):
def _disclose_fields(self, contact: PublicContact):
"""creates a disclose object that can be added to a contact Create using
.disclose= on the command before sending.
- if item is security email then make sure email is visable"""
+ if item is security email then make sure email is visible"""
is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY
DF = epp.DiscloseField
fields = {DF.EMAIL}
disclose = is_security and contact.email != PublicContact.get_default_security().email
+ # Delete after testing on other devices
+ logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose)
# Will only disclose DF.EMAIL if its not the default
return epp.Disclose(
flag=disclose,
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index e181849ac..196449bfa 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -653,13 +653,11 @@ class DomainApplication(TimeStampedModel):
def in_review(self):
"""Investigate an application that has been submitted.
- As a side effect, an email notification is sent."""
-
- self._send_status_update_email(
- "application in review",
- "emails/status_change_in_review.txt",
- "emails/status_change_in_review_subject.txt",
- )
+ This action is logged."""
+ literal = DomainApplication.ApplicationStatus.IN_REVIEW
+ # Check if the tuple exists, then grab its value
+ in_review = literal if literal is not None else "In Review"
+ logger.info(f"A status change occurred. {self} was changed to '{in_review}'")
@transition(
field="status",
@@ -674,13 +672,11 @@ class DomainApplication(TimeStampedModel):
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",
- )
+ This action is logged."""
+ literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
+ # Check if the tuple is setup correctly, then grab its value
+ action_needed = literal if literal is not None else "Action Needed"
+ logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
@transition(
field="status",
@@ -840,9 +836,13 @@ class DomainApplication(TimeStampedModel):
DomainApplication.OrganizationChoices.INTERSTATE,
]
- def show_no_other_contacts_rationale(self) -> bool:
- """Show this step if the other contacts are blank."""
- return not self.other_contacts.exists()
+ def has_rationale(self) -> bool:
+ """Does this application have no_other_contacts_rationale?"""
+ return bool(self.no_other_contacts_rationale)
+
+ def has_other_contacts(self) -> bool:
+ """Does this application have other contacts listed?"""
+ return self.other_contacts.exists()
def is_federal(self) -> Union[bool, None]:
"""Is this application for a federal agency?
diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py
index 2d756111e..3b966832f 100644
--- a/src/registrar/models/host.py
+++ b/src/registrar/models/host.py
@@ -20,7 +20,7 @@ class Host(TimeStampedModel):
null=False,
blank=False,
default=None, # prevent saving without a value
- unique=True,
+ unique=False,
help_text="Fully qualified domain name",
)
@@ -30,3 +30,6 @@ class Host(TimeStampedModel):
related_name="host", # access this Host via the Domain as `domain.host`
help_text="Domain to which this host belongs",
)
+
+ def __str__(self):
+ return f"{self.domain.name} {self.name}"
diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py
index e43661b1d..a808ef803 100644
--- a/src/registrar/models/utility/domain_helper.py
+++ b/src/registrar/models/utility/domain_helper.py
@@ -1,8 +1,12 @@
import re
-from api.views import check_domain_available
+from django import forms
+from django.http import JsonResponse
+
+from api.views import DOMAIN_API_MESSAGES, check_domain_available
from registrar.utility import errors
from epplibwrapper.errors import RegistryError
+from registrar.utility.enums import ValidationReturnType
class DomainHelper:
@@ -23,21 +27,12 @@ class DomainHelper:
return bool(cls.DOMAIN_REGEX.match(domain))
@classmethod
- def validate(cls, domain: str | None, blank_ok=False) -> str:
+ def validate(cls, domain: str, blank_ok=False) -> str:
"""Attempt to determine if a domain name could be requested."""
- if domain is None:
- raise errors.BlankValueError()
- if not isinstance(domain, str):
- raise ValueError("Domain name must be a string")
- domain = domain.lower().strip()
- if domain == "" and not blank_ok:
- raise errors.BlankValueError()
- if domain.endswith(".gov"):
- domain = domain[:-4]
- if "." in domain:
- raise errors.ExtraDotsError()
- if not DomainHelper.string_could_be_domain(domain + ".gov"):
- raise ValueError()
+
+ # Split into pieces for the linter
+ domain = cls._validate_domain_string(domain, blank_ok)
+
try:
if not check_domain_available(domain):
raise errors.DomainUnavailableError()
@@ -45,6 +40,110 @@ class DomainHelper:
raise errors.RegistrySystemError() from err
return domain
+ @staticmethod
+ def _validate_domain_string(domain, blank_ok):
+ """Normalize the domain string, and check its content"""
+ if domain is None:
+ raise errors.BlankValueError()
+
+ if not isinstance(domain, str):
+ raise errors.InvalidDomainError()
+
+ domain = domain.lower().strip()
+
+ if domain == "" and not blank_ok:
+ raise errors.BlankValueError()
+ elif domain == "":
+ # If blank ok is true, just return the domain
+ return domain
+
+ if domain.endswith(".gov"):
+ domain = domain[:-4]
+
+ if "." in domain:
+ raise errors.ExtraDotsError()
+
+ if not DomainHelper.string_could_be_domain(domain + ".gov"):
+ raise errors.InvalidDomainError()
+
+ return domain
+
+ @classmethod
+ def validate_and_handle_errors(cls, domain, return_type, blank_ok=False):
+ """
+ Validates a domain and returns an appropriate response based on the validation result.
+
+ This method uses the `validate` method to validate the domain. If validation fails, it catches the exception,
+ maps it to a corresponding error code, and returns a response based on the `return_type` parameter.
+
+ Args:
+ domain (str): The domain to validate.
+ return_type (ValidationReturnType): Determines the type of response (JSON or form validation error).
+ blank_ok (bool, optional): If True, blank input does not raise an exception. Defaults to False.
+
+ Returns:
+ tuple: The validated domain (or None if validation failed), and the response (success or error).
+ """ # noqa
+
+ # Map each exception to a corresponding error code
+ error_map = {
+ errors.BlankValueError: "required",
+ errors.ExtraDotsError: "extra_dots",
+ errors.DomainUnavailableError: "unavailable",
+ errors.RegistrySystemError: "error",
+ errors.InvalidDomainError: "invalid",
+ }
+
+ validated = None
+ response = None
+
+ try:
+ # Attempt to validate the domain
+ validated = cls.validate(domain, blank_ok)
+
+ # Get a list of each possible exception, and the code to return
+ except tuple(error_map.keys()) as error:
+ # If an error is caught, get its type
+ error_type = type(error)
+
+ # Generate the response based on the error code and return type
+ response = DomainHelper._return_form_error_or_json_response(return_type, code=error_map.get(error_type))
+ else:
+ # For form validation, we do not need to display the success message
+ if return_type != ValidationReturnType.FORM_VALIDATION_ERROR:
+ response = DomainHelper._return_form_error_or_json_response(return_type, code="success", available=True)
+
+ # Return the validated domain and the response (either error or success)
+ return (validated, response)
+
+ @staticmethod
+ def _return_form_error_or_json_response(return_type: ValidationReturnType, code, available=False):
+ """
+ Returns an error response based on the `return_type`.
+
+ If `return_type` is `FORM_VALIDATION_ERROR`, raises a form validation error.
+ If `return_type` is `JSON_RESPONSE`, returns a JSON response with 'available', 'code', and 'message' fields.
+ If `return_type` is neither, raises a ValueError.
+
+ Args:
+ return_type (ValidationReturnType): The type of error response.
+ code (str): The error code for the error message.
+ available (bool, optional): Availability, only used for JSON responses. Defaults to False.
+
+ Returns:
+ A JSON response or a form validation error.
+
+ Raises:
+ ValueError: If `return_type` is neither `FORM_VALIDATION_ERROR` nor `JSON_RESPONSE`.
+ """ # noqa
+ match return_type:
+ case ValidationReturnType.FORM_VALIDATION_ERROR:
+ raise forms.ValidationError(DOMAIN_API_MESSAGES[code], code=code)
+ case ValidationReturnType.JSON_RESPONSE:
+ return JsonResponse({"available": available, "code": code, "message": DOMAIN_API_MESSAGES[code]})
+ case _:
+ raise ValueError("Invalid return type specified")
+
@classmethod
def sld(cls, domain: str):
"""
diff --git a/src/registrar/templates/application_about_your_organization.html b/src/registrar/templates/application_about_your_organization.html
index 0d384b4f5..02e2e2c4f 100644
--- a/src/registrar/templates/application_about_your_organization.html
+++ b/src/registrar/templates/application_about_your_organization.html
@@ -2,14 +2,16 @@
{% load field_helpers %}
{% block form_instructions %}
-
We’d like to know more about your organization. Include the following in your response:
+
To help us determine your eligibility for a .gov domain, we need to know more about your organization. For example:
The type of work your organization does
-
How your organization is a government organization that is independent of a state government
-
Include links to authorizing legislation, applicable bylaws or charter, or other documentation to support your claims.
+
How your organization operates independently from a state government
+
A description of the specialized, essential services you offer (if applicable)
+
Links to authorizing legislation, applicable bylaws or charter, or other documentation to support your claims
+
What can you tell us about your organization?
{% endblock %}
{% block form_required_fields_help_text %}
@@ -20,4 +22,4 @@
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.about_your_organization %}
{% endwith %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/src/registrar/templates/application_anything_else.html b/src/registrar/templates/application_anything_else.html
index f69b7e70e..c1ecf94a9 100644
--- a/src/registrar/templates/application_anything_else.html
+++ b/src/registrar/templates/application_anything_else.html
@@ -2,7 +2,9 @@
{% load field_helpers %}
{% block form_instructions %}
-
Is there anything else you'd like us to know about your domain request? This question is optional.
+
Is there anything else you’d like us to know about your domain request?
We typically don’t reach out to the authorizing official, but if contact is necessary, our practice is to coordinate first with you, the requestor. Read more about who can serve as an authorizing official.
+
We typically don’t reach out to the authorizing official, but if contact is necessary, our practice is to coordinate with you, the requestor, first.
Enter your organization’s current public website, if you have one. For example,
- www.city.com. We can better evaluate your domain request if we know about domains
-you’re already using. If you already have any .gov domains please include them. This question is optional.
+
We can better evaluate your request if we know about domains you’re already using.
+
What are the current websites for your organization?
+
Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.
Before requesting a .gov domain, please make sure it meets our naming requirements. Your domain name must:
Be available
-
Be unique
Relate to your organization’s name, location, and/or services
Be clear to the general public. Your domain name must not be easily confused
with other organizations.
+
Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. In most instances, this requires including your state’s two-letter abbreviation.
+
+
Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.
+
Note that only federal agencies can request generic terms like
vote.gov.
-
We’ll try to give you the domain you want. We first need to make sure your request
- meets our requirements. We’ll work with you to find the best domain for your
- organization.
-
Domain examples for your type of organization
{% include "includes/domain_example.html" %}
@@ -41,10 +39,7 @@
What .gov domain do you want?
-
After you enter your domain, we’ll make sure it’s
- available and that it meets some of our naming requirements. If your domain passes
- these initial checks, we’ll verify that it meets all of our requirements once you
- complete and submit the rest of this form.
+
After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. If your domain passes these initial checks, we’ll verify that it meets all our requirements after you complete the rest of this form.