diff --git a/.github/workflows/daily-csv-upload.yaml b/.github/workflows/daily-csv-upload.yaml
index 724a19457..9cacfc3bf 100644
--- a/.github/workflows/daily-csv-upload.yaml
+++ b/.github/workflows/daily-csv-upload.yaml
@@ -31,3 +31,12 @@ jobs:
cf_space: ${{ secrets.CF_REPORT_ENV }}
cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py generate_current_full_report' --name full"
+ - name: Generate and email domain-metadata.csv
+ uses: cloud-gov/cg-cli-tools@main
+ with:
+ cf_username: ${{ secrets[env.CF_USERNAME] }}
+ cf_password: ${{ secrets[env.CF_PASSWORD] }}
+ cf_org: cisa-dotgov
+ cf_space: ${{ secrets.CF_REPORT_ENV }}
+ cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py email_current_metadata_report' --name metadata"
+
diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml
index e884c60a0..f7f4a0d65 100644
--- a/.github/workflows/deploy-sandbox.yaml
+++ b/.github/workflows/deploy-sandbox.yaml
@@ -22,6 +22,8 @@ jobs:
|| startsWith(github.head_ref, 'es/')
|| startsWith(github.head_ref, 'ky/')
|| startsWith(github.head_ref, 'backup/')
+ || startsWith(github.head_ref, 'meoward/')
+ || startsWith(github.head_ref, 'bob/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"
diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml
index 2033ee51c..825ab04d7 100644
--- a/.github/workflows/migrate.yaml
+++ b/.github/workflows/migrate.yaml
@@ -16,6 +16,8 @@ on:
- stable
- staging
- development
+ - bob
+ - meoward
- backup
- ky
- es
diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml
index f8730c865..05eb963c3 100644
--- a/.github/workflows/reset-db.yaml
+++ b/.github/workflows/reset-db.yaml
@@ -16,6 +16,8 @@ on:
options:
- staging
- development
+ - bob
+ - meoward
- backup
- ky
- es
diff --git a/.github/workflows/test-deploy.yaml b/.github/workflows/test-deploy.yaml
deleted file mode 100644
index af429738f..000000000
--- a/.github/workflows/test-deploy.yaml
+++ /dev/null
@@ -1,41 +0,0 @@
-# This workflow is to for testing a change to our deploy structure and will be deleted when testing finishes
-
-name: Deploy Main
-run-name: Run deploy for ${{ github.event.inputs.environment }}
-
-on:
- workflow_dispatch:
- inputs:
- environment:
- type: choice
- description: Which environment should we run deploy for?
- options:
- - development
- - backup
- - ky
- - es
- - nl
- - rh
- - za
- - gd
- - rb
- - ko
- - ab
- - rjm
- - dk
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- env:
- CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
- CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
- steps:
- - name: Deploy to cloud.gov sandbox
- uses: cloud-gov/cg-cli-tools@main
- with:
- cf_username: ${{ secrets[env.CF_USERNAME] }}
- cf_password: ${{ secrets[env.CF_PASSWORD] }}
- cf_org: cisa-dotgov
- cf_space: ${{ github.event.inputs.environment }}
- cf_command: "push -f ops/manifests/manifest-${{ github.event.inputs.environment }}.yaml --strategy rolling"
\ No newline at end of file
diff --git a/docs/developer/README.md b/docs/developer/README.md
index e28c57378..9f2e131b6 100644
--- a/docs/developer/README.md
+++ b/docs/developer/README.md
@@ -330,11 +330,12 @@ To associate a S3 instance to your sandbox, follow these steps:
3. Click `Services` on the application nav bar
4. Add a new service (plus symbol)
5. Click `Marketplace Service`
-6. On the `Select the service` dropdown, select `s3`
-7. Under the dropdown on `Select Plan`, select `basic-sandbox`
-8. Under `Service Instance` enter `getgov-s3` for the name
+6. For Space, put in your sandbox initials
+7. On the `Select the service` dropdown, select `s3`
+8. Under the dropdown on `Select Plan`, select `basic-sandbox`
+9. Under `Service Instance` enter `getgov-s3` for the name and leave the other fields empty
-See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
+See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
### Testing your S3 instance locally
To test the S3 bucket associated with your sandbox, you will need to add four additional variables to your `.env` file. These are as follows:
diff --git a/docs/operations/runbooks/rotate_application_secrets.md b/docs/operations/runbooks/rotate_application_secrets.md
index a776e60b8..1094b4ff7 100644
--- a/docs/operations/runbooks/rotate_application_secrets.md
+++ b/docs/operations/runbooks/rotate_application_secrets.md
@@ -117,3 +117,11 @@ You'll need to give the new certificate to the registry vendor _before_ rotating
## REGISTRY_HOSTNAME
This is the hostname at which the registry can be found.
+
+## SECRET_METADATA_KEY
+
+This is the passphrase for the zipped and encrypted metadata email that is sent out daily. Reach out to product team members or leads with access to security passwords if the passcode is needed.
+
+To change the password, use a password generator to generate a password, then update the user credentials per the above instructions. Be sure to update the [KBDX](https://docs.google.com/document/d/1_BbJmjYZNYLNh4jJPPnUEG9tFCzJrOc0nMrZrnSKKyw) file in Google Drive with this password change.
+
+
diff --git a/docs/operations/runbooks/update_python_dependencies.md b/docs/operations/runbooks/update_python_dependencies.md
index b94c0f39f..468270d09 100644
--- a/docs/operations/runbooks/update_python_dependencies.md
+++ b/docs/operations/runbooks/update_python_dependencies.md
@@ -2,8 +2,8 @@
========================
1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers
-
-2. Run
+2. Run `docker-compose stop` to spin down the current containers and images so we can start afresh
+3. Run
cd src
docker-compose run app bash -c "pipenv lock && pipenv requirements > requirements.txt"
@@ -13,9 +13,9 @@
It is necessary to use `bash -c` because `run pipenv requirements` will not recognize that it is running non-interactively and will include garbage formatting characters.
The requirements.txt is used by Cloud.gov. It is needed to work around a bug in the CloudFoundry buildpack version of Pipenv that breaks on installing from a git repository.
-3. Change geventconnpool back to what it was originally within the Pipfile.lock and requirements.txt.
+4. Change geventconnpool back to what it was originally within the Pipfile.lock and requirements.txt.
This is done by either saving what it was originally or opening a PR and using that as a reference to undo changes to any mention of geventconnpool.
Geventconnpool, when set as a requirement without the reference portion, is defaulting to get a commit from 2014 which then breaks the code, as we want the newest version from them.
-4. (optional) Run `docker-compose stop` and `docker-compose build` to build a new image for local development with the updated dependencies.
+5. Run `docker-compose build` to build a new image for local development with the updated dependencies.
The reason for de-coupling the `build` and `lock` steps is to increase consistency between builds--a run of `build` will always get exactly the dependencies listed in `Pipfile.lock`, nothing more, nothing less.
\ No newline at end of file
diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml
index a78de338e..3ca800392 100644
--- a/ops/manifests/manifest-ab.yaml
+++ b/ops/manifests/manifest-ab.yaml
@@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
- instances: 2
+ instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
diff --git a/ops/manifests/manifest-bob.yaml b/ops/manifests/manifest-bob.yaml
new file mode 100644
index 000000000..f39d9e145
--- /dev/null
+++ b/ops/manifests/manifest-bob.yaml
@@ -0,0 +1,32 @@
+---
+applications:
+- name: getgov-bob
+ buildpacks:
+ - python_buildpack
+ path: ../../src
+ instances: 1
+ memory: 512M
+ stack: cflinuxfs4
+ timeout: 180
+ command: ./run.sh
+ health-check-type: http
+ health-check-http-endpoint: /health
+ health-check-invocation-timeout: 40
+ env:
+ # Send stdout and stderr straight to the terminal without buffering
+ PYTHONUNBUFFERED: yup
+ # Tell Django where to find its configuration
+ DJANGO_SETTINGS_MODULE: registrar.config.settings
+ # Tell Django where it is being hosted
+ DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
+ # Tell Django how much stuff to log
+ DJANGO_LOG_LEVEL: INFO
+ # default public site location
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
+ # Flag to disable/enable features in prod environments
+ IS_PRODUCTION: False
+ routes:
+ - route: getgov-bob.app.cloud.gov
+ services:
+ - getgov-credentials
+ - getgov-bob-database
diff --git a/ops/manifests/manifest-meoward.yaml b/ops/manifests/manifest-meoward.yaml
new file mode 100644
index 000000000..c47d9529d
--- /dev/null
+++ b/ops/manifests/manifest-meoward.yaml
@@ -0,0 +1,32 @@
+---
+applications:
+- name: getgov-meoward
+ buildpacks:
+ - python_buildpack
+ path: ../../src
+ instances: 1
+ memory: 512M
+ stack: cflinuxfs4
+ timeout: 180
+ command: ./run.sh
+ health-check-type: http
+ health-check-http-endpoint: /health
+ health-check-invocation-timeout: 40
+ env:
+ # Send stdout and stderr straight to the terminal without buffering
+ PYTHONUNBUFFERED: yup
+ # Tell Django where to find its configuration
+ DJANGO_SETTINGS_MODULE: registrar.config.settings
+ # Tell Django where it is being hosted
+ DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
+ # Tell Django how much stuff to log
+ DJANGO_LOG_LEVEL: INFO
+ # default public site location
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
+ # Flag to disable/enable features in prod environments
+ IS_PRODUCTION: False
+ routes:
+ - route: getgov-meoward.app.cloud.gov
+ services:
+ - getgov-credentials
+ - getgov-meoward-database
diff --git a/src/Pipfile b/src/Pipfile
index f24fbd550..9208fada5 100644
--- a/src/Pipfile
+++ b/src/Pipfile
@@ -29,6 +29,7 @@ django-login-required-middleware = "*"
greenlet = "*"
gevent = "*"
fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
+pyzipper="*"
tblib = "*"
[dev-packages]
@@ -44,4 +45,4 @@ django-webtest = "*"
types-cachetools = "*"
boto3-mocking = "*"
boto3-stubs = "*"
-django-model2puml = "*"
+django-model2puml = "*"
\ No newline at end of file
diff --git a/src/Pipfile.lock b/src/Pipfile.lock
index 1c8eac0a0..4eb2c0fb3 100644
--- a/src/Pipfile.lock
+++ b/src/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "b5d93b1b9ccafc37019276a222957544bab3f1f46b5dab8a0f2ffc2e5c9e1678"
+ "sha256": "082a951f15bb26a28f2dca7e0840fdf61518b3d90c42d77a310f982344cbd1dc"
},
"pipfile-spec": 6,
"requires": {},
@@ -32,20 +32,20 @@
},
"boto3": {
"hashes": [
- "sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8",
- "sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c"
+ "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714",
+ "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"botocore": {
"hashes": [
- "sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa",
- "sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5"
+ "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f",
+ "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec"
],
"markers": "python_version >= '3.8'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"cachetools": {
"hashes": [
@@ -376,20 +376,20 @@
"django"
],
"hashes": [
- "sha256:cc421ddb143fa30183568164755aa113a160e555cd19e97e664c478662032c24",
- "sha256:feeaf28f17fd0499f9cd7c0fcf408c6d82c308e69e335eb92d09322fc9ed8138"
+ "sha256:069727a8f73d8ba8d033d3cd95c0da231d44f38f1da773bf076cef168d312ee8",
+ "sha256:e0bcfd41c718c07a7db422f9109e490746450da38793fe4ee197f397b9343435"
],
"markers": "python_version >= '3.8'",
- "version": "==10.3.0"
+ "version": "==11.0.0"
},
"faker": {
"hashes": [
- "sha256:117ce1a2805c1bc5ca753b3dc6f9d567732893b2294b827d3164261ee8f20267",
- "sha256:458d93580de34403a8dec1e8d5e6be2fee96c4deca63b95d71df7a6a80a690de"
+ "sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1",
+ "sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==23.3.0"
+ "version": "==24.0.0"
},
"fred-epplib": {
"git": "https://github.com/cisagov/epplib.git",
@@ -708,11 +708,11 @@
},
"marshmallow": {
"hashes": [
- "sha256:20f53be28c6e374a711a16165fb22a8dc6003e3f7cda1285e3ca777b9193885b",
- "sha256:e7997f83571c7fd476042c2c188e4ee8a78900ca5e74bd9c8097afa56624e9bd"
+ "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3",
+ "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633"
],
"markers": "python_version >= '3.8'",
- "version": "==3.21.0"
+ "version": "==3.21.1"
},
"oic": {
"hashes": [
@@ -994,6 +994,15 @@
"markers": "python_version >= '3.8'",
"version": "==1.0.1"
},
+ "pyzipper": {
+ "hashes": [
+ "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc",
+ "sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.4'",
+ "version": "==0.3.6"
+ },
"requests": {
"hashes": [
"sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
@@ -1186,12 +1195,12 @@
},
"boto3": {
"hashes": [
- "sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8",
- "sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c"
+ "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714",
+ "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"boto3-mocking": {
"hashes": [
@@ -1204,28 +1213,28 @@
},
"boto3-stubs": {
"hashes": [
- "sha256:7db5194e47f76e0010cd00b6ad9725db114d6a3fd04e52ceed3ef1181fe326bc",
- "sha256:c7b2e8b99f4896cf1226df47d4badaaa8df7426008c96a428bf00205695669e9"
+ "sha256:627f8eca69d832581ee1676d39df099a2a2e3a86d6b3ebd21c81c5f11ed6a6fa",
+ "sha256:a87e7ecbab6235ec371b4363027e57483bca349a9cd5c891f40db81dadfa273e"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"botocore": {
"hashes": [
- "sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa",
- "sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5"
+ "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f",
+ "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec"
],
"markers": "python_version >= '3.8'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"botocore-stubs": {
"hashes": [
- "sha256:958f0084322dc9e549f73151b686fa51b15858fb2b3a573b9f4367f073fff463",
- "sha256:bcc35bfbd14d1261813681c40108f2ce85fdf082c15b0a04016d3c22dd93b73f"
+ "sha256:018e001e3add5eb1828ef444b45fb8c9faf695e08334031bf2d96853cd9af703",
+ "sha256:25468ba6983987b704b1856bb155f297f576e6d4a690b021ab0c7122889ba907"
],
"markers": "python_version >= '3.8' and python_version < '4.0'",
- "version": "==1.34.54"
+ "version": "==1.34.56"
},
"click": {
"hashes": [
diff --git a/src/api/tests/common.py b/src/api/tests/common.py
index 122965ae8..1a8c32526 100644
--- a/src/api/tests/common.py
+++ b/src/api/tests/common.py
@@ -49,3 +49,17 @@ def less_console_noise():
handler.setStream(restore[handler.name])
# close the file we opened
devnull.close()
+
+
+def less_console_noise_decorator(func):
+ """
+ Decorator to silence console logging using the less_console_noise() function.
+ """
+
+ # "Wrap" the original function in the less_console_noise with clause,
+ # then just return this wrapper.
+ def wrapper(*args, **kwargs):
+ with less_console_noise():
+ return func(*args, **kwargs)
+
+ return wrapper
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
index 2d3c842d2..8e112769b 100644
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -6,12 +6,13 @@ from django.conf import settings
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login
from django.http import HttpResponseRedirect
-from django.shortcuts import redirect, render
+from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client
from djangooidc import exceptions as o_e
from registrar.models import User
+from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view
logger = logging.getLogger(__name__)
@@ -49,27 +50,19 @@ def error_page(request, error):
"""Display a sensible message and log the error."""
logger.error(error)
if isinstance(error, o_e.AuthenticationFailed):
- return render(
- request,
- "401.html",
- context={
- "friendly_message": error.friendly_message,
- "log_identifier": error.locator,
- },
- status=401,
- )
+ context = {
+ "friendly_message": error.friendly_message,
+ "log_identifier": error.locator,
+ }
+ return custom_401_error_view(request, context)
if isinstance(error, o_e.InternalError):
- return render(
- request,
- "500.html",
- context={
- "friendly_message": error.friendly_message,
- "log_identifier": error.locator,
- },
- status=500,
- )
+ context = {
+ "friendly_message": error.friendly_message,
+ "log_identifier": error.locator,
+ }
+ return custom_500_error_view(request, context)
if isinstance(error, Exception):
- return render(request, "500.html", status=500)
+ return custom_500_error_view(request)
def openid(request):
diff --git a/src/docker-compose.yml b/src/docker-compose.yml
index 2e4ecaae5..c69c21192 100644
--- a/src/docker-compose.yml
+++ b/src/docker-compose.yml
@@ -58,6 +58,8 @@ services:
- AWS_S3_SECRET_ACCESS_KEY
- AWS_S3_REGION
- AWS_S3_BUCKET_NAME
+ # File encryption credentials
+ - SECRET_ENCRYPT_METADATA
stdin_open: true
tty: true
ports:
diff --git a/src/epplibwrapper/client.py b/src/epplibwrapper/client.py
index a7856298b..9d203b246 100644
--- a/src/epplibwrapper/client.py
+++ b/src/epplibwrapper/client.py
@@ -1,6 +1,7 @@
"""Provide a wrapper around epplib to handle authentication and errors."""
import logging
+from gevent.lock import BoundedSemaphore
try:
from epplib.client import Client
@@ -52,10 +53,16 @@ class EPPLibWrapper:
"urn:ietf:params:xml:ns:contact-1.0",
],
)
+ # We should only ever have one active connection at a time
+ self.connection_lock = BoundedSemaphore(1)
+
+ self.connection_lock.acquire()
try:
self._initialize_client()
except Exception:
- logger.warning("Unable to configure epplib. Registrar cannot contact registry.")
+ logger.warning("Unable to configure the connection to the registry.")
+ finally:
+ self.connection_lock.release()
def _initialize_client(self) -> None:
"""Initialize a client, assuming _login defined. Sets _client to initialized
@@ -74,11 +81,7 @@ class EPPLibWrapper:
)
try:
# use the _client object to connect
- self._client.connect() # type: ignore
- response = self._client.send(self._login) # type: ignore
- if response.code >= 2000: # type: ignore
- self._client.close() # type: ignore
- raise LoginError(response.msg) # type: ignore
+ self._connect()
except TransportError as err:
message = "_initialize_client failed to execute due to a connection error."
logger.error(f"{message} Error: {err}")
@@ -90,13 +93,33 @@ class EPPLibWrapper:
logger.error(f"{message} Error: {err}")
raise RegistryError(message) from err
+ def _connect(self) -> None:
+ """Connects to EPP. Sends a login command. If an invalid response is returned,
+ the client will be closed and a LoginError raised."""
+ self._client.connect() # type: ignore
+ response = self._client.send(self._login) # type: ignore
+ if response.code >= 2000: # type: ignore
+ self._client.close() # type: ignore
+ raise LoginError(response.msg) # type: ignore
+
def _disconnect(self) -> None:
- """Close the connection."""
+ """Close the connection. Sends a logout command and closes the connection."""
+ self._send_logout_command()
+ self._close_client()
+
+ def _send_logout_command(self):
+ """Sends a logout command to epp"""
try:
self._client.send(commands.Logout()) # type: ignore
- self._client.close() # type: ignore
- except Exception:
- logger.warning("Connection to registry was not cleanly closed.")
+ except Exception as err:
+ logger.warning(f"Logout command not sent successfully: {err}")
+
+ def _close_client(self):
+ """Closes an active client connection"""
+ try:
+ self._client.close()
+ except Exception as err:
+ logger.warning(f"Connection to registry was not cleanly closed: {err}")
def _send(self, command):
"""Helper function used by `send`."""
@@ -146,6 +169,8 @@ class EPPLibWrapper:
cmd_type = command.__class__.__name__
if not cleaned:
raise ValueError("Please sanitize user input before sending it.")
+
+ self.connection_lock.acquire()
try:
return self._send(command)
except RegistryError as err:
@@ -161,6 +186,8 @@ class EPPLibWrapper:
return self._retry(command)
else:
raise err
+ finally:
+ self.connection_lock.release()
try:
diff --git a/src/epplibwrapper/tests/test_client.py b/src/epplibwrapper/tests/test_client.py
index f95b37dcd..d30ec4865 100644
--- a/src/epplibwrapper/tests/test_client.py
+++ b/src/epplibwrapper/tests/test_client.py
@@ -1,5 +1,9 @@
+import datetime
+from dateutil.tz import tzlocal # type: ignore
from unittest.mock import MagicMock, patch
+from pathlib import Path
from django.test import TestCase
+from gevent.exceptions import ConcurrentObjectUseError
from epplibwrapper.client import EPPLibWrapper
from epplibwrapper.errors import RegistryError, LoginError
from .common import less_console_noise
@@ -8,6 +12,9 @@ import logging
try:
from epplib.exceptions import TransportError
from epplib.responses import Result
+ from epplib.transport import SocketTransport
+ from epplib import commands
+ from epplib.models import common, info
except ImportError:
pass
@@ -255,3 +262,116 @@ class TestClient(TestCase):
mock_close.assert_called_once()
# send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command)
self.assertEquals(mock_send.call_count, 5)
+
+ def fake_failure_send_concurrent_threads(self, command=None, cleaned=None):
+ """
+ Raises a ConcurrentObjectUseError, which gevent throws when accessing
+ the same thread from two different locations.
+ """
+ # This error is thrown when two threads are being used concurrently
+ raise ConcurrentObjectUseError("This socket is already used by another greenlet")
+
+ def do_nothing(self, command=None):
+ """
+ A placeholder method that performs no action.
+ """
+ pass # noqa
+
+ def fake_success_send(self, command=None, cleaned=None):
+ """
+ Simulates receiving a success response from EPP.
+ """
+ mock = MagicMock(
+ code=1000,
+ msg="Command completed successfully",
+ res_data=None,
+ cl_tr_id="xkw1uo#2023-10-17T15:29:09.559376",
+ sv_tr_id="5CcH4gxISuGkq8eqvr1UyQ==-35a",
+ extensions=[],
+ msg_q=None,
+ )
+ return mock
+
+ def fake_info_domain_received(self, command=None, cleaned=None):
+ """
+ Simulates receiving a response by reading from a predefined XML file.
+ """
+ location = Path(__file__).parent / "utility" / "infoDomain.xml"
+ xml = (location).read_bytes()
+ return xml
+
+ def get_fake_epp_result(self):
+ """Mimics a return from EPP by returning a dictionary in the same format"""
+ result = {
+ "cl_tr_id": None,
+ "code": 1000,
+ "extensions": [],
+ "msg": "Command completed successfully",
+ "msg_q": None,
+ "res_data": [
+ info.InfoDomainResultData(
+ roid="DF1340360-GOV",
+ statuses=[
+ common.Status(
+ state="serverTransferProhibited",
+ description=None,
+ lang="en",
+ ),
+ common.Status(state="inactive", description=None, lang="en"),
+ ],
+ cl_id="gov2023-ote",
+ cr_id="gov2023-ote",
+ cr_date=datetime.datetime(2023, 8, 15, 23, 56, 36, tzinfo=tzlocal()),
+ up_id="gov2023-ote",
+ up_date=datetime.datetime(2023, 8, 17, 2, 3, 19, tzinfo=tzlocal()),
+ tr_date=None,
+ name="test3.gov",
+ registrant="TuaWnx9hnm84GCSU",
+ admins=[],
+ nsset=None,
+ keyset=None,
+ ex_date=datetime.date(2024, 8, 15),
+ auth_info=info.DomainAuthInfo(pw="2fooBAR123fooBaz"),
+ )
+ ],
+ "sv_tr_id": "wRRNVhKhQW2m6wsUHbo/lA==-29a",
+ }
+ return result
+
+ def test_send_command_close_failure_recovers(self):
+ """
+ Validates the resilience of the connection handling mechanism
+ during command execution on retry.
+
+ Scenario:
+ - Initialization of the connection is successful.
+ - An attempt to send a command fails with a specific error code (ConcurrentObjectUseError)
+ - The client attempts to retry.
+ - Subsequently, the client re-initializes the connection.
+ - A retry of the command execution post-reinitialization succeeds.
+ """
+
+ expected_result = self.get_fake_epp_result()
+ wrapper = None
+ # Trigger a retry
+ # Do nothing on connect, as we aren't testing it and want to connect while
+ # mimicking the rest of the client as closely as possible (which is not entirely possible with MagicMock)
+ with patch.object(EPPLibWrapper, "_connect", self.do_nothing):
+ with patch.object(SocketTransport, "send", self.fake_failure_send_concurrent_threads):
+ wrapper = EPPLibWrapper()
+ tested_command = commands.InfoDomain(name="test.gov")
+ try:
+ wrapper.send(tested_command, cleaned=True)
+ except RegistryError as err:
+ expected_error = "InfoDomain failed to execute due to an unknown error."
+ self.assertEqual(err.args[0], expected_error)
+ else:
+ self.fail("Registry error was not thrown")
+
+ # After a retry, try sending again to see if the connection recovers
+ with patch.object(EPPLibWrapper, "_connect", self.do_nothing):
+ with patch.object(SocketTransport, "send", self.fake_success_send), patch.object(
+ SocketTransport, "receive", self.fake_info_domain_received
+ ):
+ result = wrapper.send(tested_command, cleaned=True)
+ self.assertEqual(expected_result, result.__dict__)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index c729fd1ba..aee5ca382 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,10 +1,11 @@
from datetime import date
import logging
+import copy
from django import forms
-from django.db.models.functions import Concat, Coalesce
from django.db.models import Value, CharField, Q
-from django.http import HttpResponse, HttpResponseRedirect
+from django.db.models.functions import Concat, Coalesce
+from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
@@ -875,18 +876,21 @@ class DomainInformationAdmin(ListHeaderAdmin):
search_help_text = "Search by domain."
fieldsets = [
- (None, {"fields": ["creator", "domain_request", "notes"]}),
+ (None, {"fields": ["creator", "submitter", "domain_request", "notes"]}),
+ (".gov domain", {"fields": ["domain"]}),
+ ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
+ ("Background info", {"fields": ["anything_else"]}),
(
"Type of organization",
{
"fields": [
"organization_type",
+ "is_election_board",
+ "federal_type",
+ "federal_agency",
+ "tribe_name",
"federally_recognized_tribe",
"state_recognized_tribe",
- "tribe_name",
- "federal_agency",
- "federal_type",
- "is_election_board",
"about_your_organization",
]
},
@@ -896,28 +900,15 @@ class DomainInformationAdmin(ListHeaderAdmin):
{
"fields": [
"organization_name",
+ "state_territory",
"address_line1",
"address_line2",
"city",
- "state_territory",
"zipcode",
"urbanization",
]
},
),
- ("Authorizing official", {"fields": ["authorizing_official"]}),
- (".gov domain", {"fields": ["domain"]}),
- ("Your contact information", {"fields": ["submitter"]}),
- ("Other employees from your organization?", {"fields": ["other_contacts"]}),
- (
- "No other employees from your organization?",
- {"fields": ["no_other_contacts_rationale"]},
- ),
- ("Anything else?", {"fields": ["anything_else"]}),
- (
- "Requirements for operating a .gov domain",
- {"fields": ["is_policy_acknowledged"]},
- ),
]
# Read only that we'll leverage for CISA Analysts
@@ -1029,6 +1020,8 @@ class DomainRequestAdmin(ListHeaderAdmin):
if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
+ change_form_template = "django/admin/domain_application_change_form.html"
+
# Columns
list_display = [
"requested_domain",
@@ -1040,7 +1033,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"custom_election_board",
"city",
"state_territory",
- "created_at",
+ "submission_date",
"submitter",
"investigator",
]
@@ -1077,18 +1070,34 @@ class DomainRequestAdmin(ListHeaderAdmin):
search_help_text = "Search by domain or submitter."
fieldsets = [
- (None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}),
+ (
+ None,
+ {
+ "fields": [
+ "status",
+ "rejection_reason",
+ "investigator",
+ "creator",
+ "submitter",
+ "approved_domain",
+ "notes",
+ ]
+ },
+ ),
+ (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
+ ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
+ ("Background info", {"fields": ["purpose", "anything_else", "current_websites"]}),
(
"Type of organization",
{
"fields": [
"organization_type",
+ "is_election_board",
+ "federal_type",
+ "federal_agency",
+ "tribe_name",
"federally_recognized_tribe",
"state_recognized_tribe",
- "tribe_name",
- "federal_agency",
- "federal_type",
- "is_election_board",
"about_your_organization",
]
},
@@ -1098,30 +1107,15 @@ class DomainRequestAdmin(ListHeaderAdmin):
{
"fields": [
"organization_name",
+ "state_territory",
"address_line1",
"address_line2",
"city",
- "state_territory",
"zipcode",
"urbanization",
]
},
),
- ("Authorizing official", {"fields": ["authorizing_official"]}),
- ("Current websites", {"fields": ["current_websites"]}),
- (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
- ("Purpose of your domain", {"fields": ["purpose"]}),
- ("Your contact information", {"fields": ["submitter"]}),
- ("Other employees from your organization?", {"fields": ["other_contacts"]}),
- (
- "No other employees from your organization?",
- {"fields": ["no_other_contacts_rationale"]},
- ),
- ("Anything else?", {"fields": ["anything_else"]}),
- (
- "Requirements for operating a .gov domain",
- {"fields": ["is_policy_acknowledged"]},
- ),
]
# Read only that we'll leverage for CISA Analysts
@@ -1353,7 +1347,13 @@ class DomainInformationInline(admin.StackedInline):
model = models.DomainInformation
- fieldsets = DomainInformationAdmin.fieldsets
+ fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets)
+ # remove .gov domain from fieldset
+ for index, (title, f) in enumerate(fieldsets):
+ if title == ".gov domain":
+ del fieldsets[index]
+ break
+
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
# to activate the edit/delete/view buttons
@@ -1490,12 +1490,25 @@ class DomainAdmin(ListHeaderAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
- change_list_template = "django/admin/domain_change_list.html"
readonly_fields = ["state", "expiration_date", "first_ready", "deleted"]
# Table ordering
ordering = ["name"]
+ # Override for the delete confirmation page on the domain table (bulk delete action)
+ delete_selected_confirmation_template = "django/admin/domain_delete_selected_confirmation.html"
+
+ def delete_view(self, request, object_id, extra_context=None):
+ """
+ Custom delete_view to perform additional actions or customize the template.
+ """
+
+ # Set the delete template to a custom one
+ self.delete_confirmation_template = "django/admin/domain_delete_confirmation.html"
+ response = super().delete_view(request, object_id, extra_context=extra_context)
+
+ return response
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
"""Custom changeform implementation to pass in context information"""
if extra_context is None:
@@ -1523,56 +1536,6 @@ class DomainAdmin(ListHeaderAdmin):
return super().changeform_view(request, object_id, form_url, extra_context)
- def export_data_type(self, request):
- # match the CSV example with all the fields
- response = HttpResponse(content_type="text/csv")
- response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"'
- csv_export.export_data_type_to_csv(response)
- return response
-
- def export_data_full(self, request):
- # Smaller export based on 1
- response = HttpResponse(content_type="text/csv")
- response["Content-Disposition"] = 'attachment; filename="current-full.csv"'
- csv_export.export_data_full_to_csv(response)
- return response
-
- def export_data_federal(self, request):
- # Federal only
- response = HttpResponse(content_type="text/csv")
- response["Content-Disposition"] = 'attachment; filename="current-federal.csv"'
- csv_export.export_data_federal_to_csv(response)
- return response
-
- def get_urls(self):
- from django.urls import path
-
- urlpatterns = super().get_urls()
-
- # Used to extrapolate a path name, for instance
- # name="{app_label}_{model_name}_export_data_type"
- info = self.model._meta.app_label, self.model._meta.model_name
-
- my_url = [
- path(
- "export_data_type/",
- self.export_data_type,
- name="%s_%s_export_data_type" % info,
- ),
- path(
- "export_data_full/",
- self.export_data_full,
- name="%s_%s_export_data_full" % info,
- ),
- path(
- "export_data_federal/",
- self.export_data_federal,
- name="%s_%s_export_data_federal" % info,
- ),
- ]
-
- return my_url + urlpatterns
-
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
@@ -1704,9 +1667,11 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
- "Error deleting this Domain: "
- f"Can't switch from state '{obj.state}' to 'deleted'"
- ", must be either 'dns_needed' or 'on_hold'",
+ (
+ "Error deleting this Domain: "
+ f"Can't switch from state '{obj.state}' to 'deleted'"
+ ", must be either 'dns_needed' or 'on_hold'"
+ ),
messages.ERROR,
)
except Exception:
@@ -1718,7 +1683,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
- ("Domain %s has been deleted. Thanks!") % obj.name,
+ "Domain %s has been deleted. Thanks!" % obj.name,
)
return HttpResponseRedirect(".")
@@ -1760,7 +1725,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
- ("%s is in client hold. This domain is no longer accessible on the public internet.") % obj.name,
+ "%s is in client hold. This domain is no longer accessible on the public internet." % obj.name,
)
return HttpResponseRedirect(".")
@@ -1789,7 +1754,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
- ("%s is ready. This domain is accessible on the public internet.") % obj.name,
+ "%s is ready. This domain is accessible on the public internet." % obj.name,
)
return HttpResponseRedirect(".")
@@ -1834,9 +1799,6 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
search_help_text = "Search by email."
- list_filter = [
- "requestor",
- ]
readonly_fields = [
"requestor",
]
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index ff73acb65..8c60c534f 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -29,20 +29,26 @@ function openInNewTab(el, removeAttribute = false){
*/
(function (){
function createPhantomModalFormButtons(){
- let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]');
+ let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder');
form = document.querySelector("form")
submitButtons.forEach((button) => {
let input = document.createElement("input");
input.type = "submit";
- input.name = button.name;
- input.value = button.value;
+
+ if(button.name){
+ input.name = button.name;
+ }
+
+ if(button.value){
+ input.value = button.value;
+ }
+
input.style.display = "none"
// Add the hidden input to the form
form.appendChild(input);
button.addEventListener("click", () => {
- console.log("clicking")
input.click();
})
})
@@ -50,6 +56,61 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons();
})();
+
+/** An IIFE for DomainRequest to hook a modal to a dropdown option.
+ * This intentionally does not interact with createPhantomModalFormButtons()
+*/
+(function (){
+ function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
+
+ // If these exist all at the same time, we're on the right page
+ if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){
+
+ // Set the previous value in the event the user cancels.
+ let previousValue = statusDropdown.value;
+ if (actionButton){
+
+ // Otherwise, if the confirmation buttion is pressed, set it to that
+ actionButton.addEventListener('click', function() {
+ // Revert the dropdown to its previous value
+ statusDropdown.value = valueToCheck;
+ });
+ }else {
+ console.log("displayModalOnDropdownClick() -> Cancel button was null")
+ }
+
+ // Add a change event listener to the dropdown.
+ statusDropdown.addEventListener('change', function() {
+ // Check if "Ineligible" is selected
+ if (this.value && this.value.toLowerCase() === valueToCheck) {
+ // Set the old value in the event the user cancels,
+ // or otherwise exists the dropdown.
+ statusDropdown.value = previousValue
+
+ // Display the modal.
+ linkClickedDisplaysModal.click()
+ }
+ });
+ }
+ }
+
+ // When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown.
+ function hookModalToIneligibleStatus(){
+ // Grab the invisible element that will hook to the modal.
+ // This doesn't technically need to be done with one, but this is simpler to manage.
+ let modalButton = document.getElementById("invisible-ineligible-modal-toggler")
+ let statusDropdown = document.getElementById("id_status")
+
+ // Because the modal button does not have the class "dja-form-placeholder",
+ // it will not be affected by the createPhantomModalFormButtons() function.
+ let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]');
+ let valueToCheck = "ineligible"
+ displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck);
+ }
+
+ hookModalToIneligibleStatus()
+})();
+
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object,
* but this can be expanded.
@@ -307,43 +368,6 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
}
-/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
- * attach the seleted start and end dates to a url that'll trigger the view, and finally
- * redirect to that url.
-*/
-(function (){
-
- // Get the current date in the format YYYY-MM-DD
- let currentDate = new Date().toISOString().split('T')[0];
-
- // Default the value of the start date input field to the current date
- let startDateInput =document.getElementById('start');
-
- // Default the value of the end date input field to the current date
- let endDateInput =document.getElementById('end');
-
- let exportGrowthReportButton = document.getElementById('exportLink');
-
- if (exportGrowthReportButton) {
- startDateInput.value = currentDate;
- endDateInput.value = currentDate;
-
- exportGrowthReportButton.addEventListener('click', function() {
- // Get the selected start and end dates
- let startDate = startDateInput.value;
- let endDate = endDateInput.value;
- let exportUrl = document.getElementById('exportLink').dataset.exportUrl;
-
- // Build the URL with parameters
- exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
-
- // Redirect to the export URL
- window.location.href = exportUrl;
- });
- }
-
-})();
-
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select amd to show/hide the rejection reason
*/
diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js
new file mode 100644
index 000000000..d10cf2dc6
--- /dev/null
+++ b/src/registrar/assets/js/get-gov-reports.js
@@ -0,0 +1,117 @@
+/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
+ * attach the seleted start and end dates to a url that'll trigger the view, and finally
+ * redirect to that url.
+ *
+ * This function also sets the start and end dates to match the url params if they exist
+*/
+(function () {
+ // Function to get URL parameter value by name
+ function getParameterByName(name, url) {
+ if (!url) url = window.location.href;
+ name = name.replace(/[\[\]]/g, '\\$&');
+ var regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)'),
+ results = regex.exec(url);
+ if (!results) return null;
+ if (!results[2]) return '';
+ return decodeURIComponent(results[2].replace(/\+/g, ' '));
+ }
+
+ // Get the current date in the format YYYY-MM-DD
+ let currentDate = new Date().toISOString().split('T')[0];
+
+ // Default the value of the start date input field to the current date
+ let startDateInput = document.getElementById('start');
+
+ // Default the value of the end date input field to the current date
+ let endDateInput = document.getElementById('end');
+
+ let exportButtons = document.querySelectorAll('.exportLink');
+
+ if (exportButtons.length > 0) {
+ // Check if start and end dates are present in the URL
+ let urlStartDate = getParameterByName('start_date');
+ let urlEndDate = getParameterByName('end_date');
+
+ // Set input values based on URL parameters or current date
+ startDateInput.value = urlStartDate || currentDate;
+ endDateInput.value = urlEndDate || currentDate;
+
+ exportButtons.forEach((btn) => {
+ btn.addEventListener('click', function () {
+ // Get the selected start and end dates
+ let startDate = startDateInput.value;
+ let endDate = endDateInput.value;
+ let exportUrl = btn.dataset.exportUrl;
+
+ // Build the URL with parameters
+ exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
+
+ // Redirect to the export URL
+ window.location.href = exportUrl;
+ });
+ });
+ }
+
+})();
+
+document.addEventListener("DOMContentLoaded", function () {
+ createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
+ createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
+ createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
+ createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
+ createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
+ createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
+});
+
+function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
+ var canvas = document.getElementById(canvasId);
+ var ctx = canvas.getContext("2d");
+
+ var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
+ var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
+
+ var data = {
+ labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
+ datasets: [
+ {
+ label: labelOne,
+ backgroundColor: "rgba(255, 99, 132, 0.2)",
+ borderColor: "rgba(255, 99, 132, 1)",
+ borderWidth: 1,
+ data: listOne,
+ },
+ {
+ label: labelTwo,
+ backgroundColor: "rgba(75, 192, 192, 0.2)",
+ borderColor: "rgba(75, 192, 192, 1)",
+ borderWidth: 1,
+ data: listTwo,
+ },
+ ],
+ };
+
+ var options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'top',
+ },
+ title: {
+ display: true,
+ text: title
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ },
+ },
+ };
+
+ new Chart(ctx, {
+ type: "bar",
+ data: data,
+ options: options,
+ });
+}
\ No newline at end of file
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index dc67bc8b6..18025a9cb 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -112,7 +112,8 @@ html[data-theme="light"] {
.change-list .usa-table thead th,
body.dashboard,
body.change-list,
- body.change-form {
+ body.change-form,
+ .analytics {
color: var(--body-fg);
}
}
@@ -143,6 +144,10 @@ h1, h2, h3,
font-weight: font-weight('bold');
}
+div#content > h2 {
+ font-size: 1.3rem;
+}
+
.module h3 {
padding: 0;
color: var(--link-fg);
@@ -299,3 +304,45 @@ input.admin-confirm-button {
display: contents !important;
}
}
+
+.usa-button-group {
+ margin-left: -0.25rem!important;
+ padding-left: 0!important;
+ .usa-button-group__item {
+ list-style-type: none;
+ line-height: normal;
+ }
+ .button {
+ display: inline-block;
+ padding: 10px 8px;
+ line-height: normal;
+ }
+ .usa-icon {
+ top: 2px;
+ }
+ a.button:active, a.button:focus {
+ text-decoration: none;
+ }
+}
+
+.module--custom {
+ a {
+ font-size: 13px;
+ font-weight: 600;
+ border: solid 1px var(--darkened-bg);
+ background: var(--darkened-bg);
+ }
+}
+
+.usa-modal--django-admin .usa-prose ul > li {
+ list-style-type: inherit;
+ // Styling based off of the
styling in django admin
+ line-height: 1.5;
+ margin-bottom: 0;
+ margin-top: 0;
+ max-width: 68ex;
+}
+
+.usa-summary-box__dhs-color {
+ color: $dhs-blue-70;
+}
diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss
index 94407f88d..058a9f6c8 100644
--- a/src/registrar/assets/sass/_theme/_forms.scss
+++ b/src/registrar/assets/sass/_theme/_forms.scss
@@ -38,3 +38,18 @@ legend.float-left-tablet + button.float-right-tablet {
margin-top: 1rem;
}
}
+
+// Custom style for disabled inputs
+@media (prefers-color-scheme: light) {
+ .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
+ background-color: #eeeeee;
+ color: #666666;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
+ background-color: var(--body-fg);
+ color: var(--close-button-hover-bg);
+ }
+}
\ No newline at end of file
diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss
index a26f23508..6ef679734 100644
--- a/src/registrar/assets/sass/_theme/_uswds-theme.scss
+++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss
@@ -126,7 +126,6 @@ in the form $setting: value,
----------------------------*/
$theme-input-line-height: 5,
-
/*---------------------------
# Component settings
-----------------------------
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 15799f91b..646b7298f 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -74,6 +74,9 @@ secret_aws_s3_key_id = secret("access_key_id", None) or secret("AWS_S3_ACCESS_KE
secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None)
secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None)
+# Passphrase for the encrypted metadata email
+secret_encrypt_metadata = secret("SECRET_ENCRYPT_METADATA", None)
+
secret_registry_cl_id = secret("REGISTRY_CL_ID")
secret_registry_password = secret("REGISTRY_PASSWORD")
secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
@@ -94,6 +97,7 @@ DEBUG = env_debug
# Controls production specific feature toggles
IS_PRODUCTION = env_is_production
+SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
# Applications are modular pieces of code.
# They are provided by Django, by third-parties, or by yourself.
@@ -326,8 +330,9 @@ CSP_FORM_ACTION = allowed_sources
# Google analytics requires that we relax our otherwise
# strict CSP by allowing scripts to run from their domain
-# and inline with a nonce, as well as allowing connections back to their domain
-CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/"]
+# and inline with a nonce, as well as allowing connections back to their domain.
+# Note: If needed, we can embed chart.js instead of using the CDN
+CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
@@ -635,6 +640,8 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
+ "getgov-bob.app.cloud.gov",
+ "getgov-meoward.app.cloud.gov",
"getgov-backup.app.cloud.gov",
"getgov-ky.app.cloud.gov",
"getgov-es.app.cloud.gov",
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 9049d718c..3918fa087 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -9,9 +9,16 @@ from django.urls import include, path
from django.views.generic import RedirectView
from registrar import views
-
-from registrar.views.admin_views import ExportData
-
+from registrar.views.admin_views import (
+ ExportDataDomainsGrowth,
+ ExportDataFederal,
+ ExportDataFull,
+ ExportDataManagedDomains,
+ ExportDataRequestsGrowth,
+ ExportDataType,
+ ExportDataUnmanagedDomains,
+ AnalyticsView,
+)
from registrar.views.domain_request import Step
from registrar.views.utility import always_404
@@ -52,7 +59,46 @@ urlpatterns = [
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
- path("export_data/", ExportData.as_view(), name="admin_export_data"),
+ path(
+ "admin/analytics/export_data_type/",
+ ExportDataType.as_view(),
+ name="export_data_type",
+ ),
+ path(
+ "admin/analytics/export_data_full/",
+ ExportDataFull.as_view(),
+ name="export_data_full",
+ ),
+ path(
+ "admin/analytics/export_data_federal/",
+ ExportDataFederal.as_view(),
+ name="export_data_federal",
+ ),
+ path(
+ "admin/analytics/export_domains_growth/",
+ ExportDataDomainsGrowth.as_view(),
+ name="export_domains_growth",
+ ),
+ path(
+ "admin/analytics/export_requests_growth/",
+ ExportDataRequestsGrowth.as_view(),
+ name="export_requests_growth",
+ ),
+ path(
+ "admin/analytics/export_managed_domains/",
+ ExportDataManagedDomains.as_view(),
+ name="export_managed_domains",
+ ),
+ path(
+ "admin/analytics/export_unmanaged_domains/",
+ ExportDataUnmanagedDomains.as_view(),
+ name="export_unmanaged_domains",
+ ),
+ path(
+ "admin/analytics/",
+ AnalyticsView.as_view(),
+ name="analytics",
+ ),
path("admin/", admin.site.urls),
path(
"domain-request//edit/",
@@ -149,6 +195,18 @@ urlpatterns = [
),
]
+# Djangooidc strips out context data from that context, so we define a custom error
+# view through this method.
+# If Djangooidc is left to its own devices and uses reverse directly,
+# then both context and session information will be obliterated due to:
+
+# a) Djangooidc being out of scope for context_processors
+# b) Potential cyclical import errors restricting what kind of data is passable.
+
+# Rather than dealing with that, we keep everything centralized in one location.
+# This way, we can share a view for djangooidc, and other pages as we see fit.
+handler500 = "registrar.views.utility.error_views.custom_500_error_view"
+
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG
# was actually True. Instead, let's add these URLs any time we are able to
diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py
index e89809484..bbe4d0b62 100644
--- a/src/registrar/fixtures_users.py
+++ b/src/registrar/fixtures_users.py
@@ -163,6 +163,12 @@ class UserFixture:
"last_name": "Chin-Analyst",
"email": "szu.chin@ecstech.com",
},
+ {
+ "username": "d9839768-0c17-4fa2-9c8e-36291eef5c11",
+ "first_name": "Alex-Analyst",
+ "last_name": "Mcelya-Analyst",
+ "email": "ALEXANDER.MCELYA@cisa.dhs.gov",
+ },
]
def load_users(cls, users, group_name):
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index 1669774ae..22d76c768 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -1,10 +1,12 @@
"""Forms for domain management."""
+import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.forms import formset_factory
-
+from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
+from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes as nsErrorCodes,
@@ -23,6 +25,9 @@ from .common import (
import re
+logger = logging.getLogger(__name__)
+
+
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
@@ -205,6 +210,13 @@ class ContactForm(forms.ModelForm):
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
+ self.domainInfo = None
+
+ def set_domain_info(self, domainInfo):
+ """Set the domain information for the form.
+ The form instance is associated with the contact itself. In order to access the associated
+ domain information object, this needs to be set in the form by the view."""
+ self.domainInfo = domainInfo
class AuthorizingOfficialContactForm(ContactForm):
@@ -212,7 +224,7 @@ class AuthorizingOfficialContactForm(ContactForm):
JOIN = "authorizing_official"
- def __init__(self, *args, **kwargs):
+ def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs)
# Overriding bc phone not required in this form
@@ -232,20 +244,36 @@ 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
+ # All fields should be disabled if the domain is federal or tribal
+ if disable_fields:
+ DomainHelper.mass_disable_fields(fields=self.fields, disable_required=True, disable_maxlength=True)
def save(self, commit=True):
- """Override the save() method of the BaseModelForm."""
+ """
+ Override the save() method of the BaseModelForm.
+ Used to perform checks on the underlying domain_information object.
+ If this doesn't exist, we just save as normal.
+ """
+
+ # If the underlying Domain doesn't have a domainInfo object,
+ # just let the default super handle it.
+ if not self.domainInfo:
+ return super().save()
+
+ # Determine if the domain is federal or tribal
+ is_federal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.FEDERAL
+ is_tribal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.TRIBAL
# 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"):
+
+ if (is_federal or is_tribal) and self.has_changed():
+ # This action should be blocked by the UI, as the text fields are readonly.
+ # If they get past this point, we forbid it this way.
+ # This could be malicious, so lets reserve information for the backend only.
+ raise ValueError("Authorizing Official cannot be modified for federal or tribal domains.")
+ elif db_ao.has_more_than_one_join("information_authorizing_official"):
# Handle the case where the domain information object is available and the AO Contact
# has more than one joined object.
# In this case, create a new Contact, and update the new Contact with form data.
@@ -254,6 +282,7 @@ class AuthorizingOfficialContactForm(ContactForm):
self.domainInfo.authorizing_official = Contact.objects.create(**data)
self.domainInfo.save()
else:
+ # If all checks pass, just save normally
super().save()
@@ -304,11 +333,11 @@ class DomainOrgNameAddressForm(forms.ModelForm):
},
}
widgets = {
- # We need to set the required attributed for federal_agency and
- # state/territory because for these fields we are creating an individual
+ # We need to set the required attributed for State/territory
+ # because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
- "federal_agency": forms.Select(attrs={"required": True}, choices=DomainInformation.AGENCY_CHOICES),
+ "federal_agency": forms.TextInput,
"organization_name": forms.TextInput,
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,
@@ -334,6 +363,46 @@ class DomainOrgNameAddressForm(forms.ModelForm):
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
+ self.is_federal = self.instance.organization_type == DomainRequest.OrganizationChoices.FEDERAL
+ self.is_tribal = self.instance.organization_type == DomainRequest.OrganizationChoices.TRIBAL
+
+ field_to_disable = None
+ if self.is_federal:
+ field_to_disable = "federal_agency"
+ elif self.is_tribal:
+ field_to_disable = "organization_name"
+
+ # Disable any field that should be disabled, if applicable
+ if field_to_disable is not None:
+ DomainHelper.disable_field(self.fields[field_to_disable], disable_required=True)
+
+ def save(self, commit=True):
+ """Override the save() method of the BaseModelForm."""
+ if self.has_changed():
+
+ # This action should be blocked by the UI, as the text fields are readonly.
+ # If they get past this point, we forbid it this way.
+ # This could be malicious, so lets reserve information for the backend only.
+ if self.is_federal and not self._field_unchanged("federal_agency"):
+ raise ValueError("federal_agency cannot be modified when the organization_type is federal")
+ elif self.is_tribal and not self._field_unchanged("organization_name"):
+ raise ValueError("organization_name cannot be modified when the organization_type is tribal")
+
+ else:
+ super().save()
+
+ def _field_unchanged(self, field_name) -> bool:
+ """
+ Checks if a specified field has not changed between the old value
+ and the new value.
+
+ The old value is grabbed from self.initial.
+ The new value is grabbed from self.cleaned_data.
+ """
+ old_value = self.initial.get(field_name, None)
+ new_value = self.cleaned_data.get(field_name, None)
+ return old_value == new_value
+
class DomainDnssecForm(forms.Form):
"""Form for enabling and disabling dnssec"""
diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py
index 17e64fafd..ef47143ea 100644
--- a/src/registrar/forms/domain_request_wizard.py
+++ b/src/registrar/forms/domain_request_wizard.py
@@ -319,8 +319,8 @@ class AboutYourOrganizationForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
- 1000,
- message="Response must be less than 1000 characters.",
+ 2000,
+ message="Response must be less than 2000 characters.",
)
],
error_messages={"required": ("Enter more information about your organization.")},
@@ -515,8 +515,8 @@ class PurposeForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
- 1000,
- message="Response must be less than 1000 characters.",
+ 2000,
+ message="Response must be less than 2000 characters.",
)
],
error_messages={"required": "Describe how you’ll use the .gov domain you’re requesting."},
@@ -830,8 +830,8 @@ class AnythingElseForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
- 1000,
- message="Response must be less than 1000 characters.",
+ 2000,
+ message="Response must be less than 2000 characters.",
)
],
)
diff --git a/src/registrar/management/commands/email_current_metadata_report.py b/src/registrar/management/commands/email_current_metadata_report.py
new file mode 100644
index 000000000..dcaf47b06
--- /dev/null
+++ b/src/registrar/management/commands/email_current_metadata_report.py
@@ -0,0 +1,105 @@
+"""Generates current-metadata.csv then uploads to S3 + sends email"""
+
+import logging
+import os
+import pyzipper
+
+from datetime import datetime
+
+from django.core.management import BaseCommand
+from django.conf import settings
+from registrar.utility import csv_export
+from registrar.utility.s3_bucket import S3ClientHelper
+from ...utility.email import send_templated_email
+
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = (
+ "Generates and uploads a domain-metadata.csv file to our S3 bucket "
+ "which is based off of all existing Domains."
+ )
+
+ def add_arguments(self, parser):
+ """Add our two filename arguments."""
+ parser.add_argument("--directory", default="migrationdata", help="Desired directory")
+ parser.add_argument(
+ "--checkpath",
+ default=True,
+ help="Flag that determines if we do a check for os.path.exists. Used for test cases",
+ )
+
+ def handle(self, **options):
+ """Grabs the directory then creates domain-metadata.csv in that directory"""
+ file_name = "domain-metadata.csv"
+ # Ensures a slash is added
+ directory = os.path.join(options.get("directory"), "")
+ check_path = options.get("checkpath")
+
+ logger.info("Generating report...")
+ try:
+ self.email_current_metadata_report(directory, file_name, check_path)
+ except Exception as err:
+ # TODO - #1317: Notify operations when auto report generation fails
+ raise err
+ else:
+ logger.info(f"Success! Created {file_name} and successfully sent out an email!")
+
+ def email_current_metadata_report(self, directory, file_name, check_path):
+ """Creates a current-metadata.csv file under the specified directory,
+ then uploads it to a AWS S3 bucket. This is done for resiliency
+ reasons in the event our application goes down and/or the email
+ cannot send -- we'll still be able to grab info from the S3
+ instance"""
+ s3_client = S3ClientHelper()
+ file_path = os.path.join(directory, file_name)
+
+ # Generate a file locally for upload
+ with open(file_path, "w") as file:
+ csv_export.export_data_type_to_csv(file)
+
+ if check_path and not os.path.exists(file_path):
+ raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
+
+ s3_client.upload_file(file_path, file_name)
+
+ # Set zip file name
+ current_date = datetime.now().strftime("%m%d%Y")
+ current_filename = f"domain-metadata-{current_date}.zip"
+
+ # Pre-set zip file name
+ encrypted_metadata_output = current_filename
+
+ # Set context for the subject
+ current_date_str = datetime.now().strftime("%Y-%m-%d")
+
+ # Encrypt the metadata
+ encrypted_metadata_in_bytes = self._encrypt_metadata(
+ s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
+ )
+
+ # Send the metadata file that is zipped
+ send_templated_email(
+ template_name="emails/metadata_body.txt",
+ subject_template_name="emails/metadata_subject.txt",
+ to_address=settings.DEFAULT_FROM_EMAIL,
+ context={"current_date_str": current_date_str},
+ attachment_file=encrypted_metadata_in_bytes,
+ )
+
+ def _encrypt_metadata(self, input_file, output_file, password):
+ """Helper function for encrypting the attachment file"""
+ current_date = datetime.now().strftime("%m%d%Y")
+ current_filename = f"domain-metadata-{current_date}.csv"
+ # Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
+ # We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
+ with pyzipper.AESZipFile(
+ output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
+ ) as f_out:
+ f_out.setpassword(password)
+ f_out.writestr(current_filename, input_file)
+ with open(output_file, "rb") as file_data:
+ attachment_in_bytes = file_data.read()
+ return attachment_in_bytes
diff --git a/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py b/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py
new file mode 100644
index 000000000..b536f87c1
--- /dev/null
+++ b/src/registrar/migrations/0076_alter_domainrequest_current_websites_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.2.10 on 2024-03-13 21:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0075_create_groups_v08"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="current_websites",
+ field=models.ManyToManyField(
+ blank=True, related_name="current+", to="registrar.website", verbose_name="Current websites"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="other_contacts",
+ field=models.ManyToManyField(
+ blank=True,
+ related_name="contact_domain_requests",
+ to="registrar.contact",
+ verbose_name="Other employees",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="other_contacts",
+ field=models.ManyToManyField(
+ blank=True,
+ related_name="contact_domain_requests_information",
+ to="registrar.contact",
+ verbose_name="Other employees",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 079fce3bc..8fc697df5 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -198,6 +198,7 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError"""
+
if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index 77a072ae9..f8f4db9c6 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -183,7 +183,7 @@ class DomainInformation(TimeStampedModel):
"registrar.Contact",
blank=True,
related_name="contact_domain_requests_information",
- verbose_name="contacts",
+ verbose_name="Other employees",
)
no_other_contacts_rationale = models.TextField(
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 0ec285648..27c32a85b 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -505,7 +505,7 @@ class DomainRequest(TimeStampedModel):
"registrar.Website",
blank=True,
related_name="current+",
- verbose_name="websites",
+ verbose_name="Current websites",
)
approved_domain = models.OneToOneField(
@@ -551,7 +551,7 @@ class DomainRequest(TimeStampedModel):
"registrar.Contact",
blank=True,
related_name="contact_domain_requests",
- verbose_name="contacts",
+ verbose_name="Other employees",
)
no_other_contacts_rationale = models.TextField(
diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py
index 27a78403c..87a885309 100644
--- a/src/registrar/models/utility/domain_helper.py
+++ b/src/registrar/models/utility/domain_helper.py
@@ -188,3 +188,33 @@ class DomainHelper:
common_fields = model_1_fields & model_2_fields
return common_fields
+
+ @staticmethod
+ def mass_disable_fields(fields, disable_required=False, disable_maxlength=False):
+ """
+ Given some fields, invoke .disabled = True on them.
+ disable_required: bool -> invokes .required = False on each field.
+ disable_maxlength: bool -> pops "maxlength" from each field.
+ """
+ for field in fields.values():
+ field = DomainHelper.disable_field(field, disable_required, disable_maxlength)
+ return fields
+
+ @staticmethod
+ def disable_field(field, disable_required=False, disable_maxlength=False):
+ """
+ Given a fields, invoke .disabled = True on it.
+ disable_required: bool -> invokes .required = False for the field.
+ disable_maxlength: bool -> pops "maxlength" for the field.
+ """
+ field.disabled = True
+
+ if disable_required:
+ # if a field is disabled, it can't be required
+ field.required = False
+
+ if disable_maxlength:
+ # Remove the maxlength dialog
+ if "maxlength" in field.widget.attrs:
+ field.widget.attrs.pop("maxlength", None)
+ return field
diff --git a/src/registrar/public/img/registrar/dotgov_401_illo.svg b/src/registrar/public/img/registrar/dotgov_401_illo.svg
deleted file mode 100644
index 71de33eaa..000000000
--- a/src/registrar/public/img/registrar/dotgov_401_illo.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
diff --git a/src/registrar/public/img/registrar/dotgov_404_illo.svg b/src/registrar/public/img/registrar/dotgov_404_illo.svg
deleted file mode 100644
index 3c9adab7e..000000000
--- a/src/registrar/public/img/registrar/dotgov_404_illo.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
diff --git a/src/registrar/public/img/registrar/dotgov_500_illo.svg b/src/registrar/public/img/registrar/dotgov_500_illo.svg
deleted file mode 100644
index 6dd538644..000000000
--- a/src/registrar/public/img/registrar/dotgov_500_illo.svg
+++ /dev/null
@@ -1,59 +0,0 @@
-
diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html
new file mode 100644
index 000000000..e73f22ec5
--- /dev/null
+++ b/src/registrar/templates/admin/analytics.html
@@ -0,0 +1,196 @@
+{% extends "admin/base_site.html" %}
+{% load static %}
+
+{% block content_title %}
Registrar Analytics
{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
At a glance
+
+
+
User Count: {{ data.user_count }}
+
Domain Count: {{ data.domain_count }}
+
Domains in READY state: {{ data.ready_domain_count }}
+ {% comment %}
+ Inputs of type date suck for accessibility.
+ We'll need to replace those guys with a django form once we figure out how to hook one onto this page.
+ See the commit "Review for ticket #999"
+ {% endcomment %}
+
- {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
-
-
Reports
-
Domain growth report
-
- {% comment %}
- Inputs of type date suck for accessibility.
- We'll need to replace those guys with a django form once we figure out how to hook one onto this page.
- The challenge is in the path definition in urls. Itdoes NOT like admin/export_data/
-
- See the commit "Review for ticket #999"
- {% endcomment %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/django/admin/domain_application_change_form.html b/src/registrar/templates/django/admin/domain_application_change_form.html
new file mode 100644
index 000000000..95392da1e
--- /dev/null
+++ b/src/registrar/templates/django/admin/domain_application_change_form.html
@@ -0,0 +1,96 @@
+{% extends 'admin/change_form.html' %}
+{% load i18n static %}
+
+{% block field_sets %}
+ {# Create an invisible tag so that we can use a click event to toggle the modal. #}
+
+ {{ block.super }}
+{% endblock %}
+
+{% block submit_buttons_bottom %}
+ {% comment %}
+ Modals behave very weirdly in django admin.
+ They tend to "strip out" any injected form elements, leaving only the main form.
+ In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
+ In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
+
+ The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
+ element to the click of the confirmation button within this modal.
+
+ This is controlled by the class `dja-form-placeholder` on the button.
+
+ In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
+ of the application, so this means that it will briefly "populate", causing unintended visual effects.
+ {% endcomment %}
+ {# Create a modal for when a domain is marked as ineligible #}
+
+
+
+
+ Are you sure you want to select ineligible status?
+
+
+
+ When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows:
+
+
+
They cannot edit the ineligible request or any other pending requests.
+
They cannot manage any of their approved domains.
+
They cannot initiate a new domain request.
+
+
+ The restrictions will not take effect until you “save” the changes for this domain request.
+ This action can be reversed, if needed.
+
+
+ Domain: {{ original.requested_domain.name }}
+ {# Acts as a #}
+
+ New status: {{ original.DomainRequestStatus.INELIGIBLE|capfirst }}
+
+
+
+
+
+
+
+
+{{ block.super }}
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html
index 67c5ac291..44fe6851b 100644
--- a/src/registrar/templates/django/admin/domain_change_form.html
+++ b/src/registrar/templates/django/admin/domain_change_form.html
@@ -11,18 +11,15 @@
{% if original.state != original.State.DELETED %}
-
+
Extend expiration date
|
{% endif %}
{% if original.state == original.State.READY %}
-
+
+ Place hold
+
{% elif original.state == original.State.ON_HOLD %}
{% endif %}
@@ -30,7 +27,9 @@
|
{% endif %}
{% if original.state != original.State.DELETED %}
-
+
+ Remove from registry
+
{% endif %}
@@ -52,8 +51,10 @@
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
+
+ {# Create a modal for the _extend_expiration_date button #}
-
+
+
+ {# Create a modal for the _on_hold button #}
+
+
+
+
+ Are you sure you want to place this domain on hold?
+
+
+
+ When a domain is on hold:
+
+
+
The domain and its subdomains won’t resolve in DNS. Any infrastructure (like websites) will go offline.
+
The domain will still appear in the registrar / admin.
+
Domain managers won’t be able to edit the domain.
+
+
+ This action can be reversed, if needed.
+
+
+ Domain: {{ original.name }}
+ {# Acts as a #}
+
+ New status: {{ original.State.ON_HOLD|capfirst }}
+
+
+
+
+
+
+
+
+ {# Create a modal for the _remove_domain button #}
+
+
+
+
+ Are you sure you want to remove this domain from the registry?
+
+
+
+ When a domain is removed from the registry:
+
+
+
The domain and its subdomains won’t resolve in DNS. Any infrastructure (like websites) will go offline.
+
The domain will still appear in the registrar / admin.
+
Domain managers won’t be able to edit the domain.
+
+
+ This action cannot be undone.
+
+
+ Domain: {{ original.name }}
+ {# Acts as a #}
+
+ New status: {{ original.State.DELETED|capfirst }}
+
+
+
+
+
+
+
+
{{ block.super }}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/src/registrar/templates/django/admin/domain_change_list.html b/src/registrar/templates/django/admin/domain_change_list.html
deleted file mode 100644
index 22df74685..000000000
--- a/src/registrar/templates/django/admin/domain_change_list.html
+++ /dev/null
@@ -1,23 +0,0 @@
-{% extends "admin/change_list.html" %}
-
-{% block object-tools %}
-
-
The domain will no longer appear in the registrar / admin.
+
It will be removed from the registry.
+
The domain and its subdomains won’t resolve in DNS.
+
Any infrastructure (like websites) will go offline.
+
+
You should probably remove these domains from the registry instead.
+
This action cannot be undone.
+
+
+
+ {{ block.super }}
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/domain_authorizing_official.html b/src/registrar/templates/domain_authorizing_official.html
index e7fc12a5e..2e2faa0d3 100644
--- a/src/registrar/templates/domain_authorizing_official.html
+++ b/src/registrar/templates/domain_authorizing_official.html
@@ -11,12 +11,28 @@
Your authorizing official is a person within your organization who can
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about who can serve as an authorizing official.
-
+
+ {% if organization_type == "federal" or organization_type == "tribal" %}
+
+ The authorizing official for your organization can’t be updated here.
+ To suggest an update, email help@get.gov.
+