Merge branch 'main' into za/1848-copy-contact-email-to-clipboard

This commit is contained in:
zandercymatics 2024-03-19 09:26:10 -06:00
commit a5d8a3d3bf
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
53 changed files with 1841 additions and 189 deletions

View file

@ -31,3 +31,12 @@ jobs:
cf_space: ${{ secrets.CF_REPORT_ENV }} 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" 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"

View file

@ -22,6 +22,8 @@ jobs:
|| startsWith(github.head_ref, 'es/') || startsWith(github.head_ref, 'es/')
|| startsWith(github.head_ref, 'ky/') || startsWith(github.head_ref, 'ky/')
|| startsWith(github.head_ref, 'backup/') || startsWith(github.head_ref, 'backup/')
|| startsWith(github.head_ref, 'meoward/')
|| startsWith(github.head_ref, 'bob/')
outputs: outputs:
environment: ${{ steps.var.outputs.environment}} environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"

View file

@ -16,6 +16,8 @@ on:
- stable - stable
- staging - staging
- development - development
- bob
- meoward
- backup - backup
- ky - ky
- es - es

View file

@ -16,6 +16,8 @@ on:
options: options:
- staging - staging
- development - development
- bob
- meoward
- backup - backup
- ky - ky
- es - es

View file

@ -330,9 +330,10 @@ To associate a S3 instance to your sandbox, follow these steps:
3. Click `Services` on the application nav bar 3. Click `Services` on the application nav bar
4. Add a new service (plus symbol) 4. Add a new service (plus symbol)
5. Click `Marketplace Service` 5. Click `Marketplace Service`
6. On the `Select the service` dropdown, select `s3` 6. For Space, put in your sandbox initials
7. Under the dropdown on `Select Plan`, select `basic-sandbox` 7. On the `Select the service` dropdown, select `s3`
8. Under `Service Instance` enter `getgov-s3` for the name 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.

View file

@ -117,3 +117,11 @@ You'll need to give the new certificate to the registry vendor _before_ rotating
## REGISTRY_HOSTNAME ## REGISTRY_HOSTNAME
This is the hostname at which the registry can be found. 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.

View file

@ -2,8 +2,8 @@
======================== ========================
1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers 1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers
2. Run `docker-compose stop` to spin down the current containers and images so we can start afresh
2. Run 3. Run
cd src cd src
docker-compose run app bash -c "pipenv lock && pipenv requirements > requirements.txt" 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. 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. 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. 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. 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. 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.

View file

@ -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

View file

@ -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

View file

@ -29,6 +29,7 @@ django-login-required-middleware = "*"
greenlet = "*" greenlet = "*"
gevent = "*" gevent = "*"
fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"} fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
pyzipper="*"
tblib = "*" tblib = "*"
[dev-packages] [dev-packages]

65
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "b5d93b1b9ccafc37019276a222957544bab3f1f46b5dab8a0f2ffc2e5c9e1678" "sha256": "082a951f15bb26a28f2dca7e0840fdf61518b3d90c42d77a310f982344cbd1dc"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -32,20 +32,20 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8", "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714",
"sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c" "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa", "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f",
"sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5" "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"cachetools": { "cachetools": {
"hashes": [ "hashes": [
@ -376,20 +376,20 @@
"django" "django"
], ],
"hashes": [ "hashes": [
"sha256:cc421ddb143fa30183568164755aa113a160e555cd19e97e664c478662032c24", "sha256:069727a8f73d8ba8d033d3cd95c0da231d44f38f1da773bf076cef168d312ee8",
"sha256:feeaf28f17fd0499f9cd7c0fcf408c6d82c308e69e335eb92d09322fc9ed8138" "sha256:e0bcfd41c718c07a7db422f9109e490746450da38793fe4ee197f397b9343435"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==10.3.0" "version": "==11.0.0"
}, },
"faker": { "faker": {
"hashes": [ "hashes": [
"sha256:117ce1a2805c1bc5ca753b3dc6f9d567732893b2294b827d3164261ee8f20267", "sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1",
"sha256:458d93580de34403a8dec1e8d5e6be2fee96c4deca63b95d71df7a6a80a690de" "sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==23.3.0" "version": "==24.0.0"
}, },
"fred-epplib": { "fred-epplib": {
"git": "https://github.com/cisagov/epplib.git", "git": "https://github.com/cisagov/epplib.git",
@ -708,11 +708,11 @@
}, },
"marshmallow": { "marshmallow": {
"hashes": [ "hashes": [
"sha256:20f53be28c6e374a711a16165fb22a8dc6003e3f7cda1285e3ca777b9193885b", "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3",
"sha256:e7997f83571c7fd476042c2c188e4ee8a78900ca5e74bd9c8097afa56624e9bd" "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.21.0" "version": "==3.21.1"
}, },
"oic": { "oic": {
"hashes": [ "hashes": [
@ -994,6 +994,15 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.0.1" "version": "==1.0.1"
}, },
"pyzipper": {
"hashes": [
"sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc",
"sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87"
],
"index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==0.3.6"
},
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
@ -1186,12 +1195,12 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:8b3f5cc7fbedcbb22271c328039df8a6ab343001e746e0cdb24774c426cadcf8", "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714",
"sha256:f201b6a416f809283d554c652211eecec9fe3a52ed4063dab3f3e7aea7571d9c" "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"boto3-mocking": { "boto3-mocking": {
"hashes": [ "hashes": [
@ -1204,28 +1213,28 @@
}, },
"boto3-stubs": { "boto3-stubs": {
"hashes": [ "hashes": [
"sha256:7db5194e47f76e0010cd00b6ad9725db114d6a3fd04e52ceed3ef1181fe326bc", "sha256:627f8eca69d832581ee1676d39df099a2a2e3a86d6b3ebd21c81c5f11ed6a6fa",
"sha256:c7b2e8b99f4896cf1226df47d4badaaa8df7426008c96a428bf00205695669e9" "sha256:a87e7ecbab6235ec371b4363027e57483bca349a9cd5c891f40db81dadfa273e"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:4061ff4be3efcf53547ebadf2c94d419dfc8be7beec24e9fa1819599ffd936fa", "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f",
"sha256:bf215d93e9d5544c593962780d194e74c6ee40b883d0b885e62ef35fc0ec01e5" "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"botocore-stubs": { "botocore-stubs": {
"hashes": [ "hashes": [
"sha256:958f0084322dc9e549f73151b686fa51b15858fb2b3a573b9f4367f073fff463", "sha256:018e001e3add5eb1828ef444b45fb8c9faf695e08334031bf2d96853cd9af703",
"sha256:bcc35bfbd14d1261813681c40108f2ce85fdf082c15b0a04016d3c22dd93b73f" "sha256:25468ba6983987b704b1856bb155f297f576e6d4a690b021ab0c7122889ba907"
], ],
"markers": "python_version >= '3.8' and python_version < '4.0'", "markers": "python_version >= '3.8' and python_version < '4.0'",
"version": "==1.34.54" "version": "==1.34.56"
}, },
"click": { "click": {
"hashes": [ "hashes": [

View file

@ -49,3 +49,17 @@ def less_console_noise():
handler.setStream(restore[handler.name]) handler.setStream(restore[handler.name])
# close the file we opened # close the file we opened
devnull.close() 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

View file

@ -6,12 +6,13 @@ from django.conf import settings
from django.contrib.auth import logout as auth_logout from django.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate, login
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client from djangooidc.oidc import Client
from djangooidc import exceptions as o_e from djangooidc import exceptions as o_e
from registrar.models import User from registrar.models import User
from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -49,27 +50,19 @@ def error_page(request, error):
"""Display a sensible message and log the error.""" """Display a sensible message and log the error."""
logger.error(error) logger.error(error)
if isinstance(error, o_e.AuthenticationFailed): if isinstance(error, o_e.AuthenticationFailed):
return render( context = {
request, "friendly_message": error.friendly_message,
"401.html", "log_identifier": error.locator,
context={ }
"friendly_message": error.friendly_message, return custom_401_error_view(request, context)
"log_identifier": error.locator,
},
status=401,
)
if isinstance(error, o_e.InternalError): if isinstance(error, o_e.InternalError):
return render( context = {
request, "friendly_message": error.friendly_message,
"500.html", "log_identifier": error.locator,
context={ }
"friendly_message": error.friendly_message, return custom_500_error_view(request, context)
"log_identifier": error.locator,
},
status=500,
)
if isinstance(error, Exception): if isinstance(error, Exception):
return render(request, "500.html", status=500) return custom_500_error_view(request)
def openid(request): def openid(request):

View file

@ -58,6 +58,8 @@ services:
- AWS_S3_SECRET_ACCESS_KEY - AWS_S3_SECRET_ACCESS_KEY
- AWS_S3_REGION - AWS_S3_REGION
- AWS_S3_BUCKET_NAME - AWS_S3_BUCKET_NAME
# File encryption credentials
- SECRET_ENCRYPT_METADATA
stdin_open: true stdin_open: true
tty: true tty: true
ports: ports:

View file

@ -1,6 +1,7 @@
"""Provide a wrapper around epplib to handle authentication and errors.""" """Provide a wrapper around epplib to handle authentication and errors."""
import logging import logging
from gevent.lock import BoundedSemaphore
try: try:
from epplib.client import Client from epplib.client import Client
@ -52,10 +53,16 @@ class EPPLibWrapper:
"urn:ietf:params:xml:ns:contact-1.0", "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: try:
self._initialize_client() self._initialize_client()
except Exception: 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: def _initialize_client(self) -> None:
"""Initialize a client, assuming _login defined. Sets _client to initialized """Initialize a client, assuming _login defined. Sets _client to initialized
@ -74,11 +81,7 @@ class EPPLibWrapper:
) )
try: try:
# use the _client object to connect # use the _client object to connect
self._client.connect() # type: ignore self._connect()
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
except TransportError as err: except TransportError as err:
message = "_initialize_client failed to execute due to a connection error." message = "_initialize_client failed to execute due to a connection error."
logger.error(f"{message} Error: {err}") logger.error(f"{message} Error: {err}")
@ -90,13 +93,33 @@ class EPPLibWrapper:
logger.error(f"{message} Error: {err}") logger.error(f"{message} Error: {err}")
raise RegistryError(message) from 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: 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: try:
self._client.send(commands.Logout()) # type: ignore self._client.send(commands.Logout()) # type: ignore
self._client.close() # type: ignore except Exception as err:
except Exception: logger.warning(f"Logout command not sent successfully: {err}")
logger.warning("Connection to registry was not cleanly closed.")
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): def _send(self, command):
"""Helper function used by `send`.""" """Helper function used by `send`."""
@ -146,6 +169,8 @@ class EPPLibWrapper:
cmd_type = command.__class__.__name__ cmd_type = command.__class__.__name__
if not cleaned: if not cleaned:
raise ValueError("Please sanitize user input before sending it.") raise ValueError("Please sanitize user input before sending it.")
self.connection_lock.acquire()
try: try:
return self._send(command) return self._send(command)
except RegistryError as err: except RegistryError as err:
@ -161,6 +186,8 @@ class EPPLibWrapper:
return self._retry(command) return self._retry(command)
else: else:
raise err raise err
finally:
self.connection_lock.release()
try: try:

View file

@ -1,5 +1,9 @@
import datetime
from dateutil.tz import tzlocal # type: ignore
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pathlib import Path
from django.test import TestCase from django.test import TestCase
from gevent.exceptions import ConcurrentObjectUseError
from epplibwrapper.client import EPPLibWrapper from epplibwrapper.client import EPPLibWrapper
from epplibwrapper.errors import RegistryError, LoginError from epplibwrapper.errors import RegistryError, LoginError
from .common import less_console_noise from .common import less_console_noise
@ -8,6 +12,9 @@ import logging
try: try:
from epplib.exceptions import TransportError from epplib.exceptions import TransportError
from epplib.responses import Result from epplib.responses import Result
from epplib.transport import SocketTransport
from epplib import commands
from epplib.models import common, info
except ImportError: except ImportError:
pass pass
@ -255,3 +262,116 @@ class TestClient(TestCase):
mock_close.assert_called_once() mock_close.assert_called_once()
# send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command) # send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command)
self.assertEquals(mock_send.call_count, 5) 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__)

View file

@ -1,5 +1,6 @@
from datetime import date from datetime import date
import logging import logging
import copy
from django import forms from django import forms
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
@ -865,18 +866,21 @@ class DomainInformationAdmin(ListHeaderAdmin):
search_help_text = "Search by domain." search_help_text = "Search by domain."
fieldsets = [ 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", "Type of organization",
{ {
"fields": [ "fields": [
"organization_type", "organization_type",
"is_election_board",
"federal_type",
"federal_agency",
"tribe_name",
"federally_recognized_tribe", "federally_recognized_tribe",
"state_recognized_tribe", "state_recognized_tribe",
"tribe_name",
"federal_agency",
"federal_type",
"is_election_board",
"about_your_organization", "about_your_organization",
] ]
}, },
@ -886,28 +890,15 @@ class DomainInformationAdmin(ListHeaderAdmin):
{ {
"fields": [ "fields": [
"organization_name", "organization_name",
"state_territory",
"address_line1", "address_line1",
"address_line2", "address_line2",
"city", "city",
"state_territory",
"zipcode", "zipcode",
"urbanization", "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 # Read only that we'll leverage for CISA Analysts
@ -1019,6 +1010,8 @@ class DomainRequestAdmin(ListHeaderAdmin):
if self.value() == "0": if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
change_form_template = "django/admin/domain_application_change_form.html"
# Columns # Columns
list_display = [ list_display = [
"requested_domain", "requested_domain",
@ -1067,18 +1060,34 @@ class DomainRequestAdmin(ListHeaderAdmin):
search_help_text = "Search by domain or submitter." search_help_text = "Search by domain or submitter."
fieldsets = [ 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", "Type of organization",
{ {
"fields": [ "fields": [
"organization_type", "organization_type",
"is_election_board",
"federal_type",
"federal_agency",
"tribe_name",
"federally_recognized_tribe", "federally_recognized_tribe",
"state_recognized_tribe", "state_recognized_tribe",
"tribe_name",
"federal_agency",
"federal_type",
"is_election_board",
"about_your_organization", "about_your_organization",
] ]
}, },
@ -1088,30 +1097,15 @@ class DomainRequestAdmin(ListHeaderAdmin):
{ {
"fields": [ "fields": [
"organization_name", "organization_name",
"state_territory",
"address_line1", "address_line1",
"address_line2", "address_line2",
"city", "city",
"state_territory",
"zipcode", "zipcode",
"urbanization", "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 # Read only that we'll leverage for CISA Analysts
@ -1345,7 +1339,13 @@ class DomainInformationInline(admin.StackedInline):
model = models.DomainInformation 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 analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
# to activate the edit/delete/view buttons # to activate the edit/delete/view buttons
@ -1488,6 +1488,20 @@ class DomainAdmin(ListHeaderAdmin):
# Table ordering # Table ordering
ordering = ["name"] 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): def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
"""Custom changeform implementation to pass in context information""" """Custom changeform implementation to pass in context information"""
if extra_context is None: if extra_context is None:
@ -1833,9 +1847,6 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at") list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"] search_fields = ["email"]
search_help_text = "Search by email." search_help_text = "Search by email."
list_filter = [
"requestor",
]
readonly_fields = [ readonly_fields = [
"requestor", "requestor",
] ]

View file

@ -29,20 +29,26 @@ function openInNewTab(el, removeAttribute = false){
*/ */
(function (){ (function (){
function createPhantomModalFormButtons(){ 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") form = document.querySelector("form")
submitButtons.forEach((button) => { submitButtons.forEach((button) => {
let input = document.createElement("input"); let input = document.createElement("input");
input.type = "submit"; 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" input.style.display = "none"
// Add the hidden input to the form // Add the hidden input to the form
form.appendChild(input); form.appendChild(input);
button.addEventListener("click", () => { button.addEventListener("click", () => {
console.log("clicking")
input.click(); input.click();
}) })
}) })
@ -50,6 +56,61 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons(); 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. /** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object, * Currently only appends target="_blank" to the domain_form object,
* but this can be expanded. * but this can be expanded.

View file

@ -143,6 +143,10 @@ h1, h2, h3,
font-weight: font-weight('bold'); font-weight: font-weight('bold');
} }
div#content > h2 {
font-size: 1.3rem;
}
.module h3 { .module h3 {
padding: 0; padding: 0;
color: var(--link-fg); color: var(--link-fg);
@ -300,6 +304,19 @@ input.admin-confirm-button {
} }
} }
.django-admin-modal .usa-prose ul > li {
list-style-type: inherit;
// Styling based off of the <p> 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;
}
.admin-icon-group { .admin-icon-group {
position: relative; position: relative;
display: flex; display: flex;

View file

@ -38,3 +38,18 @@ legend.float-left-tablet + button.float-right-tablet {
margin-top: 1rem; 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);
}
}

View file

@ -126,7 +126,6 @@ in the form $setting: value,
----------------------------*/ ----------------------------*/
$theme-input-line-height: 5, $theme-input-line-height: 5,
/*--------------------------- /*---------------------------
# Component settings # Component settings
----------------------------- -----------------------------

View file

@ -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_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) 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_cl_id = secret("REGISTRY_CL_ID")
secret_registry_password = secret("REGISTRY_PASSWORD") secret_registry_password = secret("REGISTRY_PASSWORD")
secret_registry_cert = b64decode(secret("REGISTRY_CERT", "")) secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
@ -94,6 +97,7 @@ DEBUG = env_debug
# Controls production specific feature toggles # Controls production specific feature toggles
IS_PRODUCTION = env_is_production IS_PRODUCTION = env_is_production
SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
# Applications are modular pieces of code. # Applications are modular pieces of code.
# They are provided by Django, by third-parties, or by yourself. # They are provided by Django, by third-parties, or by yourself.
@ -635,6 +639,8 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov", "getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov", "getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov", "getgov-development.app.cloud.gov",
"getgov-bob.app.cloud.gov",
"getgov-meoward.app.cloud.gov",
"getgov-backup.app.cloud.gov", "getgov-backup.app.cloud.gov",
"getgov-ky.app.cloud.gov", "getgov-ky.app.cloud.gov",
"getgov-es.app.cloud.gov", "getgov-es.app.cloud.gov",

View file

@ -149,6 +149,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 # 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 # 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 # was actually True. Instead, let's add these URLs any time we are able to

View file

@ -163,6 +163,12 @@ class UserFixture:
"last_name": "Chin-Analyst", "last_name": "Chin-Analyst",
"email": "szu.chin@ecstech.com", "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): def load_users(cls, users, group_name):

View file

@ -1,10 +1,12 @@
"""Forms for domain management.""" """Forms for domain management."""
import logging
from django import forms from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.forms import formset_factory from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import ( from registrar.utility.errors import (
NameserverError, NameserverError,
NameserverErrorCodes as nsErrorCodes, NameserverErrorCodes as nsErrorCodes,
@ -23,6 +25,9 @@ from .common import (
import re import re
logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form): class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain.""" """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." "required": "Enter your email address in the required format, like name@example.com."
} }
self.fields["phone"].error_messages["required"] = "Enter your phone number." 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): class AuthorizingOfficialContactForm(ContactForm):
@ -212,7 +224,7 @@ class AuthorizingOfficialContactForm(ContactForm):
JOIN = "authorizing_official" JOIN = "authorizing_official"
def __init__(self, *args, **kwargs): def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Overriding bc phone not required in this form # Overriding bc phone not required in this form
@ -232,20 +244,36 @@ class AuthorizingOfficialContactForm(ContactForm):
self.fields["email"].error_messages = { self.fields["email"].error_messages = {
"required": "Enter an email address in the required format, like name@example.com." "required": "Enter an email address in the required format, like name@example.com."
} }
self.domainInfo = None
def set_domain_info(self, domainInfo): # All fields should be disabled if the domain is federal or tribal
"""Set the domain information for the form. if disable_fields:
The form instance is associated with the contact itself. In order to access the associated DomainHelper.mass_disable_fields(fields=self.fields, disable_required=True, disable_maxlength=True)
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
def save(self, commit=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 # Get the Contact object from the db for the Authorizing Official
db_ao = Contact.objects.get(id=self.instance.id) 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 # Handle the case where the domain information object is available and the AO Contact
# has more than one joined object. # has more than one joined object.
# In this case, create a new Contact, and update the new Contact with form data. # In this case, create a new Contact, and update the new Contact with form data.
@ -254,6 +282,7 @@ class AuthorizingOfficialContactForm(ContactForm):
self.domainInfo.authorizing_official = Contact.objects.create(**data) self.domainInfo.authorizing_official = Contact.objects.create(**data)
self.domainInfo.save() self.domainInfo.save()
else: else:
# If all checks pass, just save normally
super().save() super().save()
@ -304,11 +333,11 @@ class DomainOrgNameAddressForm(forms.ModelForm):
}, },
} }
widgets = { widgets = {
# We need to set the required attributed for federal_agency and # We need to set the required attributed for State/territory
# state/territory because for these fields we are creating an individual # because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set # instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true. # 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, "organization_name": forms.TextInput,
"address_line1": forms.TextInput, "address_line1": forms.TextInput,
"address_line2": 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["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].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): class DomainDnssecForm(forms.Form):
"""Form for enabling and disabling dnssec""" """Form for enabling and disabling dnssec"""

View file

@ -319,8 +319,8 @@ class AboutYourOrganizationForm(RegistrarForm):
widget=forms.Textarea(), widget=forms.Textarea(),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
1000, 2000,
message="Response must be less than 1000 characters.", message="Response must be less than 2000 characters.",
) )
], ],
error_messages={"required": ("Enter more information about your organization.")}, error_messages={"required": ("Enter more information about your organization.")},
@ -515,8 +515,8 @@ class PurposeForm(RegistrarForm):
widget=forms.Textarea(), widget=forms.Textarea(),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
1000, 2000,
message="Response must be less than 1000 characters.", message="Response must be less than 2000 characters.",
) )
], ],
error_messages={"required": "Describe how youll use the .gov domain youre requesting."}, error_messages={"required": "Describe how youll use the .gov domain youre requesting."},
@ -830,8 +830,8 @@ class AnythingElseForm(RegistrarForm):
widget=forms.Textarea(), widget=forms.Textarea(),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
1000, 2000,
message="Response must be less than 1000 characters.", message="Response must be less than 2000 characters.",
) )
], ],
) )

View file

@ -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

View file

@ -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",
),
),
]

View file

@ -198,6 +198,7 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError""" throws- RegistryError or InvalidDomainError"""
if not cls.string_could_be_domain(domain): if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain)) logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in # throw invalid domain error so that it can be caught in

View file

@ -183,7 +183,7 @@ class DomainInformation(TimeStampedModel):
"registrar.Contact", "registrar.Contact",
blank=True, blank=True,
related_name="contact_domain_requests_information", related_name="contact_domain_requests_information",
verbose_name="contacts", verbose_name="Other employees",
) )
no_other_contacts_rationale = models.TextField( no_other_contacts_rationale = models.TextField(

View file

@ -505,7 +505,7 @@ class DomainRequest(TimeStampedModel):
"registrar.Website", "registrar.Website",
blank=True, blank=True,
related_name="current+", related_name="current+",
verbose_name="websites", verbose_name="Current websites",
) )
approved_domain = models.OneToOneField( approved_domain = models.OneToOneField(
@ -551,7 +551,7 @@ class DomainRequest(TimeStampedModel):
"registrar.Contact", "registrar.Contact",
blank=True, blank=True,
related_name="contact_domain_requests", related_name="contact_domain_requests",
verbose_name="contacts", verbose_name="Other employees",
) )
no_other_contacts_rationale = models.TextField( no_other_contacts_rationale = models.TextField(

View file

@ -188,3 +188,33 @@ class DomainHelper:
common_fields = model_1_fields & model_2_fields common_fields = model_1_fields & model_2_fields
return common_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

View file

@ -0,0 +1,96 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
{{ 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 #}
<div
class="usa-modal"
id="toggle-set-ineligible"
aria-labelledby="Are you sure you want to select ineligible status?"
aria-describedby="This request will be marked as ineligible."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">
<p>
When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows:
</p>
<ul>
<li class="font-body-sm">They cannot edit the ineligible request or any other pending requests.</li>
<li class="font-body-sm">They cannot manage any of their approved domains.</li>
<li class="font-body-sm">They cannot initiate a new domain request.</li>
</ul>
<p>
The restrictions will not take effect until you “save” the changes for this domain request.
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.requested_domain.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.DomainRequestStatus.INELIGIBLE|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button"
name="_set_domain_request_ineligible"
data-close-modal
>
Yes, select ineligible status
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_domain_request_ineligible"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -11,18 +11,15 @@
</div> </div>
<div class="desktop:flex-align-self-end"> <div class="desktop:flex-align-self-end">
{% if original.state != original.State.DELETED %} {% if original.state != original.State.DELETED %}
<a <a class="text-middle" href="#toggle-extend-expiration-alert" aria-controls="toggle-extend-expiration-alert" data-open-modal>
class="text-middle"
href="#toggle-extend-expiration-alert"
aria-controls="toggle-extend-expiration-alert"
data-open-modal
>
Extend expiration date Extend expiration date
</a> </a>
<span class="margin-left-05 margin-right-05 text-middle"> | </span> <span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %} {% endif %}
{% if original.state == original.State.READY %} {% if original.state == original.State.READY %}
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button"> <a class="text-middle" href="#toggle-place-on-hold" aria-controls="toggle-place-on-hold" data-open-modal>
Place hold
</a>
{% elif original.state == original.State.ON_HOLD %} {% elif original.state == original.State.ON_HOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button"> <input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
{% endif %} {% endif %}
@ -30,7 +27,9 @@
<span class="margin-left-05 margin-right-05 text-middle"> | </span> <span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %} {% endif %}
{% if original.state != original.State.DELETED %} {% if original.state != original.State.DELETED %}
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button"> <a class="text-middle" href="#toggle-remove-from-registry" aria-controls="toggle-remove-from-registry" data-open-modal>
Remove from registry
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -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 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. of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %} {% endcomment %}
{# Create a modal for the _extend_expiration_date button #}
<div <div
class="usa-modal" class="usa-modal django-admin-modal"
id="toggle-extend-expiration-alert" id="toggle-extend-expiration-alert"
aria-labelledby="Are you sure you want to extend the expiration date?" aria-labelledby="Are you sure you want to extend the expiration date?"
aria-describedby="This expiration date will be extended." aria-describedby="This expiration date will be extended."
@ -114,5 +115,140 @@
</button> </button>
</div> </div>
</div> </div>
{# Create a modal for the _on_hold button #}
<div
class="usa-modal django-admin-modal"
id="toggle-place-on-hold"
aria-labelledby="Are you sure you want to place this domain on hold?"
aria-describedby="This domain will be put on hold"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to place this domain on hold?
</h2>
<div class="usa-prose">
<p>
When a domain is on hold:
</p>
<ul>
<li class="font-body-sm">The domain and its subdomains wont resolve in DNS. Any infrastructure (like websites) will go offline.</li>
<li class="font-body-sm">The domain will still appear in the registrar / admin.</li>
<li class="font-body-sm">Domain managers wont be able to edit the domain.</li>
</ul>
<p>
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.State.ON_HOLD|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button dja-form-placeholder"
name="_place_client_hold"
>
Yes, place hold
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{# Create a modal for the _remove_domain button #}
<div
class="usa-modal django-admin-modal"
id="toggle-remove-from-registry"
aria-labelledby="Are you sure you want to remove this domain from the registry?"
aria-describedby="This domain will be removed."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to remove this domain from the registry?
</h2>
<div class="usa-prose">
<p>
When a domain is removed from the registry:
</p>
<ul>
<li class="font-body-sm">The domain and its subdomains wont resolve in DNS. Any infrastructure (like websites) will go offline.</li>
<li class="font-body-sm">The domain will still appear in the registrar / admin.</li>
<li class="font-body-sm">Domain managers wont be able to edit the domain.</li>
</ul>
<p>
This action cannot be undone.
</p>
<p>
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.State.DELETED|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button dja-form-placeholder"
name="_delete_domain"
>
Yes, remove from registry
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,27 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content %}
<div
class="usa-summary-box width-tablet"
role="region"
aria-labelledby="summary-box-description"
>
<div class="usa-summary-box__body">
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
When a domain is deleted:
</h3>
<div class="usa-summary-box__text">
<ul class="usa-list">
<li>The domain will no longer appear in the registrar / admin.</li>
<li>It will be removed from the registry. </li>
<li>The domain and its subdomains wont resolve in DNS.</li>
<li>Any infrastructure (like websites) will go offline.</li>
</ul>
<p>You should probably remove this domain from the registry instead of deleting it.</p>
<p><strong>This action cannot be undone.</strong></p>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content %}
<div
class="usa-summary-box width-tablet"
role="region"
aria-labelledby="summary-box-description"
>
<div class="usa-summary-box__body">
<h3 class="usa-summary-box__heading usa-summary-box__dhs-color" id="summary-box-description">
When a domain is deleted:
</h3>
<div class="usa-summary-box__text">
<ul class="usa-list">
<li>The domain will no longer appear in the registrar / admin.</li>
<li>It will be removed from the registry. </li>
<li>The domain and its subdomains wont resolve in DNS.</li>
<li>Any infrastructure (like websites) will go offline.</li>
</ul>
<p>You should probably remove these domains from the registry instead.</p>
<p><strong>This action cannot be undone.</strong></p>
</div>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -12,11 +12,27 @@
<p>Your authorizing official is a person within your organization who can <p>Your authorizing official is a person within your organization who can
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p> authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p>
{% if organization_type == "federal" or organization_type == "tribal" %}
<p>
The authorizing official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% else %}
{% include "includes/required_fields.html" %} {% include "includes/required_fields.html" %}
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container"> <form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %} {% csrf_token %}
{% if organization_type == "federal" or organization_type == "tribal" %}
{# If all fields are disabled, add SR content #}
<div class="usa-sr-only" aria-labelledby="id_first_name" id="sr-ao-first-name">{{ form.first_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_last_name" id="sr-ao-last-name">{{ form.last_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_title" id="sr-ao-title">{{ form.title.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_email" id="sr-ao-email">{{ form.email.value }}</div>
{% endif %}
{% input_with_errors form.first_name %} {% input_with_errors form.first_name %}
{% input_with_errors form.last_name %} {% input_with_errors form.last_name %}
@ -25,10 +41,8 @@
{% input_with_errors form.email %} {% input_with_errors form.email %}
<button {% if organization_type != "federal" and organization_type != "tribal" %}
type="submit" <button type="submit" class="usa-button">Save</button>
class="usa-button" {% endif %}
>Save</button> </form>
</form>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -11,6 +11,18 @@
<p>The name of your organization will be publicly listed as the domain registrant.</p> <p>The name of your organization will be publicly listed as the domain registrant.</p>
{% if domain.domain_info.organization_type == "federal" %}
<p>
The federal agency for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% elif domain.domain_info.organization_type == "tribal" %}
<p>
Your organization name cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% endif %}
{% include "includes/required_fields.html" %} {% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container"> <form class="usa-form usa-form--large" method="post" novalidate id="form-container">

View file

@ -19,7 +19,7 @@
{% endblock %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %} {% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.about_your_organization %} {% input_with_errors forms.0.about_your_organization %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View file

@ -13,7 +13,7 @@
{% block form_fields %} {% block form_fields %}
{% with add_label_class="usa-sr-only" attr_maxlength=1000 %} {% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.anything_else %} {% input_with_errors forms.0.anything_else %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View file

@ -13,7 +13,7 @@
{% endblock %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %} {% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.purpose %} {% input_with_errors forms.0.purpose %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1 @@
An export of all .gov metadata.

View file

@ -0,0 +1,2 @@
Domain metadata - {{current_date_str}}

View file

@ -97,7 +97,7 @@ def less_console_noise(output_stream=None):
class GenericTestHelper(TestCase): class GenericTestHelper(TestCase):
"""A helper class that contains various helper functions for TestCases""" """A helper class that contains various helper functions for TestCases"""
def __init__(self, admin, model=None, url=None, user=None, factory=None, **kwargs): def __init__(self, admin, model=None, url=None, user=None, factory=None, client=None, **kwargs):
""" """
Parameters: Parameters:
admin (ModelAdmin): The Django ModelAdmin instance associated with the model. admin (ModelAdmin): The Django ModelAdmin instance associated with the model.
@ -112,6 +112,7 @@ class GenericTestHelper(TestCase):
self.admin = admin self.admin = admin
self.model = model self.model = model
self.url = url self.url = url
self.client = client
def assert_table_sorted(self, o_index, sort_fields): def assert_table_sorted(self, o_index, sort_fields):
""" """
@ -147,9 +148,7 @@ class GenericTestHelper(TestCase):
dummy_request.user = self.user dummy_request.user = self.user
# Mock a user request # Mock a user request
middleware = SessionMiddleware(lambda req: req) dummy_request = self._mock_user_request_for_factory(dummy_request)
middleware.process_request(dummy_request)
dummy_request.session.save()
expected_sort_order = list(self.model.objects.order_by(*sort_fields)) expected_sort_order = list(self.model.objects.order_by(*sort_fields))
@ -160,6 +159,27 @@ class GenericTestHelper(TestCase):
self.assertEqual(expected_sort_order, returned_sort_order) self.assertEqual(expected_sort_order, returned_sort_order)
def _mock_user_request_for_factory(self, request):
"""Adds sessionmiddleware when using factory to associate session information"""
middleware = SessionMiddleware(lambda req: req)
middleware.process_request(request)
request.session.save()
return request
def get_table_delete_confirmation_page(self, selected_across: str, index: str):
"""
Grabs the response for the delete confirmation page (generated from the actions toolbar).
selected_across and index must both be numbers encoded as str, e.g. "0" rather than 0
"""
response = self.client.post(
self.url,
{"action": "delete_selected", "select_across": selected_across, "index": index, "_selected_action": "23"},
follow=True,
)
print(f"what is the response? {response}")
return response
class MockUserLogin: class MockUserLogin:
def __init__(self, get_response): def __init__(self, get_response):

View file

@ -61,6 +61,16 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.factory = RequestFactory() self.factory = RequestFactory()
self.app.set_user(self.superuser.username) self.app.set_user(self.superuser.username)
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
# Contains some test tools
self.test_helper = GenericTestHelper(
factory=self.factory,
user=self.superuser,
admin=self.admin,
url=reverse("admin:registrar_domain_changelist"),
model=Domain,
client=self.client,
)
super().setUp() super().setUp()
@skip("TODO for another ticket. This test case is grabbing old db data.") @skip("TODO for another ticket. This test case is grabbing old db data.")
@ -230,6 +240,35 @@ class TestDomainAdmin(MockEppLib, WebTest):
) )
mock_add_message.assert_has_calls([expected_call], 1) mock_add_message.assert_has_calls([expected_call], 1)
def test_custom_delete_confirmation_page(self):
"""Tests if we override the delete confirmation page for custom content"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
domain_change_page = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
self.assertContains(domain_change_page, "fake.gov")
# click the "Manage" link
confirmation_page = domain_change_page.click("Delete", index=0)
content_slice = "When a domain is deleted:"
self.assertContains(confirmation_page, content_slice)
def test_custom_delete_confirmation_page_table(self):
"""Tests if we override the delete confirmation page for custom content on the table"""
# Create a ready domain
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
# Get the index. The post expects the index to be encoded as a string
index = f"{domain.id}"
# Simulate selecting a single record, then clicking "Delete selected domains"
response = self.test_helper.get_table_delete_confirmation_page("0", index)
# Check that our content exists
content_slice = "When a domain is deleted:"
self.assertContains(response, content_slice)
def test_short_org_name_in_domains_list(self): def test_short_org_name_in_domains_list(self):
""" """
Make sure the short name is displaying in admin on the list page Make sure the short name is displaying in admin on the list page
@ -309,6 +348,17 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name) self.assertContains(response, domain.name)
self.assertContains(response, "Remove from registry") self.assertContains(response, "Remove from registry")
# The contents of the modal should exist before and after the post.
# Check for the header
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
# Check for some of its body
self.assertContains(response, "When a domain is removed from the registry:")
# Check for some of the button content
self.assertContains(response, "Yes, remove from registry")
# Test the info dialog # Test the info dialog
request = self.factory.post( request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk), "/admin/registrar/domain/{}/change/".format(domain.pk),
@ -325,8 +375,60 @@ class TestDomainAdmin(MockEppLib, WebTest):
extra_tags="", extra_tags="",
fail_silently=False, fail_silently=False,
) )
# The modal should still exist
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
self.assertContains(response, "When a domain is removed from the registry:")
self.assertContains(response, "Yes, remove from registry")
self.assertEqual(domain.state, Domain.State.DELETED) self.assertEqual(domain.state, Domain.State.DELETED)
def test_on_hold_is_successful_web_test(self):
"""
Scenario: Domain on_hold is successful through webtest
"""
with less_console_noise():
domain = create_ready_domain()
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Check the contents of the modal
# Check for the header
self.assertContains(response, "Are you sure you want to place this domain on hold?")
# Check for some of its body
self.assertContains(response, "When a domain is on hold:")
# Check for some of the button content
self.assertContains(response, "Yes, place hold")
# Grab the form to submit
form = response.forms["domain_form"]
# Submit the form
response = form.submit("_place_client_hold")
# Follow the response
response = response.follow()
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove hold")
# The modal should still exist
# Check for the header
self.assertContains(response, "Are you sure you want to place this domain on hold?")
# Check for some of its body
self.assertContains(response, "When a domain is on hold:")
# Check for some of the button content
self.assertContains(response, "Yes, place hold")
# Web test has issues grabbing up to date data from the db, so we can test
# the returned view instead
self.assertContains(response, '<div class="readonly">On hold</div>')
def test_deletion_ready_fsm_failure(self): def test_deletion_ready_fsm_failure(self):
""" """
Scenario: Domain deletion is unsuccessful Scenario: Domain deletion is unsuccessful
@ -1101,7 +1203,9 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Create a mock request # Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), follow=True
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Modify the domain request's property # Modify the domain request's property
@ -1113,6 +1217,64 @@ class TestDomainRequestAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain # Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.creator.status, "restricted") self.assertEqual(domain_request.creator.status, "restricted")
def test_user_sets_restricted_status_modal(self):
"""Tests the modal for when a user sets the status to restricted"""
with less_console_noise():
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
# Create a sample domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the modal has the right content
# Check for the header
self.assertContains(response, "Are you sure you want to select ineligible status?")
# Check for some of its body
self.assertContains(response, "When a domain request is in ineligible status")
# Check for some of the button content
self.assertContains(response, "Yes, select ineligible status")
# Create a mock request
request = self.factory.post(
"/admin/registrar/domainrequest{}/change/".format(domain_request.pk), follow=True
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Modify the domain request's property
domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE
# Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True)
# Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.creator.status, "restricted")
# 'Get' to the domain request again
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# The modal should be unchanged
self.assertContains(response, "Are you sure you want to select ineligible status?")
self.assertContains(response, "When a domain request is in ineligible status")
self.assertContains(response, "Yes, select ineligible status")
def test_readonly_when_restricted_creator(self): def test_readonly_when_restricted_creator(self):
with less_console_noise(): with less_console_noise():
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)

View file

@ -5,7 +5,8 @@ from unittest.mock import MagicMock
from django.test import TestCase from django.test import TestCase
from .common import completed_domain_request, less_console_noise from .common import completed_domain_request, less_console_noise
from datetime import datetime
from registrar.utility import email
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -182,3 +183,32 @@ class TestEmails(TestCase):
self.assertNotIn("Anything else", body) self.assertNotIn("Anything else", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\n----") self.assertRegex(body, r"5557\n\n----")
@boto3_mocking.patching
def test_send_email_with_attachment(self):
with boto3_mocking.clients.handler_for("ses", self.mock_client_class):
sender_email = "sender@example.com"
recipient_email = "recipient@example.com"
subject = "Test Subject"
body = "Test Body"
attachment_file = b"Attachment file content"
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.zip"
email.send_email_with_attachment(
sender_email, recipient_email, subject, body, attachment_file, self.mock_client
)
# Assert that the `send_raw_email` method of the mocked SES client was called with the expected params
self.mock_client.send_raw_email.assert_called_once()
# Get the args passed to the `send_raw_email` method
call_args = self.mock_client.send_raw_email.call_args[1]
# Assert that the attachment filename is correct
self.assertEqual(call_args["RawMessage"]["Data"].count(f'filename="{current_filename}"'), 1)
# Assert that the attachment content is encrypted
self.assertIn("Content-Type: application/octet-stream", call_args["RawMessage"]["Data"])
self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])

View file

@ -226,7 +226,7 @@ class TestFormValidation(MockEppLib):
) )
def test_purpose_form_character_count_invalid(self): def test_purpose_form_character_count_invalid(self):
"""Response must be less than 1000 characters.""" """Response must be less than 2000 characters."""
form = PurposeForm( form = PurposeForm(
data={ data={
"purpose": "Bacon ipsum dolor amet fatback strip steak pastrami" "purpose": "Bacon ipsum dolor amet fatback strip steak pastrami"
@ -247,15 +247,33 @@ class TestFormValidation(MockEppLib):
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf" "cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong" "beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin." "bacon rump tail boudin meatball boudin meatball boudin."
"Bacon ipsum dolor amet fatback strip steak pastrami"
"shankle, drumstick doner chicken landjaeger turkey andouille."
"Buffalo biltong chuck pork chop tongue bresaola turkey. Doner"
"ground round strip steak, jowl tail chuck ribeye bacon"
"beef ribs swine filet ball tip pancetta strip steak sirloin"
"mignon ham spare ribs rump. Tail shank biltong beef ribs doner"
"buffalo swine bacon. Tongue cow picanha brisket bacon chuck"
"leberkas pork loin pork, drumstick capicola. Doner short loin"
"ground round fatback turducken chislic shoulder turducken"
"spare ribs, burgdoggen kielbasa kevin frankfurter ball tip"
"pancetta cupim. Turkey meatball andouille porchetta hamburger"
"pork chop corned beef. Brisket short ribs turducken, pork chop"
"chislic turkey ball pork chop leberkas rump, rump bacon, jowl"
"tip ham. Shankle salami tongue venison short ribs kielbasa"
"tri-tip ham hock swine hamburger. Flank meatball corned beef"
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin."
} }
) )
self.assertEqual( self.assertEqual(
form.errors["purpose"], form.errors["purpose"],
["Response must be less than 1000 characters."], ["Response must be less than 2000 characters."],
) )
def test_anything_else_form_about_your_organization_character_count_invalid(self): def test_anything_else_form_about_your_organization_character_count_invalid(self):
"""Response must be less than 1000 characters.""" """Response must be less than 2000 characters."""
form = AnythingElseForm( form = AnythingElseForm(
data={ data={
"anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami" "anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami"
@ -276,15 +294,32 @@ class TestFormValidation(MockEppLib):
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf" "cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong" "beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin." "bacon rump tail boudin meatball boudin meatball boudin."
"shankle, drumstick doner chicken landjaeger turkey andouille."
"Buffalo biltong chuck pork chop tongue bresaola turkey. Doner"
"ground round strip steak, jowl tail chuck ribeye bacon"
"beef ribs swine filet ball tip pancetta strip steak sirloin"
"mignon ham spare ribs rump. Tail shank biltong beef ribs doner"
"buffalo swine bacon. Tongue cow picanha brisket bacon chuck"
"leberkas pork loin pork, drumstick capicola. Doner short loin"
"ground round fatback turducken chislic shoulder turducken"
"spare ribs, burgdoggen kielbasa kevin frankfurter ball tip"
"pancetta cupim. Turkey meatball andouille porchetta hamburger"
"pork chop corned beef. Brisket short ribs turducken, pork chop"
"chislic turkey ball pork chop leberkas rump, rump bacon, jowl"
"tip ham. Shankle salami tongue venison short ribs kielbasa"
"tri-tip ham hock swine hamburger. Flank meatball corned beef"
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin."
} }
) )
self.assertEqual( self.assertEqual(
form.errors["anything_else"], form.errors["anything_else"],
["Response must be less than 1000 characters."], ["Response must be less than 2000 characters."],
) )
def test_anything_else_form_character_count_invalid(self): def test_anything_else_form_character_count_invalid(self):
"""Response must be less than 1000 characters.""" """Response must be less than 2000 characters."""
form = AboutYourOrganizationForm( form = AboutYourOrganizationForm(
data={ data={
"about_your_organization": "Bacon ipsum dolor amet fatback" "about_your_organization": "Bacon ipsum dolor amet fatback"
@ -306,11 +341,29 @@ class TestFormValidation(MockEppLib):
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf" "cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong" "beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin." "bacon rump tail boudin meatball boudin meatball boudin."
"strip steak pastrami"
"shankle, drumstick doner chicken landjaeger turkey andouille."
"Buffalo biltong chuck pork chop tongue bresaola turkey. Doner"
"ground round strip steak, jowl tail chuck ribeye bacon"
"beef ribs swine filet ball tip pancetta strip steak sirloin"
"mignon ham spare ribs rump. Tail shank biltong beef ribs doner"
"buffalo swine bacon. Tongue cow picanha brisket bacon chuck"
"leberkas pork loin pork, drumstick capicola. Doner short loin"
"ground round fatback turducken chislic shoulder turducken"
"spare ribs, burgdoggen kielbasa kevin frankfurter ball tip"
"pancetta cupim. Turkey meatball andouille porchetta hamburger"
"pork chop corned beef. Brisket short ribs turducken, pork chop"
"chislic turkey ball pork chop leberkas rump, rump bacon, jowl"
"tip ham. Shankle salami tongue venison short ribs kielbasa"
"tri-tip ham hock swine hamburger. Flank meatball corned beef"
"cow sausage ball tip kielbasa ham hock. Ball tip cupim meatloaf"
"beef ribs rump jowl tenderloin swine sausage biltong"
"bacon rump tail boudin meatball boudin meatball boudin."
} }
) )
self.assertEqual( self.assertEqual(
form.errors["about_your_organization"], form.errors["about_your_organization"],
["Response must be less than 1000 characters."], ["Response must be less than 2000 characters."],
) )
def test_your_contact_email_invalid(self): def test_your_contact_email_invalid(self):

View file

@ -1,8 +1,14 @@
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .common import MockEppLib # type: ignore from api.tests.common import less_console_noise_decorator
from registrar.models.domain import Domain
from registrar.models.user_domain_role import UserDomainRole
from registrar.views.domain import DomainNameserversView
from .common import MockEppLib # type: ignore
from unittest.mock import patch
from django.urls import reverse
from registrar.models import ( from registrar.models import (
DomainRequest, DomainRequest,
@ -66,6 +72,7 @@ class TestEnvironmentVariablesEffects(TestCase):
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
Domain.objects.all().delete()
self.user.delete() self.user.delete()
@override_settings(IS_PRODUCTION=True) @override_settings(IS_PRODUCTION=True)
@ -79,3 +86,52 @@ class TestEnvironmentVariablesEffects(TestCase):
"""Banner on non-prod.""" """Banner on non-prod."""
home_page = self.client.get("/") home_page = self.client.get("/")
self.assertContains(home_page, "You are on a test site.") self.assertContains(home_page, "You are on a test site.")
def side_effect_raise_value_error(self):
"""Side effect that raises a 500 error"""
raise ValueError("Some error")
@less_console_noise_decorator
@override_settings(IS_PRODUCTION=False)
def test_non_production_environment_raises_500_and_shows_banner(self):
"""Tests if the non-prod banner is still shown on a 500"""
fake_domain, _ = Domain.objects.get_or_create(name="igorville.gov")
# Add a role
fake_role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER
)
with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error):
with self.assertRaises(ValueError):
contact_page_500 = self.client.get(
reverse("domain-dns-nameservers", kwargs={"pk": fake_domain.id}),
)
# Check that a 500 response is returned
self.assertEqual(contact_page_500.status_code, 500)
self.assertContains(contact_page_500, "You are on a test site.")
@less_console_noise_decorator
@override_settings(IS_PRODUCTION=True)
def test_production_environment_raises_500_and_doesnt_show_banner(self):
"""Test if the non-prod banner is not shown on production when a 500 is raised"""
fake_domain, _ = Domain.objects.get_or_create(name="igorville.gov")
# Add a role
fake_role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER
)
with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error):
with self.assertRaises(ValueError):
contact_page_500 = self.client.get(
reverse("domain-dns-nameservers", kwargs={"pk": fake_domain.id}),
)
# Check that a 500 response is returned
self.assertEqual(contact_page_500.status_code, 500)
self.assertNotContains(contact_page_500, "You are on a test site.")

View file

@ -1021,6 +1021,144 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
self.assertEqual("Testy2", self.domain_information.authorizing_official.first_name) self.assertEqual("Testy2", self.domain_information.authorizing_official.first_name)
self.assertEqual(ao_pk, self.domain_information.authorizing_official.id) self.assertEqual(ao_pk, self.domain_information.authorizing_official.id)
def assert_all_form_fields_have_expected_values(self, form, test_cases, test_for_disabled=False):
"""
Asserts that each specified form field has the expected value and, optionally, checks if the field is disabled.
This method iterates over a list of tuples, where each
tuple contains a field name and the expected value for that field.
It uses subtests to isolate each assertion, allowing multiple field
checks within a single test method without stopping at the first failure.
Example usage:
test_cases = [
("first_name", "John"),
("last_name", "Doe"),
("email", "john.doe@example.com"),
]
self.assert_all_form_fields_have_expected_values(my_form, test_cases, test_for_disabled=True)
"""
for field_name, expected_value in test_cases:
with self.subTest(field_name=field_name, expected_value=expected_value):
# Test that each field has the value we expect
self.assertEqual(expected_value, form[field_name].value)
if test_for_disabled:
# Test for disabled on each field
self.assertTrue("disabled" in form[field_name].attrs)
def test_domain_edit_authorizing_official_federal(self):
"""Tests that no edit can occur when the underlying domain is federal"""
# Set the org type to federal
self.domain_information.organization_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
)
self.domain_information.authorizing_official.save()
self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
ao_form = ao_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
ao_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email)
def test_domain_edit_authorizing_official_tribal(self):
"""Tests that no edit can occur when the underlying domain is tribal"""
# Set the org type to federal
self.domain_information.organization_type = DomainInformation.OrganizationChoices.TRIBAL
self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
)
self.domain_information.authorizing_official.save()
self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
ao_form = ao_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
ao_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email)
def test_domain_edit_authorizing_official_creates_new(self): def test_domain_edit_authorizing_official_creates_new(self):
"""When editing an authorizing official for domain information and AO IS """When editing an authorizing official for domain information and AO IS
joined to another object""" joined to another object"""
@ -1088,6 +1226,149 @@ class TestDomainOrganization(TestDomainOverview):
self.assertContains(success_result_page, "Not igorville") self.assertContains(success_result_page, "Not igorville")
self.assertContains(success_result_page, "Faketown") self.assertContains(success_result_page, "Faketown")
def test_domain_org_name_address_form_tribal(self):
"""
Submitting a change to organization_name is blocked for tribal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
tribal_org_type = DomainInformation.OrganizationChoices.TRIBAL
self.domain_information.organization_type = tribal_org_type
self.domain_information.save()
try:
# Add an org name
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, tribal_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["organization_name"][0]
self.assertEqual(organization_name_input.value, "Town of Igorville")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Make the change. The org name should be unchanged, but city should be modifiable.
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check for the old and new value
self.assertContains(success_result_page, "Town of Igorville")
self.assertNotContains(success_result_page, "Not igorville")
# Do another check on the form itself
form = success_result_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["organization_name"][0]
self.assertEqual(organization_name_input.value, "Town of Igorville")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
def test_domain_org_name_address_form_federal(self):
"""
Submitting a change to federal_agency is blocked for federal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
fed_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.organization_type = fed_org_type
self.domain_information.save()
try:
self.domain_information.federal_agency = "AMTRAK"
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, fed_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
agency_input = form.fields["federal_agency"][0]
self.assertEqual(agency_input.value, "AMTRAK")
# Check if the input field is disabled
self.assertTrue("disabled" in agency_input.attrs)
self.assertEqual(agency_input.attrs.get("disabled"), "")
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["federal_agency"] = "Department of State"
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Make the change. The agency should be unchanged, but city should be modifiable.
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check for the old and new value
self.assertContains(success_result_page, "AMTRAK")
self.assertNotContains(success_result_page, "Department of State")
# Do another check on the form itself
form = success_result_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["federal_agency"][0]
self.assertEqual(organization_name_input.value, "AMTRAK")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
def test_federal_agency_submit_blocked(self):
"""
Submitting a change to federal_agency is blocked for federal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
federal_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.organization_type = federal_org_type
self.domain_information.save()
old_federal_agency_value = ("AMTRAK", "AMTRAK")
try:
# Add a federal agency. Defined as a tuple since this list may change order.
self.domain_information.federal_agency = old_federal_agency_value
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, federal_org_type)
new_value = ("Department of State", "Department of State")
self.client.post(
reverse("domain-org-name-address", kwargs={"pk": self.domain.id}),
{
"federal_agency": new_value,
},
)
self.assertEqual(self.domain_information.federal_agency, old_federal_agency_value)
self.assertNotEqual(self.domain_information.federal_agency, new_value)
class TestDomainContactInformation(TestDomainOverview): class TestDomainContactInformation(TestDomainOverview):
def test_domain_your_contact_information(self): def test_domain_your_contact_information(self):

View file

@ -2,8 +2,12 @@
import boto3 import boto3
import logging import logging
from datetime import datetime
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,7 +19,14 @@ class EmailSendingError(RuntimeError):
pass pass
def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}): def send_templated_email(
template_name: str,
subject_template_name: str,
to_address: str,
bcc_address="",
context={},
attachment_file: str = None,
):
"""Send an email built from a template to one email address. """Send an email built from a template to one email address.
template_name and subject_template_name are relative to the same template template_name and subject_template_name are relative to the same template
@ -45,15 +56,50 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
destination["BccAddresses"] = [bcc_address] destination["BccAddresses"] = [bcc_address]
try: try:
ses_client.send_email( if attachment_file is None:
FromEmailAddress=settings.DEFAULT_FROM_EMAIL, ses_client.send_email(
Destination=destination, FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
Content={ Destination=destination,
"Simple": { Content={
"Subject": {"Data": subject}, "Simple": {
"Body": {"Text": {"Data": email_body}}, "Subject": {"Data": subject},
"Body": {"Text": {"Data": email_body}},
},
}, },
}, )
) else:
ses_client = boto3.client(
"ses",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG,
)
send_email_with_attachment(
settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client
)
except Exception as exc: except Exception as exc:
raise EmailSendingError("Could not send SES email.") from exc raise EmailSendingError("Could not send SES email.") from exc
def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client):
# Create a multipart/mixed parent container
msg = MIMEMultipart("mixed")
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = recipient
# Add the text part
text_part = MIMEText(body, "plain")
msg.attach(text_part)
# Add the attachment part
attachment_part = MIMEApplication(attachment_file)
# Adding attachment header + filename that the attachment will be called
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.zip"
attachment_part.add_header("Content-Disposition", f'attachment; filename="{current_filename}"')
msg.attach(attachment_part)
response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()})
return response

View file

@ -18,6 +18,8 @@ from django.conf import settings
from registrar.models import ( from registrar.models import (
Domain, Domain,
DomainRequest,
DomainInformation,
DomainInvitation, DomainInvitation,
User, User,
UserDomainRole, UserDomainRole,
@ -134,6 +136,20 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
# superclass has the redirect # superclass has the redirect
return super().form_invalid(form) return super().form_invalid(form)
def get_domain_info_from_domain(self) -> DomainInformation | None:
"""
Grabs the underlying domain_info object based off of self.object.name.
Returns None if nothing is found.
"""
_domain_info = DomainInformation.objects.filter(domain__name=self.object.name)
current_domain_info = None
if _domain_info.exists() and _domain_info.count() == 1:
current_domain_info = _domain_info.get()
else:
logger.error("Could get domain_info. No domain info exists, or duplicates exist.")
return current_domain_info
class DomainView(DomainBaseView): class DomainView(DomainBaseView):
"""Domain detail overview page.""" """Domain detail overview page."""
@ -217,16 +233,29 @@ class DomainAuthorizingOfficialView(DomainFormBaseView):
"""Add domain_info.authorizing_official instance to make a bound form.""" """Add domain_info.authorizing_official instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs) form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.object.domain_info.authorizing_official form_kwargs["instance"] = self.object.domain_info.authorizing_official
domain_info = self.get_domain_info_from_domain()
invalid_fields = [DomainRequest.OrganizationChoices.FEDERAL, DomainRequest.OrganizationChoices.TRIBAL]
is_federal_or_tribal = domain_info and (domain_info.organization_type in invalid_fields)
form_kwargs["disable_fields"] = is_federal_or_tribal
return form_kwargs return form_kwargs
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
context["organization_type"] = self.object.domain_info.organization_type
return context
def get_success_url(self): def get_success_url(self):
"""Redirect to the overview page for the domain.""" """Redirect to the overview page for the domain."""
return reverse("domain-authorizing-official", kwargs={"pk": self.object.pk}) return reverse("domain-authorizing-official", kwargs={"pk": self.object.pk})
def form_valid(self, form): def form_valid(self, form):
"""The form is valid, save the authorizing official.""" """The form is valid, save the authorizing official."""
# Set the domain information in the form so that it can be accessible # Set the domain information in the form so that it can be accessible
# to associate a new Contact as authorizing official, if new Contact is needed # to associate a new Contact, if a new Contact is needed
# in the save() method # in the save() method
form.set_domain_info(self.object.domain_info) form.set_domain_info(self.object.domain_info)
form.save() form.save()

View file

@ -0,0 +1,32 @@
"""
Custom views that allow for error view customization.
Used as a general handler for 500 errors both coming from the registrar app, but
also the djangooidc app.
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.
"""
from django.shortcuts import render
def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view"""
if context is None:
return render(request, "500.html", status=500)
else:
return render(request, "500.html", context=context, status=500)
def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view"""
if context is None:
return render(request, "401.html", status=401)
else:
return render(request, "401.html", context=context, status=401)

View file

@ -1,8 +1,8 @@
-i https://pypi.python.org/simple -i https://pypi.python.org/simple
annotated-types==0.6.0; python_version >= '3.8' annotated-types==0.6.0; python_version >= '3.8'
asgiref==3.7.2; python_version >= '3.7' asgiref==3.7.2; python_version >= '3.7'
boto3==1.34.54; python_version >= '3.8' boto3==1.34.56; python_version >= '3.8'
botocore==1.34.54; python_version >= '3.8' botocore==1.34.56; python_version >= '3.8'
cachetools==5.3.3; python_version >= '3.7' cachetools==5.3.3; python_version >= '3.7'
certifi==2024.2.2; python_version >= '3.6' certifi==2024.2.2; python_version >= '3.6'
cfenv==0.5.3 cfenv==0.5.3
@ -22,8 +22,8 @@ django-fsm==2.8.1
django-login-required-middleware==0.9.0 django-login-required-middleware==0.9.0
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8' django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==10.3.0; python_version >= '3.8' environs[django]==11.0.0; python_version >= '3.8'
faker==23.3.0; python_version >= '3.8' faker==24.0.0; python_version >= '3.8'
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3 furl==2.1.3
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
@ -35,7 +35,7 @@ jmespath==1.0.1; python_version >= '3.7'
lxml==5.1.0; python_version >= '3.6' lxml==5.1.0; python_version >= '3.6'
mako==1.3.2; python_version >= '3.8' mako==1.3.2; python_version >= '3.8'
markupsafe==2.1.5; python_version >= '3.7' markupsafe==2.1.5; python_version >= '3.7'
marshmallow==3.21.0; python_version >= '3.8' marshmallow==3.21.1; python_version >= '3.8'
oic==1.6.1; python_version ~= '3.7' oic==1.6.1; python_version ~= '3.7'
orderedmultidict==1.0.1 orderedmultidict==1.0.1
packaging==23.2; python_version >= '3.7' packaging==23.2; python_version >= '3.7'
@ -49,6 +49,7 @@ pydantic-settings==2.2.1; python_version >= '3.8'
pyjwkest==1.4.2 pyjwkest==1.4.2
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dotenv==1.0.1; python_version >= '3.8' python-dotenv==1.0.1; python_version >= '3.8'
pyzipper==0.3.6; python_version >= '3.4'
requests==2.31.0; python_version >= '3.7' requests==2.31.0; python_version >= '3.7'
s3transfer==0.10.0; python_version >= '3.8' s3transfer==0.10.0; python_version >= '3.8'
setuptools==69.1.1; python_version >= '3.8' setuptools==69.1.1; python_version >= '3.8'