Merge main

This commit is contained in:
Rachid Mrad 2024-04-30 21:10:21 -04:00
commit a20a9154a6
No known key found for this signature in database
30 changed files with 1148 additions and 655 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 -g npm@10.5.0
RUN npm install RUN npm install

4
src/package-lock.json generated
View file

@ -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": {

View file

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

View file

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

View file

@ -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&amp;_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) { // Append the new <span> element to the <h3> element
handleSelectClick(toList, changeLink, deleteLink, viewLink); h3Element.appendChild(newSpan);
});
// 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) => {
selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)});
});
}
// 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

View file

@ -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);
}

View 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);
}
}

View file

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

View file

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

View file

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

View file

@ -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}"
)

View file

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

View 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,
),
),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -978,6 +978,26 @@ class TestDomainRequestAdmin(MockEppLib):
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):
"""Tests if an analyst can still see and edit the alternative domain field""" """Tests if an analyst can still see and edit the alternative domain field"""
@ -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")}),

View file

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

View file

@ -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, " "youll 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 cant be edited and wont 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, " "youll 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,
"Youre 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)

View file

@ -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, " "youll 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 cant be edited and wont 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, " "youll 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,
"Youre 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)