mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-14 08:37:03 +02:00
Merge main
This commit is contained in:
commit
a20a9154a6
30 changed files with 1148 additions and 655 deletions
15
.github/workflows/deploy-development.yaml
vendored
15
.github/workflows/deploy-development.yaml
vendored
|
@ -22,9 +22,16 @@ jobs:
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: |
|
run: |
|
||||||
docker compose run node npm install &&
|
docker compose run node bash -c "\
|
||||||
docker compose run node npx gulp copyAssets &&
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
|
||||||
docker compose run node npx gulp compile
|
export NVM_DIR=\"\$HOME/.nvm\" && \
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
|
||||||
|
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
|
||||||
|
nvm install 21.7.3 && \
|
||||||
|
nvm use 21.7.3 && \
|
||||||
|
npm install && \
|
||||||
|
npx gulp copyAssets && \
|
||||||
|
npx gulp compile"
|
||||||
- name: Collect static assets
|
- name: Collect static assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: docker compose run app python manage.py collectstatic --no-input
|
run: docker compose run app python manage.py collectstatic --no-input
|
||||||
|
@ -45,4 +52,4 @@ jobs:
|
||||||
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
|
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: development
|
cf_space: development
|
||||||
cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
|
cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
|
15
.github/workflows/deploy-sandbox.yaml
vendored
15
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -42,9 +42,16 @@ jobs:
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: |
|
run: |
|
||||||
docker compose run node npm install &&
|
docker compose run node bash -c "\
|
||||||
docker compose run node npx gulp copyAssets &&
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
|
||||||
docker compose run node npx gulp compile
|
export NVM_DIR=\"\$HOME/.nvm\" && \
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
|
||||||
|
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
|
||||||
|
nvm install 21.7.3 && \
|
||||||
|
nvm use 21.7.3 && \
|
||||||
|
npm install && \
|
||||||
|
npx gulp copyAssets && \
|
||||||
|
npx gulp compile"
|
||||||
- name: Collect static assets
|
- name: Collect static assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: docker compose run app python manage.py collectstatic --no-input
|
run: docker compose run app python manage.py collectstatic --no-input
|
||||||
|
@ -75,4 +82,4 @@ jobs:
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
|
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
|
||||||
})
|
})
|
13
.github/workflows/deploy-stable.yaml
vendored
13
.github/workflows/deploy-stable.yaml
vendored
|
@ -23,9 +23,16 @@ jobs:
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: |
|
run: |
|
||||||
docker compose run node npm install &&
|
docker compose run node bash -c "\
|
||||||
docker compose run node npx gulp copyAssets &&
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
|
||||||
docker compose run node npx gulp compile
|
export NVM_DIR=\"\$HOME/.nvm\" && \
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
|
||||||
|
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
|
||||||
|
nvm install 21.7.3 && \
|
||||||
|
nvm use 21.7.3 && \
|
||||||
|
npm install && \
|
||||||
|
npx gulp copyAssets && \
|
||||||
|
npx gulp compile"
|
||||||
- name: Collect static assets
|
- name: Collect static assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: docker compose run app python manage.py collectstatic --no-input
|
run: docker compose run app python manage.py collectstatic --no-input
|
||||||
|
|
15
.github/workflows/deploy-staging.yaml
vendored
15
.github/workflows/deploy-staging.yaml
vendored
|
@ -23,9 +23,16 @@ jobs:
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: |
|
run: |
|
||||||
docker compose run node npm install &&
|
docker compose run node bash -c "\
|
||||||
docker compose run node npx gulp copyAssets &&
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
|
||||||
docker compose run node npx gulp compile
|
export NVM_DIR=\"\$HOME/.nvm\" && \
|
||||||
|
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
|
||||||
|
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
|
||||||
|
nvm install 21.7.3 && \
|
||||||
|
nvm use 21.7.3 && \
|
||||||
|
npm install && \
|
||||||
|
npx gulp copyAssets && \
|
||||||
|
npx gulp compile"
|
||||||
- name: Collect static assets
|
- name: Collect static assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: docker compose run app python manage.py collectstatic --no-input
|
run: docker compose run app python manage.py collectstatic --no-input
|
||||||
|
@ -44,4 +51,4 @@ jobs:
|
||||||
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: staging
|
cf_space: staging
|
||||||
cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
|
cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
|
|
@ -602,18 +602,18 @@ That data are synthesized from the generic_org_type field and the is_election_bo
|
||||||
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
|
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
|
||||||
After downloading this file, place it in `src/migrationdata`
|
After downloading this file, place it in `src/migrationdata`
|
||||||
|
|
||||||
#### Step 2: Upload the domain_election_board file to your sandbox
|
#### Step 3: Upload the domain_election_board file to your sandbox
|
||||||
Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc.
|
Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc.
|
||||||
|
|
||||||
#### Step 2: SSH into your environment
|
#### Step 4: SSH into your environment
|
||||||
```cf ssh getgov-{space}```
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
Example: `cf ssh getgov-za`
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
#### Step 3: Create a shell instance
|
#### Step 5: Create a shell instance
|
||||||
```/tmp/lifecycle/shell```
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
#### Step 4: Running the script
|
#### Step 6: Running the script
|
||||||
```./manage.py populate_organization_type {domain_election_board_filename}```
|
```./manage.py populate_organization_type {domain_election_board_filename}```
|
||||||
|
|
||||||
- The domain_election_board_filename file must adhere to this format:
|
- The domain_election_board_filename file must adhere to this format:
|
||||||
|
@ -642,3 +642,29 @@ Example (assuming that this is being ran from src/):
|
||||||
| | Parameter | Description |
|
| | Parameter | Description |
|
||||||
|:-:|:------------------------------------|:-------------------------------------------------------------------|
|
|:-:|:------------------------------------|:-------------------------------------------------------------------|
|
||||||
| 1 | **domain_election_board_filename** | A file containing every domain that is an election office.
|
| 1 | **domain_election_board_filename** | A file containing every domain that is an election office.
|
||||||
|
|
||||||
|
|
||||||
|
## Populate Verification Type
|
||||||
|
This section outlines how to run the `populate_verification_type` script.
|
||||||
|
The script is used to update the verification_type field on User when it is None.
|
||||||
|
|
||||||
|
### Running on sandboxes
|
||||||
|
|
||||||
|
#### Step 1: Login to CloudFoundry
|
||||||
|
```cf login -a api.fr.cloud.gov --sso```
|
||||||
|
|
||||||
|
#### Step 2: SSH into your environment
|
||||||
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
|
#### Step 3: Create a shell instance
|
||||||
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
|
#### Step 4: Running the script
|
||||||
|
```./manage.py populate_verification_type```
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
|
||||||
|
#### Step 1: Running the script
|
||||||
|
```docker-compose exec app ./manage.py populate_verification_type```
|
||||||
|
|
|
@ -4,8 +4,10 @@ from django.http import HttpResponse
|
||||||
from django.test import Client, TestCase, RequestFactory
|
from django.test import Client, TestCase, RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from api.tests.common import less_console_noise_decorator
|
||||||
from djangooidc.exceptions import StateMismatch, InternalError
|
from djangooidc.exceptions import StateMismatch, InternalError
|
||||||
from ..views import login_callback
|
from ..views import login_callback
|
||||||
|
from registrar.models import User, Contact, VerifiedByStaff, DomainInvitation, TransitionDomain, Domain
|
||||||
|
|
||||||
from .common import less_console_noise
|
from .common import less_console_noise
|
||||||
|
|
||||||
|
@ -16,6 +18,14 @@ class ViewsTest(TestCase):
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
User.objects.all().delete()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
DomainInvitation.objects.all().delete()
|
||||||
|
VerifiedByStaff.objects.all().delete()
|
||||||
|
TransitionDomain.objects.all().delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
|
||||||
def say_hi(*args):
|
def say_hi(*args):
|
||||||
return HttpResponse("Hi")
|
return HttpResponse("Hi")
|
||||||
|
|
||||||
|
@ -229,6 +239,140 @@ class ViewsTest(TestCase):
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.url, "/")
|
self.assertEqual(response.url, "/")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_login_callback_sets_verification_type_regular(self, mock_client):
|
||||||
|
"""
|
||||||
|
Test that openid sets the verification type to regular on the returned user.
|
||||||
|
Regular, in this context, means that this user was "Verifed by Login.gov"
|
||||||
|
"""
|
||||||
|
# SETUP
|
||||||
|
session = self.client.session
|
||||||
|
session.save()
|
||||||
|
# MOCK
|
||||||
|
# mock that callback returns user_info; this is the expected behavior
|
||||||
|
mock_client.callback.side_effect = self.user_info
|
||||||
|
# patch that the request does not require step up auth
|
||||||
|
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||||
|
"djangooidc.views._initialize_client"
|
||||||
|
) as mock_init_client:
|
||||||
|
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||||
|
# TEST
|
||||||
|
# test the login callback url
|
||||||
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
|
|
||||||
|
# assert that _initialize_client was called
|
||||||
|
mock_init_client.assert_called_once()
|
||||||
|
|
||||||
|
# Assert that we get a redirect
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, "/")
|
||||||
|
|
||||||
|
# Test the created user object
|
||||||
|
created_user = User.objects.get(email="test@example.com")
|
||||||
|
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.REGULAR)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_login_callback_sets_verification_type_invited(self, mock_client):
|
||||||
|
"""Test that openid sets the verification type to invited on the returned user
|
||||||
|
when they exist in the DomainInvitation table"""
|
||||||
|
# SETUP
|
||||||
|
session = self.client.session
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="test123.gov")
|
||||||
|
invitation, _ = DomainInvitation.objects.get_or_create(email="test@example.com", domain=domain)
|
||||||
|
# MOCK
|
||||||
|
# mock that callback returns user_info; this is the expected behavior
|
||||||
|
mock_client.callback.side_effect = self.user_info
|
||||||
|
# patch that the request does not require step up auth
|
||||||
|
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||||
|
"djangooidc.views._initialize_client"
|
||||||
|
) as mock_init_client:
|
||||||
|
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||||
|
# TEST
|
||||||
|
# test the login callback url
|
||||||
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
|
|
||||||
|
# assert that _initialize_client was called
|
||||||
|
mock_init_client.assert_called_once()
|
||||||
|
|
||||||
|
# Assert that we get a redirect
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, "/")
|
||||||
|
|
||||||
|
# Test the created user object
|
||||||
|
created_user = User.objects.get(email="test@example.com")
|
||||||
|
self.assertEqual(created_user.email, invitation.email)
|
||||||
|
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.INVITED)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_login_callback_sets_verification_type_grandfathered(self, mock_client):
|
||||||
|
"""Test that openid sets the verification type to grandfathered
|
||||||
|
on a user which exists in our TransitionDomain table"""
|
||||||
|
# SETUP
|
||||||
|
session = self.client.session
|
||||||
|
session.save()
|
||||||
|
# MOCK
|
||||||
|
# mock that callback returns user_info; this is the expected behavior
|
||||||
|
mock_client.callback.side_effect = self.user_info
|
||||||
|
|
||||||
|
td, _ = TransitionDomain.objects.get_or_create(username="test@example.com", domain_name="test123.gov")
|
||||||
|
|
||||||
|
# patch that the request does not require step up auth
|
||||||
|
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||||
|
"djangooidc.views._initialize_client"
|
||||||
|
) as mock_init_client:
|
||||||
|
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||||
|
# TEST
|
||||||
|
# test the login callback url
|
||||||
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
|
|
||||||
|
# assert that _initialize_client was called
|
||||||
|
mock_init_client.assert_called_once()
|
||||||
|
|
||||||
|
# Assert that we get a redirect
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, "/")
|
||||||
|
|
||||||
|
# Test the created user object
|
||||||
|
created_user = User.objects.get(email="test@example.com")
|
||||||
|
self.assertEqual(created_user.email, td.username)
|
||||||
|
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_login_callback_sets_verification_type_verified_by_staff(self, mock_client):
|
||||||
|
"""Test that openid sets the verification type to verified_by_staff
|
||||||
|
on a user which exists in our VerifiedByStaff table"""
|
||||||
|
# SETUP
|
||||||
|
session = self.client.session
|
||||||
|
session.save()
|
||||||
|
# MOCK
|
||||||
|
# mock that callback returns user_info; this is the expected behavior
|
||||||
|
mock_client.callback.side_effect = self.user_info
|
||||||
|
|
||||||
|
vip, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com")
|
||||||
|
|
||||||
|
# patch that the request does not require step up auth
|
||||||
|
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||||
|
"djangooidc.views._initialize_client"
|
||||||
|
) as mock_init_client:
|
||||||
|
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||||
|
# TEST
|
||||||
|
# test the login callback url
|
||||||
|
response = self.client.get(reverse("openid_login_callback"))
|
||||||
|
|
||||||
|
# assert that _initialize_client was called
|
||||||
|
mock_init_client.assert_called_once()
|
||||||
|
|
||||||
|
# Assert that we get a redirect
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, "/")
|
||||||
|
|
||||||
|
# Test the created user object
|
||||||
|
created_user = User.objects.get(email="test@example.com")
|
||||||
|
self.assertEqual(created_user.email, vip.email)
|
||||||
|
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF)
|
||||||
|
|
||||||
def test_login_callback_no_step_up_auth(self, mock_client):
|
def test_login_callback_no_step_up_auth(self, mock_client):
|
||||||
"""Walk through login_callback when _requires_step_up_auth returns False
|
"""Walk through login_callback when _requires_step_up_auth returns False
|
||||||
and assert that we have a redirect to /"""
|
and assert that we have a redirect to /"""
|
||||||
|
|
|
@ -99,8 +99,22 @@ def login_callback(request):
|
||||||
return CLIENT.create_authn_request(request.session)
|
return CLIENT.create_authn_request(request.session)
|
||||||
user = authenticate(request=request, **userinfo)
|
user = authenticate(request=request, **userinfo)
|
||||||
if user:
|
if user:
|
||||||
|
|
||||||
|
# Fixture users kind of exist in a superposition of verification types,
|
||||||
|
# because while the system "verified" them, if they login,
|
||||||
|
# we don't know how the user themselves was verified through login.gov until
|
||||||
|
# they actually try logging in. This edge-case only matters in non-production environments.
|
||||||
|
fixture_user = User.VerificationTypeChoices.FIXTURE_USER
|
||||||
|
is_fixture_user = user.verification_type and user.verification_type == fixture_user
|
||||||
|
|
||||||
|
# Set the verification type if it doesn't already exist or if its a fixture user
|
||||||
|
if not user.verification_type or is_fixture_user:
|
||||||
|
user.set_user_verification_type()
|
||||||
|
user.save()
|
||||||
|
|
||||||
login(request, user)
|
login(request, user)
|
||||||
logger.info("Successfully logged in user %s" % user)
|
logger.info("Successfully logged in user %s" % user)
|
||||||
|
|
||||||
# Clear the flag if the exception is not caught
|
# Clear the flag if the exception is not caught
|
||||||
request.session.pop("redirect_attempted", None)
|
request.session.pop("redirect_attempted", None)
|
||||||
return redirect(request.session.get("next", "/"))
|
return redirect(request.session.get("next", "/"))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
FROM docker.io/cimg/node:current-browsers
|
FROM docker.io/cimg/node:current-browsers
|
||||||
|
FROM node:21.7.3
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install app dependencies
|
# Install app dependencies
|
||||||
|
@ -7,4 +7,6 @@ WORKDIR /app
|
||||||
# where available (npm@5+)
|
# where available (npm@5+)
|
||||||
COPY --chown=circleci:circleci package*.json ./
|
COPY --chown=circleci:circleci package*.json ./
|
||||||
|
|
||||||
RUN npm install
|
|
||||||
|
RUN npm install -g npm@10.5.0
|
||||||
|
RUN npm install
|
4
src/package-lock.json
generated
4
src/package-lock.json
generated
|
@ -15,6 +15,10 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@uswds/compile": "^1.0.0-beta.3"
|
"@uswds/compile": "^1.0.0-beta.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "21.7.3",
|
||||||
|
"npm": "10.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@gulp-sourcemaps/identity-map": {
|
"node_modules/@gulp-sourcemaps/identity-map": {
|
||||||
|
|
|
@ -3,6 +3,11 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "========================",
|
"description": "========================",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": "21.7.3",
|
||||||
|
"npm": "10.5.0"
|
||||||
|
},
|
||||||
|
"engineStrict": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"pa11y-ci": "pa11y-ci",
|
"pa11y-ci": "pa11y-ci",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
@ -17,4 +22,4 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@uswds/compile": "^1.0.0-beta.3"
|
"@uswds/compile": "^1.0.0-beta.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -537,7 +537,7 @@ class MyUserAdmin(BaseUserAdmin):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{"fields": ("username", "password", "status")},
|
{"fields": ("username", "password", "status", "verification_type")},
|
||||||
),
|
),
|
||||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||||
(
|
(
|
||||||
|
@ -555,13 +555,20 @@ class MyUserAdmin(BaseUserAdmin):
|
||||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readonly_fields = ("verification_type",)
|
||||||
|
|
||||||
# Hide Username (uuid), Groups and Permissions
|
# Hide Username (uuid), Groups and Permissions
|
||||||
# Q: Now that we're using Groups and Permissions,
|
# Q: Now that we're using Groups and Permissions,
|
||||||
# do we expose those to analysts to view?
|
# do we expose those to analysts to view?
|
||||||
analyst_fieldsets = (
|
analyst_fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{"fields": ("status",)},
|
{
|
||||||
|
"fields": (
|
||||||
|
"status",
|
||||||
|
"verification_type",
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||||
(
|
(
|
||||||
|
@ -681,11 +688,14 @@ class MyUserAdmin(BaseUserAdmin):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
readonly_fields = list(self.readonly_fields)
|
||||||
|
|
||||||
if request.user.has_perm("registrar.full_access_permission"):
|
if request.user.has_perm("registrar.full_access_permission"):
|
||||||
return () # No read-only fields for all access users
|
return readonly_fields
|
||||||
# Return restrictive Read-only fields for analysts and
|
else:
|
||||||
# users who might not belong to groups
|
# Return restrictive Read-only fields for analysts and
|
||||||
return self.analyst_readonly_fields
|
# users who might not belong to groups
|
||||||
|
return self.analyst_readonly_fields
|
||||||
|
|
||||||
|
|
||||||
class HostIPInline(admin.StackedInline):
|
class HostIPInline(admin.StackedInline):
|
||||||
|
@ -1001,9 +1011,10 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"More details",
|
"Show details",
|
||||||
{
|
{
|
||||||
"classes": ["collapse"],
|
"classes": ["collapse--dotgov"],
|
||||||
|
"description": "Extends type of organization",
|
||||||
"fields": [
|
"fields": [
|
||||||
"federal_type",
|
"federal_type",
|
||||||
# "updated_federal_agency",
|
# "updated_federal_agency",
|
||||||
|
@ -1026,9 +1037,10 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"More details",
|
"Show details",
|
||||||
{
|
{
|
||||||
"classes": ["collapse"],
|
"classes": ["collapse--dotgov"],
|
||||||
|
"description": "Extends organization name and mailing address",
|
||||||
"fields": [
|
"fields": [
|
||||||
"address_line1",
|
"address_line1",
|
||||||
"address_line2",
|
"address_line2",
|
||||||
|
@ -1252,9 +1264,10 @@ class DomainRequestAdmin(ListHeaderAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"More details",
|
"Show details",
|
||||||
{
|
{
|
||||||
"classes": ["collapse"],
|
"classes": ["collapse--dotgov"],
|
||||||
|
"description": "Extends type of organization",
|
||||||
"fields": [
|
"fields": [
|
||||||
"federal_type",
|
"federal_type",
|
||||||
# "updated_federal_agency",
|
# "updated_federal_agency",
|
||||||
|
@ -1277,9 +1290,10 @@ class DomainRequestAdmin(ListHeaderAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"More details",
|
"Show details",
|
||||||
{
|
{
|
||||||
"classes": ["collapse"],
|
"classes": ["collapse--dotgov"],
|
||||||
|
"description": "Extends organization name and mailing address",
|
||||||
"fields": [
|
"fields": [
|
||||||
"address_line1",
|
"address_line1",
|
||||||
"address_line2",
|
"address_line2",
|
||||||
|
|
|
@ -233,10 +233,8 @@ function openInNewTab(el, removeAttribute = false){
|
||||||
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
|
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
|
||||||
// and a "to" select list; initialization is based off of the presence of the
|
// and a "to" select list; initialization is based off of the presence of the
|
||||||
// "to" select list
|
// "to" select list
|
||||||
checkToListThenInitWidget('id_other_contacts_to', 0);
|
checkToListThenInitWidget('id_groups_to', 0);
|
||||||
checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0);
|
checkToListThenInitWidget('id_user_permissions_to', 0);
|
||||||
checkToListThenInitWidget('id_current_websites_to', 0);
|
|
||||||
checkToListThenInitWidget('id_alternative_domains_to', 0);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
|
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
|
||||||
|
@ -245,215 +243,56 @@ function checkToListThenInitWidget(toListId, attempts) {
|
||||||
let toList = document.getElementById(toListId);
|
let toList = document.getElementById(toListId);
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
if (attempts < 6) {
|
if (attempts < 12) {
|
||||||
if ((toList !== null)) {
|
if (toList) {
|
||||||
// toList found, handle it
|
// toList found, handle it
|
||||||
// Add an event listener on the element
|
// Then get fromList and handle it
|
||||||
// Add disabled buttons on the element's great-grandparent
|
initializeWidgetOnList(toList, ".selector-chosen");
|
||||||
initializeWidgetOnToList(toList, toListId);
|
let fromList = toList.closest('.selector').querySelector(".selector-available select");
|
||||||
|
initializeWidgetOnList(fromList, ".selector-available");
|
||||||
} else {
|
} else {
|
||||||
// Element not found, check again after a delay
|
// Element not found, check again after a delay
|
||||||
setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second)
|
setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the widget:
|
// Initialize the widget:
|
||||||
// add related buttons to the widget for edit, delete and view
|
// Replace h2 with more semantic h3
|
||||||
// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons
|
function initializeWidgetOnList(list, parentId) {
|
||||||
function initializeWidgetOnToList(toList, toListId) {
|
if (list) {
|
||||||
// create the change button
|
// Get h2 and its container
|
||||||
let changeLink = createAndCustomizeLink(
|
const parentElement = list.closest(parentId);
|
||||||
toList,
|
const h2Element = parentElement.querySelector('h2');
|
||||||
toListId,
|
|
||||||
'related-widget-wrapper-link change-related',
|
|
||||||
'Change',
|
|
||||||
'/public/admin/img/icon-changelink.svg',
|
|
||||||
{
|
|
||||||
'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1',
|
|
||||||
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
|
|
||||||
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
let hasDeletePermission = hasDeletePermissionOnPage();
|
// One last check
|
||||||
|
if (parentElement && h2Element) {
|
||||||
|
// Create a new <h3> element
|
||||||
|
const h3Element = document.createElement('h3');
|
||||||
|
|
||||||
let deleteLink = null;
|
// Copy the text content from the <h2> element to the <h3> element
|
||||||
if (hasDeletePermission) {
|
h3Element.textContent = h2Element.textContent;
|
||||||
// create the delete button if user has permission to delete
|
|
||||||
deleteLink = createAndCustomizeLink(
|
|
||||||
toList,
|
|
||||||
toListId,
|
|
||||||
'related-widget-wrapper-link delete-related',
|
|
||||||
'Delete',
|
|
||||||
'/public/admin/img/icon-deletelink.svg',
|
|
||||||
{
|
|
||||||
'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&_popup=1',
|
|
||||||
'websites': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
|
|
||||||
'alternative_domains': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// create the view button
|
// Find the nested <span> element inside the <h2>
|
||||||
let viewLink = createAndCustomizeLink(
|
const nestedSpan = h2Element.querySelector('span[class][title]');
|
||||||
toList,
|
|
||||||
toListId,
|
|
||||||
'related-widget-wrapper-link view-related',
|
|
||||||
'View',
|
|
||||||
'/public/admin/img/icon-viewlink.svg',
|
|
||||||
{
|
|
||||||
'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id',
|
|
||||||
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
|
||||||
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
|
||||||
},
|
|
||||||
// NOTE: If we open view in the same window then use the back button
|
|
||||||
// to go back, the 'chosen' list will fail to initialize correctly in
|
|
||||||
// sandbozes (but will work fine on local). This is related to how the
|
|
||||||
// Django JS runs (SelectBox.js) and is probably due to a race condition.
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
// identify the fromList element in the DOM
|
// If the nested <span> element exists
|
||||||
let fromList = toList.closest('.selector').querySelector(".selector-available select");
|
if (nestedSpan) {
|
||||||
|
// Create a new <span> element
|
||||||
|
const newSpan = document.createElement('span');
|
||||||
|
|
||||||
fromList.addEventListener('click', function(event) {
|
// Copy the class and title attributes from the nested <span> element
|
||||||
handleSelectClick(fromList, changeLink, deleteLink, viewLink);
|
newSpan.className = nestedSpan.className;
|
||||||
});
|
newSpan.title = nestedSpan.title;
|
||||||
|
|
||||||
toList.addEventListener('click', function(event) {
|
|
||||||
handleSelectClick(toList, changeLink, deleteLink, viewLink);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable buttons when the selectors are interacted with (items are moved from one column to the other)
|
|
||||||
let selectorButtons = [];
|
|
||||||
selectorButtons.push(toList.closest(".selector").querySelector(".selector-chooseall"));
|
|
||||||
selectorButtons.push(toList.closest(".selector").querySelector(".selector-add"));
|
|
||||||
selectorButtons.push(toList.closest(".selector").querySelector(".selector-remove"));
|
|
||||||
|
|
||||||
selectorButtons.forEach((selector) => {
|
// Append the new <span> element to the <h3> element
|
||||||
selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)});
|
h3Element.appendChild(newSpan);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// create and customize the button, then add to the DOM, relative to the toList
|
|
||||||
// toList - the element in the DOM for the toList
|
|
||||||
// toListId - the ID of the element in the DOM
|
|
||||||
// className - className to add to the created link
|
|
||||||
// action - the action to perform on the item {change, delete, view}
|
|
||||||
// imgSrc - the img.src for the created link
|
|
||||||
// dataMappings - dictionary which relates toListId to href for the created link
|
|
||||||
// dataPopup - boolean for whether the link should produce a popup window
|
|
||||||
// firstPosition - boolean indicating if link should be first position in list of links, otherwise, should be last link
|
|
||||||
function createAndCustomizeLink(toList, toListId, className, action, imgSrc, dataMappings, dataPopup, firstPosition) {
|
|
||||||
// Create a link element
|
|
||||||
var link = document.createElement('a');
|
|
||||||
|
|
||||||
// Set class attribute for the link
|
|
||||||
link.className = className;
|
|
||||||
|
|
||||||
// Set id
|
|
||||||
// Determine function {change, link, view} from the className
|
|
||||||
// Add {function}_ to the beginning of the string
|
|
||||||
let modifiedLinkString = className.split('-')[0] + '_' + toListId;
|
|
||||||
// Remove '_to' from the end of the string
|
|
||||||
modifiedLinkString = modifiedLinkString.replace('_to', '');
|
|
||||||
link.id = modifiedLinkString;
|
|
||||||
|
|
||||||
// Set data-href-template
|
|
||||||
for (const [idPattern, template] of Object.entries(dataMappings)) {
|
|
||||||
if (toListId.includes(idPattern)) {
|
|
||||||
link.setAttribute('data-href-template', template);
|
|
||||||
break; // Stop checking once a match is found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataPopup)
|
|
||||||
link.setAttribute('data-popup', 'yes');
|
|
||||||
|
|
||||||
link.setAttribute('title-template', action + " selected item")
|
|
||||||
link.title = link.getAttribute('title-template');
|
|
||||||
|
|
||||||
// Create an 'img' element
|
|
||||||
var img = document.createElement('img');
|
|
||||||
|
|
||||||
// Set attributes for the new image
|
|
||||||
img.src = imgSrc;
|
|
||||||
img.alt = action;
|
|
||||||
|
|
||||||
// Append the image to the link
|
|
||||||
link.appendChild(img);
|
|
||||||
|
|
||||||
let relatedWidgetWrapper = toList.closest('.related-widget-wrapper');
|
|
||||||
// If firstPosition is true, insert link as the first child element
|
|
||||||
if (firstPosition) {
|
|
||||||
relatedWidgetWrapper.insertBefore(link, relatedWidgetWrapper.children[0]);
|
|
||||||
} else {
|
|
||||||
// otherwise, insert the link prior to the last child (which is a div)
|
|
||||||
// and also prior to any text elements immediately preceding the last
|
|
||||||
// child node
|
|
||||||
var lastChild = relatedWidgetWrapper.lastChild;
|
|
||||||
|
|
||||||
// Check if lastChild is an element node (not a text node, comment, etc.)
|
|
||||||
if (lastChild.nodeType === 1) {
|
|
||||||
var previousSibling = lastChild.previousSibling;
|
|
||||||
// need to work around some white space which has been inserted into the dom
|
|
||||||
while (previousSibling.nodeType !== 1) {
|
|
||||||
previousSibling = previousSibling.previousSibling;
|
|
||||||
}
|
}
|
||||||
relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling);
|
|
||||||
|
// Replace the <h2> element with the new <h3> element
|
||||||
|
parentElement.replaceChild(h3Element, h2Element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the link, which we'll use in the disable and enable functions
|
|
||||||
return link;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count
|
|
||||||
// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are
|
|
||||||
// associated with the selected item
|
|
||||||
function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) {
|
|
||||||
|
|
||||||
// If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them
|
|
||||||
if (selectElement.selectedOptions.length === 1) {
|
|
||||||
// enable buttons for selected item in selectElement
|
|
||||||
enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text);
|
|
||||||
} else {
|
|
||||||
disableRelatedWidgetButtons(changeLink, deleteLink, viewLink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// return true if there exist elements on the page with classname of delete-related.
|
|
||||||
// presence of one or more of these elements indicates user has permission to delete
|
|
||||||
function hasDeletePermissionOnPage() {
|
|
||||||
return document.querySelector('.delete-related') != null
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) {
|
|
||||||
changeLink.removeAttribute('href');
|
|
||||||
changeLink.setAttribute('title', changeLink.getAttribute('title-template'));
|
|
||||||
if (deleteLink) {
|
|
||||||
deleteLink.removeAttribute('href');
|
|
||||||
deleteLink.setAttribute('title', deleteLink.getAttribute('title-template'));
|
|
||||||
}
|
|
||||||
viewLink.removeAttribute('href');
|
|
||||||
viewLink.setAttribute('title', viewLink.getAttribute('title-template'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) {
|
|
||||||
changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk));
|
|
||||||
changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText));
|
|
||||||
if (deleteLink) {
|
|
||||||
deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk));
|
|
||||||
deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText));
|
|
||||||
}
|
|
||||||
viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk));
|
|
||||||
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
||||||
|
|
|
@ -542,17 +542,30 @@ address.dja-address-contact-list {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse button styles for fieldsets
|
// Collapse button styles for fieldsets
|
||||||
.module.collapse {
|
.module.collapse--dotgov {
|
||||||
margin-top: -35px;
|
margin-top: -35px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
border: none;
|
border: none;
|
||||||
h2 {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--body-fg)!important;
|
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--link-fg);
|
color: var(--link-fg);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 10px;
|
||||||
|
span {
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 13px;
|
||||||
|
font-feature-settings: "kern";
|
||||||
|
font-kerning: normal;
|
||||||
|
line-height: 13px;
|
||||||
|
font-family: -apple-system, "system-ui", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.collapse--dotgov.collapsed .collapse-toggle--dotgov {
|
||||||
|
display: inline-block!important;
|
||||||
|
* {
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -634,3 +647,32 @@ address.dja-address-contact-list {
|
||||||
.usa-button__small-text {
|
.usa-button__small-text {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get rid of padding on all help texts
|
||||||
|
form .aligned p.help, form .aligned div.help {
|
||||||
|
padding-left: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We override the DJA header on multi list selects from h2 to h3
|
||||||
|
// The following block of code styles our generated h3s to match the old h2s
|
||||||
|
.selector .selector-available h3 {
|
||||||
|
background: var(--darkened-bg);
|
||||||
|
color: var(--body-quiet-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-available h3, .selector-chosen h3 {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector .selector-chosen h3 {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--header-link-color);
|
||||||
|
}
|
||||||
|
|
21
src/registrar/assets/sass/_theme/_links.scss
Normal file
21
src/registrar/assets/sass/_theme/_links.scss
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
@use "uswds-core" as *;
|
||||||
|
|
||||||
|
.dotgov-table {
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: color('primary');
|
||||||
|
|
||||||
|
&:visited {
|
||||||
|
color: color('primary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
.usa-icon {
|
||||||
|
// align icon with x height
|
||||||
|
margin-top: units(0.5);
|
||||||
|
margin-right: units(0.5);
|
||||||
|
}
|
||||||
|
}
|
|
@ -56,22 +56,6 @@
|
||||||
.dotgov-table {
|
.dotgov-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
color: color('primary');
|
|
||||||
|
|
||||||
&:visited {
|
|
||||||
color: color('primary');
|
|
||||||
}
|
|
||||||
|
|
||||||
.usa-icon {
|
|
||||||
// align icon with x height
|
|
||||||
margin-top: units(0.5);
|
|
||||||
margin-right: units(0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
|
@ -108,12 +92,51 @@
|
||||||
padding: units(2) units(2) units(2) 0;
|
padding: units(2) units(2) units(2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:first-of-type {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead tr:first-child th:first-child {
|
thead tr:first-child th:first-child {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 1040px){
|
||||||
|
.dotgov-table__domain-requests {
|
||||||
|
th:nth-of-type(1) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(2) {
|
||||||
|
width: 158px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(3) {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(4) {
|
||||||
|
width: 95px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(5) {
|
||||||
|
width: 85px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1040px){
|
||||||
|
.dotgov-table__registered-domains {
|
||||||
|
th:nth-of-type(1) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(2) {
|
||||||
|
width: 158px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(3) {
|
||||||
|
width: 215px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-of-type(4) {
|
||||||
|
width: 95px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@
|
||||||
--- Custom Styles ---------------------------------*/
|
--- Custom Styles ---------------------------------*/
|
||||||
@forward "base";
|
@forward "base";
|
||||||
@forward "typography";
|
@forward "typography";
|
||||||
|
@forward "links";
|
||||||
@forward "lists";
|
@forward "lists";
|
||||||
@forward "buttons";
|
@forward "buttons";
|
||||||
@forward "forms";
|
@forward "forms";
|
||||||
|
|
|
@ -7,6 +7,7 @@ from registrar.models import (
|
||||||
UserGroup,
|
UserGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -207,6 +208,10 @@ class UserFixture:
|
||||||
user.email = user_data["email"]
|
user.email = user_data["email"]
|
||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
|
# This verification type will get reverted to "regular" (or whichever is applicables)
|
||||||
|
# once the user logs in for the first time (as they then got verified through different means).
|
||||||
|
# In the meantime, we can still describe how the user got here in the first place.
|
||||||
|
user.verification_type = User.VerificationTypeChoices.FIXTURE_USER
|
||||||
group = UserGroup.objects.get(name=group_name)
|
group = UserGroup.objects.get(name=group_name)
|
||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import logging
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||||
|
from registrar.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand, PopulateScriptTemplate):
|
||||||
|
help = "Loops through each valid User object and updates its verification_type value"
|
||||||
|
|
||||||
|
def handle(self, **kwargs):
|
||||||
|
"""Loops through each valid User object and updates its verification_type value"""
|
||||||
|
filter_condition = {"verification_type__isnull": True}
|
||||||
|
self.mass_populate_field(User, filter_condition, ["verification_type"])
|
||||||
|
|
||||||
|
def populate_field(self, field_to_update):
|
||||||
|
"""Defines how we update the verification_type field"""
|
||||||
|
field_to_update.set_user_verification_type()
|
||||||
|
logger.info(
|
||||||
|
f"{TerminalColors.OKCYAN}Updating {field_to_update} => "
|
||||||
|
f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
|
||||||
|
)
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from typing import List
|
from typing import List
|
||||||
from registrar.utility.enums import LogCode
|
from registrar.utility.enums import LogCode
|
||||||
|
@ -58,6 +59,55 @@ class ScriptDataHelper:
|
||||||
model_class.objects.bulk_update(page.object_list, fields_to_update)
|
model_class.objects.bulk_update(page.object_list, fields_to_update)
|
||||||
|
|
||||||
|
|
||||||
|
class PopulateScriptTemplate(ABC):
|
||||||
|
"""
|
||||||
|
Contains an ABC for generic populate scripts
|
||||||
|
"""
|
||||||
|
|
||||||
|
def mass_populate_field(self, sender, filter_conditions, fields_to_update):
|
||||||
|
"""Loops through each valid "sender" object - specified by filter_conditions - and
|
||||||
|
updates fields defined by fields_to_update using populate_function.
|
||||||
|
|
||||||
|
You must define populate_field before you can use this function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
objects = sender.objects.filter(**filter_conditions)
|
||||||
|
|
||||||
|
# Code execution will stop here if the user prompts "N"
|
||||||
|
TerminalHelper.prompt_for_execution(
|
||||||
|
system_exit_on_terminate=True,
|
||||||
|
info_to_inspect=f"""
|
||||||
|
==Proposed Changes==
|
||||||
|
Number of {sender} objects to change: {len(objects)}
|
||||||
|
These fields will be updated on each record: {fields_to_update}
|
||||||
|
""",
|
||||||
|
prompt_title="Do you wish to patch this data?",
|
||||||
|
)
|
||||||
|
logger.info("Updating...")
|
||||||
|
|
||||||
|
to_update: List[sender] = []
|
||||||
|
failed_to_update: List[sender] = []
|
||||||
|
for updated_object in objects:
|
||||||
|
try:
|
||||||
|
self.populate_field(updated_object)
|
||||||
|
to_update.append(updated_object)
|
||||||
|
except Exception as err:
|
||||||
|
failed_to_update.append(updated_object)
|
||||||
|
logger.error(err)
|
||||||
|
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}")
|
||||||
|
|
||||||
|
# Do a bulk update on the first_ready field
|
||||||
|
ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update)
|
||||||
|
|
||||||
|
# Log what happened
|
||||||
|
TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def populate_field(self, field_to_update):
|
||||||
|
"""Defines how we update each field. Must be defined before using mass_populate_field."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class TerminalHelper:
|
class TerminalHelper:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
|
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
|
||||||
|
|
29
src/registrar/migrations/0089_user_verification_type.py
Normal file
29
src/registrar/migrations/0089_user_verification_type.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-04-26 14:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0088_domaininformation_cisa_representative_email_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="verification_type",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("grandfathered", "Legacy user"),
|
||||||
|
("verified_by_staff", "Verified by staff"),
|
||||||
|
("regular", "Verified by Login.gov"),
|
||||||
|
("invited", "Invited by a domain manager"),
|
||||||
|
("fixture_user", "Created by fixtures"),
|
||||||
|
],
|
||||||
|
help_text="The means through which this user was verified",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,6 +2,7 @@ import logging
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
|
||||||
|
@ -23,6 +24,28 @@ class User(AbstractUser):
|
||||||
but can be customized later.
|
but can be customized later.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class VerificationTypeChoices(models.TextChoices):
|
||||||
|
"""
|
||||||
|
Users achieve access to our system in a few different ways.
|
||||||
|
These choices reflect those pathways.
|
||||||
|
|
||||||
|
Overview of verification types:
|
||||||
|
- GRANDFATHERED: User exists in the `TransitionDomain` table
|
||||||
|
- VERIFIED_BY_STAFF: User exists in the `VerifiedByStaff` table
|
||||||
|
- INVITED: User exists in the `DomainInvitation` table
|
||||||
|
- REGULAR: User was verified through IAL2
|
||||||
|
- FIXTURE_USER: User was created by fixtures
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRANDFATHERED = "grandfathered", "Legacy user"
|
||||||
|
VERIFIED_BY_STAFF = "verified_by_staff", "Verified by staff"
|
||||||
|
REGULAR = "regular", "Verified by Login.gov"
|
||||||
|
INVITED = "invited", "Invited by a domain manager"
|
||||||
|
# We need a type for fixture users (rather than using verified by staff)
|
||||||
|
# because those users still do get "verified" through normal means
|
||||||
|
# after they login.
|
||||||
|
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
||||||
|
|
||||||
# #### Constants for choice fields ####
|
# #### Constants for choice fields ####
|
||||||
RESTRICTED = "restricted"
|
RESTRICTED = "restricted"
|
||||||
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
||||||
|
@ -50,6 +73,13 @@ class User(AbstractUser):
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
verification_type = models.CharField(
|
||||||
|
choices=VerificationTypeChoices.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="The means through which this user was verified",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# this info is pulled from Login.gov
|
# this info is pulled from Login.gov
|
||||||
if self.first_name or self.last_name:
|
if self.first_name or self.last_name:
|
||||||
|
@ -114,23 +144,61 @@ class User(AbstractUser):
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise err
|
raise err
|
||||||
|
|
||||||
# A new incoming user who is a domain manager for one of the domains
|
# We can't set the verification type here because the user may not
|
||||||
# that we inputted from Verisign (that is, their email address appears
|
# always exist at this point. We do it down the line.
|
||||||
# in the username field of a TransitionDomain)
|
verification_type = cls.get_verification_type_from_email(email)
|
||||||
if TransitionDomain.objects.filter(username=email).exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# New users flagged by Staff to bypass ial2
|
# Checks if the user needs verification.
|
||||||
if VerifiedByStaff.objects.filter(email=email).exists():
|
# The user needs identity verification if they don't meet
|
||||||
return False
|
# any special criteria, i.e. we are validating them "regularly"
|
||||||
|
return verification_type == cls.VerificationTypeChoices.REGULAR
|
||||||
|
|
||||||
# A new incoming user who is being invited to be a domain manager (that is,
|
def set_user_verification_type(self):
|
||||||
# their email address is in DomainInvitation for an invitation that is not yet "retrieved").
|
"""
|
||||||
invited = DomainInvitation.DomainInvitationStatus.INVITED
|
Given pre-existing data from TransitionDomain, VerifiedByStaff, and DomainInvitation,
|
||||||
if DomainInvitation.objects.filter(email=email, status=invited).exists():
|
set the verification "type" defined in VerificationTypeChoices.
|
||||||
return False
|
"""
|
||||||
|
email_or_username = self.email if self.email else self.username
|
||||||
|
retrieved = DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||||
|
verification_type = self.get_verification_type_from_email(email_or_username, invitation_status=retrieved)
|
||||||
|
|
||||||
return True
|
# An existing user may have been invited to a domain after they got verified.
|
||||||
|
# We need to check for this condition.
|
||||||
|
if verification_type == User.VerificationTypeChoices.INVITED:
|
||||||
|
invitation = (
|
||||||
|
DomainInvitation.objects.filter(email=email_or_username, status=retrieved)
|
||||||
|
.order_by("created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
# If you joined BEFORE the oldest invitation was created, then you were verified normally.
|
||||||
|
# (See logic in get_verification_type_from_email)
|
||||||
|
if not invitation and self.date_joined < invitation.created_at:
|
||||||
|
verification_type = User.VerificationTypeChoices.REGULAR
|
||||||
|
|
||||||
|
self.verification_type = verification_type
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_verification_type_from_email(cls, email, invitation_status=DomainInvitation.DomainInvitationStatus.INVITED):
|
||||||
|
"""Retrieves the verification type based off of a provided email address"""
|
||||||
|
|
||||||
|
verification_type = None
|
||||||
|
if TransitionDomain.objects.filter(Q(username=email) | Q(email=email)).exists():
|
||||||
|
# A new incoming user who is a domain manager for one of the domains
|
||||||
|
# that we inputted from Verisign (that is, their email address appears
|
||||||
|
# in the username field of a TransitionDomain)
|
||||||
|
verification_type = cls.VerificationTypeChoices.GRANDFATHERED
|
||||||
|
elif VerifiedByStaff.objects.filter(email=email).exists():
|
||||||
|
# New users flagged by Staff to bypass ial2
|
||||||
|
verification_type = cls.VerificationTypeChoices.VERIFIED_BY_STAFF
|
||||||
|
elif DomainInvitation.objects.filter(email=email, status=invitation_status).exists():
|
||||||
|
# A new incoming user who is being invited to be a domain manager (that is,
|
||||||
|
# their email address is in DomainInvitation for an invitation that is not yet "retrieved").
|
||||||
|
verification_type = cls.VerificationTypeChoices.INVITED
|
||||||
|
else:
|
||||||
|
verification_type = cls.VerificationTypeChoices.REGULAR
|
||||||
|
|
||||||
|
return verification_type
|
||||||
|
|
||||||
def check_domain_invitations_on_login(self):
|
def check_domain_invitations_on_login(self):
|
||||||
"""When a user first arrives on the site, we need to retrieve any domain
|
"""When a user first arrives on the site, we need to retrieve any domain
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
||||||
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
|
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
|
||||||
|
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||||
|
|
|
@ -6,9 +6,23 @@ It is not inherently customizable on its own, so we can modify this instead.
|
||||||
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html
|
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<fieldset class="module aligned {{ fieldset.classes }}">
|
<fieldset class="module aligned {{ fieldset.classes }}">
|
||||||
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
|
{% if fieldset.name %}
|
||||||
|
{# Customize the markup for the collapse toggle #}
|
||||||
|
{% if 'collapse--dotgov' in fieldset.classes %}
|
||||||
|
<button type="button">
|
||||||
|
<span>{{ fieldset.name }}</span>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<legend class="sr-only">{{ fieldset.description }}</legend>
|
||||||
|
{% else %}
|
||||||
|
<h2>{{ fieldset.name }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if fieldset.description %}
|
{# Customize the markup for the collapse toggle: Do not show a description for the collapse fieldsets, instead we're using the description as a screen reader only legend #}
|
||||||
|
{% if fieldset.description and 'collapse--dotgov' not in fieldset.classes %}
|
||||||
<div class="description">{{ fieldset.description|safe }}</div>
|
<div class="description">{{ fieldset.description|safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
|
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
|
||||||
|
|
||||||
{% if show_formatted_name %}
|
{% if show_formatted_name %}
|
||||||
{% if contact.get_formatted_name %}
|
{% if user.get_formatted_name %}
|
||||||
<a href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a><br />
|
<a href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a><br />
|
||||||
{% else %}
|
{% else %}
|
||||||
None<br />
|
None<br />
|
||||||
|
@ -47,7 +47,12 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
None<br>
|
None<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
No additional contact information found.
|
No additional contact information found.<br>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_verification_type %}
|
||||||
|
{{ user_verification_type }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</address>
|
</address>
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
{% comment %}
|
{% comment %}
|
||||||
This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block field_readonly %}
|
{% block field_readonly %}
|
||||||
{% with all_contacts=original_object.other_contacts.all %}
|
{% with all_contacts=original_object.other_contacts.all %}
|
||||||
{% if field.field.name == "other_contacts" %}
|
{% if field.field.name == "other_contacts" %}
|
||||||
|
@ -66,16 +67,9 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endblock field_readonly %}
|
{% endblock field_readonly %}
|
||||||
|
|
||||||
{% block help_text %}
|
|
||||||
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
|
|
||||||
<div>{{ field.field.help_text|safe }}</div>
|
|
||||||
</div>
|
|
||||||
{% endblock help_text %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block after_help_text %}
|
{% block after_help_text %}
|
||||||
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
||||||
<div class="flex-container tablet:margin-top-1">
|
<div class="flex-container tablet:margin-top-2">
|
||||||
<label aria-label="Submitter contact details"></label>
|
<label aria-label="Submitter contact details"></label>
|
||||||
<div class="usa-table-container--scrollable" tabindex="0">
|
<div class="usa-table-container--scrollable" tabindex="0">
|
||||||
<table class="usa-table usa-table--borderless">
|
<table class="usa-table usa-table--borderless">
|
||||||
|
@ -102,14 +96,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif field.field.name == "creator" %}
|
{% if field.field.name == "creator" %}
|
||||||
<div class="flex-container tablet:margin-top-1">
|
<div class="flex-container tablet:margin-top-2">
|
||||||
<label aria-label="Creator contact details"></label>
|
<label aria-label="Creator contact details"></label>
|
||||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
|
||||||
</div>
|
</div>
|
||||||
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
||||||
{% elif field.field.name == "submitter" %}
|
{% elif field.field.name == "submitter" %}
|
||||||
<div class="flex-container">
|
<div class="flex-container tablet:margin-top-2">
|
||||||
<label aria-label="Submitter contact details"></label>
|
<label aria-label="Submitter contact details"></label>
|
||||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
|
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
<section class="section--outlined">
|
<section class="section--outlined">
|
||||||
<h2>Domains</h2>
|
<h2>Domains</h2>
|
||||||
{% if domains %}
|
{% if domains %}
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
|
||||||
<caption class="sr-only">Your registered domains</caption>
|
<caption class="sr-only">Your registered domains</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -98,13 +98,21 @@
|
||||||
></div>
|
></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>You don't have any registered domains.</p>
|
<p>You don't have any registered domains.</p>
|
||||||
|
<p class="maxw-none clearfix">
|
||||||
|
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||||
|
</svg>
|
||||||
|
Why don't I see my domain when I sign in to the registrar?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section--outlined">
|
<section class="section--outlined">
|
||||||
<h2>Domain requests</h2>
|
<h2>Domain requests</h2>
|
||||||
{% if domain_requests %}
|
{% if domain_requests %}
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
|
||||||
<caption class="sr-only">Your domain requests</caption>
|
<caption class="sr-only">Your domain requests</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -977,6 +977,26 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||||
self.assertContains(response, "<td>In review</td>", count=2)
|
self.assertContains(response, "<td>In review</td>", count=2)
|
||||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||||
|
|
||||||
|
def test_collaspe_toggle_button_markup(self):
|
||||||
|
"""
|
||||||
|
Tests for the correct collapse toggle button markup
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a fake domain request and domain
|
||||||
|
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||||
|
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
response = self.client.get(
|
||||||
|
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure the page loaded, and that we're on the right page
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, domain_request.requested_domain.name)
|
||||||
|
self.test_helper.assertContains(response, "<span>Show details</span>")
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_analyst_can_see_and_edit_alternative_domain(self):
|
def test_analyst_can_see_and_edit_alternative_domain(self):
|
||||||
|
@ -3134,7 +3154,15 @@ class TestMyUserAdmin(TestCase):
|
||||||
request.user = create_user()
|
request.user = create_user()
|
||||||
fieldsets = self.admin.get_fieldsets(request)
|
fieldsets = self.admin.get_fieldsets(request)
|
||||||
expected_fieldsets = (
|
expected_fieldsets = (
|
||||||
(None, {"fields": ("status",)}),
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"status",
|
||||||
|
"verification_type",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||||
("Permissions", {"fields": ("is_active", "groups")}),
|
("Permissions", {"fields": ("is_active", "groups")}),
|
||||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||||
|
|
|
@ -14,8 +14,9 @@ from registrar.models import (
|
||||||
TransitionDomain,
|
TransitionDomain,
|
||||||
DomainInformation,
|
DomainInformation,
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
|
VerifiedByStaff,
|
||||||
|
PublicContact,
|
||||||
)
|
)
|
||||||
from registrar.models.public_contact import PublicContact
|
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from unittest.mock import patch, call
|
from unittest.mock import patch, call
|
||||||
|
@ -25,6 +26,103 @@ from .common import MockEppLib, less_console_noise, completed_domain_request
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
|
||||||
|
|
||||||
|
class TestPopulateVerificationType(MockEppLib):
|
||||||
|
"""Tests for the populate_organization_type script"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Creates a fake domain object"""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Get the domain requests
|
||||||
|
self.domain_request_1 = completed_domain_request(
|
||||||
|
name="lasers.gov",
|
||||||
|
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||||
|
is_election_board=True,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Approve the request
|
||||||
|
self.domain_request_1.approve()
|
||||||
|
|
||||||
|
# Get the domains
|
||||||
|
self.domain_1 = Domain.objects.get(name="lasers.gov")
|
||||||
|
|
||||||
|
# Get users
|
||||||
|
self.regular_user, _ = User.objects.get_or_create(username="testuser@igormail.gov")
|
||||||
|
|
||||||
|
vip, _ = VerifiedByStaff.objects.get_or_create(email="vipuser@igormail.gov")
|
||||||
|
self.verified_by_staff_user, _ = User.objects.get_or_create(username="vipuser@igormail.gov")
|
||||||
|
|
||||||
|
grandfathered, _ = TransitionDomain.objects.get_or_create(
|
||||||
|
username="grandpa@igormail.gov", domain_name=self.domain_1.name
|
||||||
|
)
|
||||||
|
self.grandfathered_user, _ = User.objects.get_or_create(username="grandpa@igormail.gov")
|
||||||
|
|
||||||
|
invited, _ = DomainInvitation.objects.get_or_create(
|
||||||
|
email="invited@igormail.gov", domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||||
|
)
|
||||||
|
self.invited_user, _ = User.objects.get_or_create(username="invited@igormail.gov")
|
||||||
|
|
||||||
|
self.untouched_user, _ = User.objects.get_or_create(
|
||||||
|
username="iaminvincible@igormail.gov", verification_type=User.VerificationTypeChoices.GRANDFATHERED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fixture users should be untouched by the script. These will auto update once the
|
||||||
|
# user logs in / creates an account.
|
||||||
|
self.fixture_user, _ = User.objects.get_or_create(
|
||||||
|
username="fixture@igormail.gov", verification_type=User.VerificationTypeChoices.FIXTURE_USER
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Deletes all DB objects related to migrations"""
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
# Delete domains and related information
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
|
DomainRequest.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
Website.objects.all().delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def run_populate_verification_type(self):
|
||||||
|
"""
|
||||||
|
This method executes the populate_organization_type command.
|
||||||
|
|
||||||
|
The 'call_command' function from Django's management framework is then used to
|
||||||
|
execute the populate_organization_type command with the specified arguments.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
call_command("populate_verification_type")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_verification_type_script_populates_data(self):
|
||||||
|
"""Ensures that the verification type script actually populates data"""
|
||||||
|
|
||||||
|
# Run the script
|
||||||
|
self.run_populate_verification_type()
|
||||||
|
|
||||||
|
# Scripts don't work as we'd expect in our test environment, we need to manually
|
||||||
|
# trigger the refresh event
|
||||||
|
self.regular_user.refresh_from_db()
|
||||||
|
self.grandfathered_user.refresh_from_db()
|
||||||
|
self.invited_user.refresh_from_db()
|
||||||
|
self.verified_by_staff_user.refresh_from_db()
|
||||||
|
self.untouched_user.refresh_from_db()
|
||||||
|
|
||||||
|
# Test all users
|
||||||
|
self.assertEqual(self.regular_user.verification_type, User.VerificationTypeChoices.REGULAR)
|
||||||
|
self.assertEqual(self.grandfathered_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||||
|
self.assertEqual(self.invited_user.verification_type, User.VerificationTypeChoices.INVITED)
|
||||||
|
self.assertEqual(self.verified_by_staff_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF)
|
||||||
|
self.assertEqual(self.untouched_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||||
|
self.assertEqual(self.fixture_user.verification_type, User.VerificationTypeChoices.FIXTURE_USER)
|
||||||
|
|
||||||
|
|
||||||
class TestPopulateOrganizationType(MockEppLib):
|
class TestPopulateOrganizationType(MockEppLib):
|
||||||
"""Tests for the populate_organization_type script"""
|
"""Tests for the populate_organization_type script"""
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
|
from datetime import date
|
||||||
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 api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
from registrar.models.contact import Contact
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
|
from registrar.models.draft_domain import DraftDomain
|
||||||
|
from registrar.models.user import User
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.views.domain import DomainNameserversView
|
from registrar.views.domain import DomainNameserversView
|
||||||
|
|
||||||
from .common import MockEppLib # type: ignore
|
from .common import MockEppLib, less_console_noise # type: ignore
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
@ -135,3 +139,369 @@ class TestEnvironmentVariablesEffects(TestCase):
|
||||||
self.assertEqual(contact_page_500.status_code, 500)
|
self.assertEqual(contact_page_500.status_code, 500)
|
||||||
|
|
||||||
self.assertNotContains(contact_page_500, "You are on a test site.")
|
self.assertNotContains(contact_page_500, "You are on a test site.")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeTests(TestWithUser):
|
||||||
|
"""A series of tests that target the two tables on home.html"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
|
||||||
|
def test_empty_domain_table(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertContains(response, "You don't have any registered domains.")
|
||||||
|
self.assertContains(response, "Why don't I see my domain when I sign in to the registrar?")
|
||||||
|
|
||||||
|
def test_home_lists_domain_requests(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertNotContains(response, "igorville.gov")
|
||||||
|
site = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site)
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
# count = 7 because of screenreader content
|
||||||
|
self.assertContains(response, "igorville.gov", count=7)
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
domain_request.delete()
|
||||||
|
|
||||||
|
def test_state_help_text(self):
|
||||||
|
"""Tests if each domain state has help text"""
|
||||||
|
|
||||||
|
# Get the expected text content of each state
|
||||||
|
deleted_text = "This domain has been removed and " "is no longer registered to your organization."
|
||||||
|
dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses."
|
||||||
|
ready_text = "This domain has name servers and is ready for use."
|
||||||
|
on_hold_text = (
|
||||||
|
"This domain is administratively paused, "
|
||||||
|
"so it can’t be edited and won’t resolve in DNS. "
|
||||||
|
"Contact help@get.gov for details."
|
||||||
|
)
|
||||||
|
deleted_text = "This domain has been removed and " "is no longer registered to your organization."
|
||||||
|
# Generate a mapping of domain names, the state, and expected messages for the subtest
|
||||||
|
test_cases = [
|
||||||
|
("deleted.gov", Domain.State.DELETED, deleted_text),
|
||||||
|
("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text),
|
||||||
|
("unknown.gov", Domain.State.UNKNOWN, dns_needed_text),
|
||||||
|
("onhold.gov", Domain.State.ON_HOLD, on_hold_text),
|
||||||
|
("ready.gov", Domain.State.READY, ready_text),
|
||||||
|
]
|
||||||
|
for domain_name, state, expected_message in test_cases:
|
||||||
|
with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message):
|
||||||
|
# Create a domain and a UserRole with the given params
|
||||||
|
test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state)
|
||||||
|
test_domain.expiration_date = date.today()
|
||||||
|
test_domain.save()
|
||||||
|
|
||||||
|
user_role, _ = UserDomainRole.objects.get_or_create(
|
||||||
|
user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grab the home page
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
# Make sure the user can actually see the domain.
|
||||||
|
# We expect two instances because of SR content.
|
||||||
|
self.assertContains(response, domain_name, count=2)
|
||||||
|
|
||||||
|
# Check that we have the right text content.
|
||||||
|
self.assertContains(response, expected_message, count=1)
|
||||||
|
|
||||||
|
# Delete the role and domain to ensure we're testing in isolation
|
||||||
|
user_role.delete()
|
||||||
|
test_domain.delete()
|
||||||
|
|
||||||
|
def test_state_help_text_expired(self):
|
||||||
|
"""Tests if each domain state has help text when expired"""
|
||||||
|
expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
|
||||||
|
test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY)
|
||||||
|
test_domain.expiration_date = date(2011, 10, 10)
|
||||||
|
test_domain.save()
|
||||||
|
|
||||||
|
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Grab the home page
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
# Make sure the user can actually see the domain.
|
||||||
|
# We expect two instances because of SR content.
|
||||||
|
self.assertContains(response, "expired.gov", count=2)
|
||||||
|
|
||||||
|
# Check that we have the right text content.
|
||||||
|
self.assertContains(response, expired_text, count=1)
|
||||||
|
|
||||||
|
def test_state_help_text_no_expiration_date(self):
|
||||||
|
"""Tests if each domain state has help text when expiration date is None"""
|
||||||
|
|
||||||
|
# == Test a expiration of None for state ready. This should be expired. == #
|
||||||
|
expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
|
||||||
|
test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY)
|
||||||
|
test_domain.expiration_date = None
|
||||||
|
test_domain.save()
|
||||||
|
|
||||||
|
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Grab the home page
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
# Make sure the user can actually see the domain.
|
||||||
|
# We expect two instances because of SR content.
|
||||||
|
self.assertContains(response, "imexpired.gov", count=2)
|
||||||
|
|
||||||
|
# Make sure the expiration date is None
|
||||||
|
self.assertEqual(test_domain.expiration_date, None)
|
||||||
|
|
||||||
|
# Check that we have the right text content.
|
||||||
|
self.assertContains(response, expired_text, count=1)
|
||||||
|
|
||||||
|
# == Test a expiration of None for state unknown. This should not display expired text. == #
|
||||||
|
unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses."
|
||||||
|
test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN)
|
||||||
|
test_domain_2.expiration_date = None
|
||||||
|
test_domain_2.save()
|
||||||
|
|
||||||
|
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Grab the home page
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
# Make sure the user can actually see the domain.
|
||||||
|
# We expect two instances because of SR content.
|
||||||
|
self.assertContains(response, "notexpired.gov", count=2)
|
||||||
|
|
||||||
|
# Make sure the expiration date is None
|
||||||
|
self.assertEqual(test_domain_2.expiration_date, None)
|
||||||
|
|
||||||
|
# Check that we have the right text content.
|
||||||
|
self.assertContains(response, unknown_text, count=1)
|
||||||
|
|
||||||
|
def test_home_deletes_withdrawn_domain_request(self):
|
||||||
|
"""Tests if the user can delete a DomainRequest in the 'withdrawn' status"""
|
||||||
|
|
||||||
|
site = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
domain_request = DomainRequest.objects.create(
|
||||||
|
creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure that igorville.gov exists on the page
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertContains(home_page, "igorville.gov")
|
||||||
|
|
||||||
|
# Check if the delete button exists. We can do this by checking for its id and text content.
|
||||||
|
self.assertContains(home_page, "Delete")
|
||||||
|
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
|
||||||
|
|
||||||
|
# Trigger the delete logic
|
||||||
|
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
||||||
|
|
||||||
|
self.assertNotContains(response, "igorville.gov")
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
domain_request.delete()
|
||||||
|
|
||||||
|
def test_home_deletes_started_domain_request(self):
|
||||||
|
"""Tests if the user can delete a DomainRequest in the 'started' status"""
|
||||||
|
|
||||||
|
site = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
domain_request = DomainRequest.objects.create(
|
||||||
|
creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure that igorville.gov exists on the page
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertContains(home_page, "igorville.gov")
|
||||||
|
|
||||||
|
# Check if the delete button exists. We can do this by checking for its id and text content.
|
||||||
|
self.assertContains(home_page, "Delete")
|
||||||
|
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
|
||||||
|
|
||||||
|
# Trigger the delete logic
|
||||||
|
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
||||||
|
|
||||||
|
self.assertNotContains(response, "igorville.gov")
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
domain_request.delete()
|
||||||
|
|
||||||
|
def test_home_doesnt_delete_other_domain_requests(self):
|
||||||
|
"""Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN"""
|
||||||
|
|
||||||
|
# Given that we are including a subset of items that can be deleted while excluding the rest,
|
||||||
|
# subTest is appropriate here as otherwise we would need many duplicate tests for the same reason.
|
||||||
|
with less_console_noise():
|
||||||
|
draft_domain = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
for status in DomainRequest.DomainRequestStatus:
|
||||||
|
if status not in [
|
||||||
|
DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||||
|
]:
|
||||||
|
with self.subTest(status=status):
|
||||||
|
domain_request = DomainRequest.objects.create(
|
||||||
|
creator=self.user, requested_domain=draft_domain, status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger the delete logic
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for a 403 error - the end user should not be allowed to do this
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain)
|
||||||
|
|
||||||
|
# Make sure the DomainRequest wasn't deleted
|
||||||
|
self.assertEqual(desired_domain_request.count(), 1)
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
domain_request.delete()
|
||||||
|
|
||||||
|
def test_home_deletes_domain_request_and_orphans(self):
|
||||||
|
"""Tests if delete for DomainRequest deletes orphaned Contact objects"""
|
||||||
|
|
||||||
|
# Create the site and contacts to delete (orphaned)
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
first_name="Henry",
|
||||||
|
last_name="Mcfakerson",
|
||||||
|
)
|
||||||
|
contact_shared = Contact.objects.create(
|
||||||
|
first_name="Relative",
|
||||||
|
last_name="Aether",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create two non-orphaned contacts
|
||||||
|
contact_2 = Contact.objects.create(
|
||||||
|
first_name="Saturn",
|
||||||
|
last_name="Mars",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach a user object to a contact (should not be deleted)
|
||||||
|
contact_user, _ = Contact.objects.get_or_create(user=self.user)
|
||||||
|
|
||||||
|
site = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
domain_request = DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=site,
|
||||||
|
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||||
|
authorizing_official=contact,
|
||||||
|
submitter=contact_user,
|
||||||
|
)
|
||||||
|
domain_request.other_contacts.set([contact_2])
|
||||||
|
|
||||||
|
# Create a second domain request to attach contacts to
|
||||||
|
site_2 = DraftDomain.objects.create(name="teaville.gov")
|
||||||
|
domain_request_2 = DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=site_2,
|
||||||
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
authorizing_official=contact_2,
|
||||||
|
submitter=contact_shared,
|
||||||
|
)
|
||||||
|
domain_request_2.other_contacts.set([contact_shared])
|
||||||
|
|
||||||
|
# Ensure that igorville.gov exists on the page
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertContains(home_page, "igorville.gov")
|
||||||
|
|
||||||
|
# Trigger the delete logic
|
||||||
|
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
||||||
|
|
||||||
|
# igorville is now deleted
|
||||||
|
self.assertNotContains(response, "igorville.gov")
|
||||||
|
|
||||||
|
# Check if the orphaned contact was deleted
|
||||||
|
orphan = Contact.objects.filter(id=contact.id)
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
|
||||||
|
# All non-orphan contacts should still exist and are unaltered
|
||||||
|
try:
|
||||||
|
current_user = Contact.objects.filter(id=contact_user.id).get()
|
||||||
|
except Contact.DoesNotExist:
|
||||||
|
self.fail("contact_user (a non-orphaned contact) was deleted")
|
||||||
|
|
||||||
|
self.assertEqual(current_user, contact_user)
|
||||||
|
try:
|
||||||
|
edge_case = Contact.objects.filter(id=contact_2.id).get()
|
||||||
|
except Contact.DoesNotExist:
|
||||||
|
self.fail("contact_2 (a non-orphaned contact) was deleted")
|
||||||
|
|
||||||
|
self.assertEqual(edge_case, contact_2)
|
||||||
|
|
||||||
|
def test_home_deletes_domain_request_and_shared_orphans(self):
|
||||||
|
"""Test the edge case for an object that will become orphaned after a delete
|
||||||
|
(but is not an orphan at the time of deletion)"""
|
||||||
|
|
||||||
|
# Create the site and contacts to delete (orphaned)
|
||||||
|
contact = Contact.objects.create(
|
||||||
|
first_name="Henry",
|
||||||
|
last_name="Mcfakerson",
|
||||||
|
)
|
||||||
|
contact_shared = Contact.objects.create(
|
||||||
|
first_name="Relative",
|
||||||
|
last_name="Aether",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create two non-orphaned contacts
|
||||||
|
contact_2 = Contact.objects.create(
|
||||||
|
first_name="Saturn",
|
||||||
|
last_name="Mars",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach a user object to a contact (should not be deleted)
|
||||||
|
contact_user, _ = Contact.objects.get_or_create(user=self.user)
|
||||||
|
|
||||||
|
site = DraftDomain.objects.create(name="igorville.gov")
|
||||||
|
domain_request = DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=site,
|
||||||
|
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||||
|
authorizing_official=contact,
|
||||||
|
submitter=contact_user,
|
||||||
|
)
|
||||||
|
domain_request.other_contacts.set([contact_2])
|
||||||
|
|
||||||
|
# Create a second domain request to attach contacts to
|
||||||
|
site_2 = DraftDomain.objects.create(name="teaville.gov")
|
||||||
|
domain_request_2 = DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=site_2,
|
||||||
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
authorizing_official=contact_2,
|
||||||
|
submitter=contact_shared,
|
||||||
|
)
|
||||||
|
domain_request_2.other_contacts.set([contact_shared])
|
||||||
|
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertContains(home_page, "teaville.gov")
|
||||||
|
|
||||||
|
# Trigger the delete logic
|
||||||
|
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True)
|
||||||
|
|
||||||
|
self.assertNotContains(response, "teaville.gov")
|
||||||
|
|
||||||
|
# Check if the orphaned contact was deleted
|
||||||
|
orphan = Contact.objects.filter(id=contact_shared.id)
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
|
||||||
|
def test_domain_request_form_view(self):
|
||||||
|
response = self.client.get("/request/", follow=True)
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
"You’re about to start your .gov domain request.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_domain_request_form_with_ineligible_user(self):
|
||||||
|
"""Domain request form not accessible for an ineligible user.
|
||||||
|
This test should be solid enough since all domain request wizard
|
||||||
|
views share the same permissions class"""
|
||||||
|
self.user.status = User.RESTRICTED
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
response = self.client.get("/request/", follow=True)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
|
@ -3,7 +3,6 @@ from unittest.mock import Mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from .common import MockSESClient, completed_domain_request # type: ignore
|
from .common import MockSESClient, completed_domain_request # type: ignore
|
||||||
from django_webtest import WebTest # type: ignore
|
from django_webtest import WebTest # type: ignore
|
||||||
|
@ -17,7 +16,6 @@ from registrar.models import (
|
||||||
Contact,
|
Contact,
|
||||||
User,
|
User,
|
||||||
Website,
|
Website,
|
||||||
UserDomainRole,
|
|
||||||
)
|
)
|
||||||
from registrar.views.domain_request import DomainRequestWizard, Step
|
from registrar.views.domain_request import DomainRequestWizard, Step
|
||||||
|
|
||||||
|
@ -2603,364 +2601,3 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.fail(f"Expected a redirect, but got a different response: {response}")
|
self.fail(f"Expected a redirect, but got a different response: {response}")
|
||||||
|
|
||||||
|
|
||||||
class HomeTests(TestWithUser):
|
|
||||||
"""A series of tests that target the two tables on home.html"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
super().tearDown()
|
|
||||||
Contact.objects.all().delete()
|
|
||||||
|
|
||||||
def test_home_lists_domain_requests(self):
|
|
||||||
response = self.client.get("/")
|
|
||||||
self.assertNotContains(response, "igorville.gov")
|
|
||||||
site = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site)
|
|
||||||
response = self.client.get("/")
|
|
||||||
|
|
||||||
# count = 7 because of screenreader content
|
|
||||||
self.assertContains(response, "igorville.gov", count=7)
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
domain_request.delete()
|
|
||||||
|
|
||||||
def test_state_help_text(self):
|
|
||||||
"""Tests if each domain state has help text"""
|
|
||||||
|
|
||||||
# Get the expected text content of each state
|
|
||||||
deleted_text = "This domain has been removed and " "is no longer registered to your organization."
|
|
||||||
dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses."
|
|
||||||
ready_text = "This domain has name servers and is ready for use."
|
|
||||||
on_hold_text = (
|
|
||||||
"This domain is administratively paused, "
|
|
||||||
"so it can’t be edited and won’t resolve in DNS. "
|
|
||||||
"Contact help@get.gov for details."
|
|
||||||
)
|
|
||||||
deleted_text = "This domain has been removed and " "is no longer registered to your organization."
|
|
||||||
# Generate a mapping of domain names, the state, and expected messages for the subtest
|
|
||||||
test_cases = [
|
|
||||||
("deleted.gov", Domain.State.DELETED, deleted_text),
|
|
||||||
("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text),
|
|
||||||
("unknown.gov", Domain.State.UNKNOWN, dns_needed_text),
|
|
||||||
("onhold.gov", Domain.State.ON_HOLD, on_hold_text),
|
|
||||||
("ready.gov", Domain.State.READY, ready_text),
|
|
||||||
]
|
|
||||||
for domain_name, state, expected_message in test_cases:
|
|
||||||
with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message):
|
|
||||||
# Create a domain and a UserRole with the given params
|
|
||||||
test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state)
|
|
||||||
test_domain.expiration_date = date.today()
|
|
||||||
test_domain.save()
|
|
||||||
|
|
||||||
user_role, _ = UserDomainRole.objects.get_or_create(
|
|
||||||
user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Grab the home page
|
|
||||||
response = self.client.get("/")
|
|
||||||
|
|
||||||
# Make sure the user can actually see the domain.
|
|
||||||
# We expect two instances because of SR content.
|
|
||||||
self.assertContains(response, domain_name, count=2)
|
|
||||||
|
|
||||||
# Check that we have the right text content.
|
|
||||||
self.assertContains(response, expected_message, count=1)
|
|
||||||
|
|
||||||
# Delete the role and domain to ensure we're testing in isolation
|
|
||||||
user_role.delete()
|
|
||||||
test_domain.delete()
|
|
||||||
|
|
||||||
def test_state_help_text_expired(self):
|
|
||||||
"""Tests if each domain state has help text when expired"""
|
|
||||||
expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
|
|
||||||
test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY)
|
|
||||||
test_domain.expiration_date = date(2011, 10, 10)
|
|
||||||
test_domain.save()
|
|
||||||
|
|
||||||
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER)
|
|
||||||
|
|
||||||
# Grab the home page
|
|
||||||
response = self.client.get("/")
|
|
||||||
|
|
||||||
# Make sure the user can actually see the domain.
|
|
||||||
# We expect two instances because of SR content.
|
|
||||||
self.assertContains(response, "expired.gov", count=2)
|
|
||||||
|
|
||||||
# Check that we have the right text content.
|
|
||||||
self.assertContains(response, expired_text, count=1)
|
|
||||||
|
|
||||||
def test_state_help_text_no_expiration_date(self):
|
|
||||||
"""Tests if each domain state has help text when expiration date is None"""
|
|
||||||
|
|
||||||
# == Test a expiration of None for state ready. This should be expired. == #
|
|
||||||
expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
|
|
||||||
test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY)
|
|
||||||
test_domain.expiration_date = None
|
|
||||||
test_domain.save()
|
|
||||||
|
|
||||||
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER)
|
|
||||||
|
|
||||||
# Grab the home page
|
|
||||||
response = self.client.get("/")
|
|
||||||
|
|
||||||
# Make sure the user can actually see the domain.
|
|
||||||
# We expect two instances because of SR content.
|
|
||||||
self.assertContains(response, "imexpired.gov", count=2)
|
|
||||||
|
|
||||||
# Make sure the expiration date is None
|
|
||||||
self.assertEqual(test_domain.expiration_date, None)
|
|
||||||
|
|
||||||
# Check that we have the right text content.
|
|
||||||
self.assertContains(response, expired_text, count=1)
|
|
||||||
|
|
||||||
# == Test a expiration of None for state unknown. This should not display expired text. == #
|
|
||||||
unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses."
|
|
||||||
test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN)
|
|
||||||
test_domain_2.expiration_date = None
|
|
||||||
test_domain_2.save()
|
|
||||||
|
|
||||||
UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER)
|
|
||||||
|
|
||||||
# Grab the home page
|
|
||||||
response = self.client.get("/")
|
|
||||||
|
|
||||||
# Make sure the user can actually see the domain.
|
|
||||||
# We expect two instances because of SR content.
|
|
||||||
self.assertContains(response, "notexpired.gov", count=2)
|
|
||||||
|
|
||||||
# Make sure the expiration date is None
|
|
||||||
self.assertEqual(test_domain_2.expiration_date, None)
|
|
||||||
|
|
||||||
# Check that we have the right text content.
|
|
||||||
self.assertContains(response, unknown_text, count=1)
|
|
||||||
|
|
||||||
def test_home_deletes_withdrawn_domain_request(self):
|
|
||||||
"""Tests if the user can delete a DomainRequest in the 'withdrawn' status"""
|
|
||||||
|
|
||||||
site = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
domain_request = DomainRequest.objects.create(
|
|
||||||
creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure that igorville.gov exists on the page
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertContains(home_page, "igorville.gov")
|
|
||||||
|
|
||||||
# Check if the delete button exists. We can do this by checking for its id and text content.
|
|
||||||
self.assertContains(home_page, "Delete")
|
|
||||||
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
|
||||||
|
|
||||||
self.assertNotContains(response, "igorville.gov")
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
domain_request.delete()
|
|
||||||
|
|
||||||
def test_home_deletes_started_domain_request(self):
|
|
||||||
"""Tests if the user can delete a DomainRequest in the 'started' status"""
|
|
||||||
|
|
||||||
site = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
domain_request = DomainRequest.objects.create(
|
|
||||||
creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure that igorville.gov exists on the page
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertContains(home_page, "igorville.gov")
|
|
||||||
|
|
||||||
# Check if the delete button exists. We can do this by checking for its id and text content.
|
|
||||||
self.assertContains(home_page, "Delete")
|
|
||||||
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
|
||||||
|
|
||||||
self.assertNotContains(response, "igorville.gov")
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
domain_request.delete()
|
|
||||||
|
|
||||||
def test_home_doesnt_delete_other_domain_requests(self):
|
|
||||||
"""Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN"""
|
|
||||||
|
|
||||||
# Given that we are including a subset of items that can be deleted while excluding the rest,
|
|
||||||
# subTest is appropriate here as otherwise we would need many duplicate tests for the same reason.
|
|
||||||
with less_console_noise():
|
|
||||||
draft_domain = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
for status in DomainRequest.DomainRequestStatus:
|
|
||||||
if status not in [
|
|
||||||
DomainRequest.DomainRequestStatus.STARTED,
|
|
||||||
DomainRequest.DomainRequestStatus.WITHDRAWN,
|
|
||||||
]:
|
|
||||||
with self.subTest(status=status):
|
|
||||||
domain_request = DomainRequest.objects.create(
|
|
||||||
creator=self.user, requested_domain=draft_domain, status=status
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for a 403 error - the end user should not be allowed to do this
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain)
|
|
||||||
|
|
||||||
# Make sure the DomainRequest wasn't deleted
|
|
||||||
self.assertEqual(desired_domain_request.count(), 1)
|
|
||||||
|
|
||||||
# clean up
|
|
||||||
domain_request.delete()
|
|
||||||
|
|
||||||
def test_home_deletes_domain_request_and_orphans(self):
|
|
||||||
"""Tests if delete for DomainRequest deletes orphaned Contact objects"""
|
|
||||||
|
|
||||||
# Create the site and contacts to delete (orphaned)
|
|
||||||
contact = Contact.objects.create(
|
|
||||||
first_name="Henry",
|
|
||||||
last_name="Mcfakerson",
|
|
||||||
)
|
|
||||||
contact_shared = Contact.objects.create(
|
|
||||||
first_name="Relative",
|
|
||||||
last_name="Aether",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create two non-orphaned contacts
|
|
||||||
contact_2 = Contact.objects.create(
|
|
||||||
first_name="Saturn",
|
|
||||||
last_name="Mars",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attach a user object to a contact (should not be deleted)
|
|
||||||
contact_user, _ = Contact.objects.get_or_create(user=self.user)
|
|
||||||
|
|
||||||
site = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
domain_request = DomainRequest.objects.create(
|
|
||||||
creator=self.user,
|
|
||||||
requested_domain=site,
|
|
||||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
|
||||||
authorizing_official=contact,
|
|
||||||
submitter=contact_user,
|
|
||||||
)
|
|
||||||
domain_request.other_contacts.set([contact_2])
|
|
||||||
|
|
||||||
# Create a second domain request to attach contacts to
|
|
||||||
site_2 = DraftDomain.objects.create(name="teaville.gov")
|
|
||||||
domain_request_2 = DomainRequest.objects.create(
|
|
||||||
creator=self.user,
|
|
||||||
requested_domain=site_2,
|
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
|
||||||
authorizing_official=contact_2,
|
|
||||||
submitter=contact_shared,
|
|
||||||
)
|
|
||||||
domain_request_2.other_contacts.set([contact_shared])
|
|
||||||
|
|
||||||
# Ensure that igorville.gov exists on the page
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertContains(home_page, "igorville.gov")
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
|
||||||
|
|
||||||
# igorville is now deleted
|
|
||||||
self.assertNotContains(response, "igorville.gov")
|
|
||||||
|
|
||||||
# Check if the orphaned contact was deleted
|
|
||||||
orphan = Contact.objects.filter(id=contact.id)
|
|
||||||
self.assertFalse(orphan.exists())
|
|
||||||
|
|
||||||
# All non-orphan contacts should still exist and are unaltered
|
|
||||||
try:
|
|
||||||
current_user = Contact.objects.filter(id=contact_user.id).get()
|
|
||||||
except Contact.DoesNotExist:
|
|
||||||
self.fail("contact_user (a non-orphaned contact) was deleted")
|
|
||||||
|
|
||||||
self.assertEqual(current_user, contact_user)
|
|
||||||
try:
|
|
||||||
edge_case = Contact.objects.filter(id=contact_2.id).get()
|
|
||||||
except Contact.DoesNotExist:
|
|
||||||
self.fail("contact_2 (a non-orphaned contact) was deleted")
|
|
||||||
|
|
||||||
self.assertEqual(edge_case, contact_2)
|
|
||||||
|
|
||||||
def test_home_deletes_domain_request_and_shared_orphans(self):
|
|
||||||
"""Test the edge case for an object that will become orphaned after a delete
|
|
||||||
(but is not an orphan at the time of deletion)"""
|
|
||||||
|
|
||||||
# Create the site and contacts to delete (orphaned)
|
|
||||||
contact = Contact.objects.create(
|
|
||||||
first_name="Henry",
|
|
||||||
last_name="Mcfakerson",
|
|
||||||
)
|
|
||||||
contact_shared = Contact.objects.create(
|
|
||||||
first_name="Relative",
|
|
||||||
last_name="Aether",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create two non-orphaned contacts
|
|
||||||
contact_2 = Contact.objects.create(
|
|
||||||
first_name="Saturn",
|
|
||||||
last_name="Mars",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attach a user object to a contact (should not be deleted)
|
|
||||||
contact_user, _ = Contact.objects.get_or_create(user=self.user)
|
|
||||||
|
|
||||||
site = DraftDomain.objects.create(name="igorville.gov")
|
|
||||||
domain_request = DomainRequest.objects.create(
|
|
||||||
creator=self.user,
|
|
||||||
requested_domain=site,
|
|
||||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
|
||||||
authorizing_official=contact,
|
|
||||||
submitter=contact_user,
|
|
||||||
)
|
|
||||||
domain_request.other_contacts.set([contact_2])
|
|
||||||
|
|
||||||
# Create a second domain request to attach contacts to
|
|
||||||
site_2 = DraftDomain.objects.create(name="teaville.gov")
|
|
||||||
domain_request_2 = DomainRequest.objects.create(
|
|
||||||
creator=self.user,
|
|
||||||
requested_domain=site_2,
|
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
|
||||||
authorizing_official=contact_2,
|
|
||||||
submitter=contact_shared,
|
|
||||||
)
|
|
||||||
domain_request_2.other_contacts.set([contact_shared])
|
|
||||||
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertContains(home_page, "teaville.gov")
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True)
|
|
||||||
|
|
||||||
self.assertNotContains(response, "teaville.gov")
|
|
||||||
|
|
||||||
# Check if the orphaned contact was deleted
|
|
||||||
orphan = Contact.objects.filter(id=contact_shared.id)
|
|
||||||
self.assertFalse(orphan.exists())
|
|
||||||
|
|
||||||
def test_domain_request_form_view(self):
|
|
||||||
response = self.client.get("/request/", follow=True)
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
"You’re about to start your .gov domain request.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_domain_request_form_with_ineligible_user(self):
|
|
||||||
"""Domain request form not accessible for an ineligible user.
|
|
||||||
This test should be solid enough since all domain request wizard
|
|
||||||
views share the same permissions class"""
|
|
||||||
self.user.status = User.RESTRICTED
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
with less_console_noise():
|
|
||||||
response = self.client.get("/request/", follow=True)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue