Merge branch 'main' into za/1271-admin-add-notes

This commit is contained in:
zandercymatics 2024-02-01 10:35:10 -07:00
commit a4f960aa51
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
46 changed files with 1212 additions and 252 deletions

View file

@ -48,7 +48,7 @@ All other changes require just a single approving review.-->
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve) - [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review - [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide - [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. - [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
#### Ensured code standards are met (Original Developer) #### Ensured code standards are met (Original Developer)
@ -72,7 +72,7 @@ All other changes require just a single approving review.-->
- [ ] Reviewed this code and left comments - [ ] Reviewed this code and left comments
- [ ] Checked that all code is adequately covered by tests - [ ] Checked that all code is adequately covered by tests
- [ ] Made it clear which comments need to be addressed before this work is merged - [ ] Made it clear which comments need to be addressed before this work is merged
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. - [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
#### Ensured code standards are met (Code reviewer) #### Ensured code standards are met (Code reviewer)

View file

@ -38,3 +38,11 @@ jobs:
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: development cf_space: development
push_arguments: "-f ops/manifests/manifest-development.yaml" push_arguments: "-f ops/manifests/manifest-development.yaml"
- name: Run Django migrations
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_DEVELOPMENT_USERNAME }}
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
cf_org: cisa-dotgov
cf_space: development
cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"

View file

@ -237,3 +237,7 @@ Bugs on production software need to be documented quickly and triaged to determi
3. In the case where the engineering lead is is unresponsive or unavailable to assign the ticket immediately, the product team will make sure an engineer volunteers or is assigned to the ticket/PR review ASAP. 3. In the case where the engineering lead is is unresponsive or unavailable to assign the ticket immediately, the product team will make sure an engineer volunteers or is assigned to the ticket/PR review ASAP.
4. Once done, the developer must make a PR and should tag the assigned PR reviewers in our Slack dev channel stating that the PR is now waiting on their review. These reviewers should drop other tasks in order to review this promptly. 4. Once done, the developer must make a PR and should tag the assigned PR reviewers in our Slack dev channel stating that the PR is now waiting on their review. These reviewers should drop other tasks in order to review this promptly.
5. See the the section above on [Making bug fixes on stable](#making-bug-fixes-on-stable-during-production) for how to push changes to stable once the PR is approved 5. See the the section above on [Making bug fixes on stable](#making-bug-fixes-on-stable-during-production) for how to push changes to stable once the PR is approved
# Investigating and monitoring the health of the Registrar
Sometimes, we may want individuals to routinely monitor the Registrar's health, such as after big feature launches. The cadence of such monitoring and what we look for is subject to change and is instead documented in [Checklist for production verification document](https://docs.google.com/document/d/15b_qwEZMiL76BHeRHnznV1HxDQcxNRt--vPSEfixBOI). All project team members should feel free to suggest edits to this document and should refer to it if production-level monitoring is underway.

View file

@ -20,6 +20,8 @@ from . import models
from auditlog.models import LogEntry # type: ignore from auditlog.models import LogEntry # type: ignore
from auditlog.admin import LogEntryAdmin # type: ignore from auditlog.admin import LogEntryAdmin # type: ignore
from django_fsm import TransitionNotAllowed # type: ignore from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe
from django.utils.html import escape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -452,6 +454,60 @@ class ContactAdmin(ListHeaderAdmin):
readonly_fields.extend([field for field in self.analyst_readonly_fields]) readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts return readonly_fields # Read-only fields for analysts
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Extend the change_view for Contact objects in django admin.
Customize to display related objects to the Contact. These will be passed
through the messages construct to the template for display to the user."""
# Fetch the Contact instance
contact = models.Contact.objects.get(pk=object_id)
# initialize related_objects array
related_objects = []
# for all defined fields in the model
for related_field in contact._meta.get_fields():
# if the field is a relation to another object
if related_field.is_relation:
# Check if the related field is not None
related_manager = getattr(contact, related_field.name)
if related_manager is not None:
# Check if it's a ManyToManyField/reverse ForeignKey or a OneToOneField
# Do this by checking for get_queryset method on the related_manager
if hasattr(related_manager, "get_queryset"):
# Handles ManyToManyRel and ManyToOneRel
queryset = related_manager.get_queryset()
else:
# Handles OneToOne rels, ie. User
queryset = [related_manager]
for obj in queryset:
# for each object, build the edit url in this view and add as tuple
# to the related_objects array
app_label = obj._meta.app_label
model_name = obj._meta.model_name
obj_id = obj.id
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
related_objects.append((change_url, obj))
if related_objects:
message = "<ul class='messagelist_content-list--unstyled'>"
for i, (url, obj) in enumerate(related_objects):
if i < 5:
escaped_obj = escape(obj)
message += f"<li>Joined to {obj.__class__.__name__}: <a href='{url}'>{escaped_obj}</a></li>"
message += "</ul>"
if len(related_objects) > 5:
related_objects_over_five = len(related_objects) - 5
message += f"<p class='font-sans-3xs'>And {related_objects_over_five} more...</p>"
message_html = mark_safe(message) # nosec
messages.warning(
request,
message_html,
)
return super().change_view(request, object_id, form_url, extra_context=extra_context)
class WebsiteAdmin(ListHeaderAdmin): class WebsiteAdmin(ListHeaderAdmin):
"""Custom website admin class.""" """Custom website admin class."""
@ -1246,7 +1302,7 @@ class DraftDomainAdmin(ListHeaderAdmin):
search_help_text = "Search by draft domain name." search_help_text = "Search by draft domain name."
class VeryImportantPersonAdmin(ListHeaderAdmin): class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at") list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"] search_fields = ["email"]
search_help_text = "Search by email." search_help_text = "Search by email."
@ -1289,4 +1345,4 @@ admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VeryImportantPerson, VeryImportantPersonAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)

View file

@ -283,19 +283,20 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
(function (){ (function (){
// Get the current date in the format YYYY-MM-DD // Get the current date in the format YYYY-MM-DD
var currentDate = new Date().toISOString().split('T')[0]; let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date // Default the value of the start date input field to the current date
let startDateInput =document.getElementById('start'); let startDateInput =document.getElementById('start');
startDateInput.value = currentDate;
// Default the value of the end date input field to the current date // Default the value of the end date input field to the current date
let endDateInput =document.getElementById('end'); let endDateInput =document.getElementById('end');
endDateInput.value = currentDate;
let exportGrowthReportButton = document.getElementById('exportLink'); let exportGrowthReportButton = document.getElementById('exportLink');
if (exportGrowthReportButton) { if (exportGrowthReportButton) {
startDateInput.value = currentDate;
endDateInput.value = currentDate;
exportGrowthReportButton.addEventListener('click', function() { exportGrowthReportButton.addEventListener('click', function() {
// Get the selected start and end dates // Get the selected start and end dates
let startDate = startDateInput.value; let startDate = startDateInput.value;

View file

@ -130,7 +130,7 @@ function inlineToast(el, id, style, msg) {
} }
} }
function _checkDomainAvailability(el) { function checkDomainAvailability(el) {
const callback = (response) => { const callback = (response) => {
toggleInputValidity(el, (response && response.available), msg=response.message); toggleInputValidity(el, (response && response.available), msg=response.message);
announce(el.id, response.message); announce(el.id, response.message);
@ -154,9 +154,6 @@ function _checkDomainAvailability(el) {
fetchJSON(`available/?domain=${el.value}`, callback); fetchJSON(`available/?domain=${el.value}`, callback);
} }
/** Call the API to see if the domain is good. */
const checkDomainAvailability = debounce(_checkDomainAvailability);
/** Hides the toast message and clears the aira live region. */ /** Hides the toast message and clears the aira live region. */
function clearDomainAvailability(el) { function clearDomainAvailability(el) {
el.classList.remove('usa-input--success'); el.classList.remove('usa-input--success');
@ -206,13 +203,33 @@ function handleInputValidation(e) {
} }
/** On button click, handles running any associated validators. */ /** On button click, handles running any associated validators. */
function handleValidationClick(e) { function validateFieldInput(e) {
const attribute = e.target.getAttribute("validate-for") || ""; const attribute = e.target.getAttribute("validate-for") || "";
if (!attribute.length) return; if (!attribute.length) return;
const input = document.getElementById(attribute); const input = document.getElementById(attribute);
removeFormErrors(input, true);
runValidators(input); runValidators(input);
} }
function validateFormsetInputs(e, availabilityButton) {
// Collect input IDs from the repeatable forms
let inputs = Array.from(document.querySelectorAll('.repeatable-form input'))
// Run validators for each input
inputs.forEach(input => {
removeFormErrors(input, true);
runValidators(input);
});
// Set the validate-for attribute on the button with the collected input IDs
// Not needed for functionality but nice for accessibility
inputs = inputs.map(input => input.id).join(', ');
availabilityButton.setAttribute('validate-for', inputs);
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code. // Initialization code.
@ -232,14 +249,64 @@ function handleValidationClick(e) {
for(const input of needsValidation) { for(const input of needsValidation) {
input.addEventListener('input', handleInputValidation); input.addEventListener('input', handleInputValidation);
} }
const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability');
const activatesValidation = document.querySelectorAll('[validate-for]'); const activatesValidation = document.querySelectorAll('[validate-for]');
for(const button of activatesValidation) { for(const button of activatesValidation) {
button.addEventListener('click', handleValidationClick); // Adds multi-field validation for alternative domains
if (button === alternativeDomainsAvailability) {
button.addEventListener('click', (e) => {
validateFormsetInputs(e, alternativeDomainsAvailability)
});
} else {
button.addEventListener('click', validateFieldInput);
}
} }
})(); })();
/** /**
* Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data) * Removes form errors surrounding a form input
*/
function removeFormErrors(input, removeStaleAlerts=false){
// Remove error message
let errorMessage = document.getElementById(`${input.id}__error-message`);
if (errorMessage) {
errorMessage.remove();
}else{
return
}
// Remove error classes
if (input.classList.contains('usa-input--error')) {
input.classList.remove('usa-input--error');
}
// Get the form label
let label = document.querySelector(`label[for="${input.id}"]`);
if (label) {
label.classList.remove('usa-label--error');
// Remove error classes from parent div
let parentDiv = label.parentElement;
if (parentDiv) {
parentDiv.classList.remove('usa-form-group--error');
}
}
if (removeStaleAlerts){
let staleAlerts = document.querySelectorAll(".usa-alert--error")
for (let alert of staleAlerts){
// Don't remove the error associated with the input
if (alert.id !== `${input.id}--toast`) {
alert.remove()
}
}
}
}
/**
* Prepare the namerservers and DS data forms delete buttons
* We will call this on the forms init, and also every time we add a form
* *
*/ */
function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
@ -460,6 +527,7 @@ function hideDeletedForms() {
let isNameserversForm = document.querySelector(".nameservers-form"); let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form");
let isDsDataForm = document.querySelector(".ds-data-form"); let isDsDataForm = document.querySelector(".ds-data-form");
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
// The Nameservers formset features 2 required and 11 optionals // The Nameservers formset features 2 required and 11 optionals
if (isNameserversForm) { if (isNameserversForm) {
cloneIndex = 2; cloneIndex = 2;
@ -472,6 +540,8 @@ function hideDeletedForms() {
formLabel = "Organization contact"; formLabel = "Organization contact";
container = document.querySelector("#other-employees"); container = document.querySelector("#other-employees");
formIdentifier = "other_contacts" formIdentifier = "other_contacts"
} else if (isDotgovDomain) {
formIdentifier = "dotgov_domain"
} }
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
@ -554,6 +624,7 @@ function hideDeletedForms() {
// Reset the values of each input to blank // Reset the values of each input to blank
inputs.forEach((input) => { inputs.forEach((input) => {
input.classList.remove("usa-input--error"); input.classList.remove("usa-input--error");
input.classList.remove("usa-input--success");
if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") {
input.value = ""; // Set the value to an empty string input.value = ""; // Set the value to an empty string
@ -566,22 +637,25 @@ function hideDeletedForms() {
let selects = newForm.querySelectorAll("select"); let selects = newForm.querySelectorAll("select");
selects.forEach((select) => { selects.forEach((select) => {
select.classList.remove("usa-input--error"); select.classList.remove("usa-input--error");
select.classList.remove("usa-input--success");
select.selectedIndex = 0; // Set the value to an empty string select.selectedIndex = 0; // Set the value to an empty string
}); });
let labels = newForm.querySelectorAll("label"); let labels = newForm.querySelectorAll("label");
labels.forEach((label) => { labels.forEach((label) => {
label.classList.remove("usa-label--error"); label.classList.remove("usa-label--error");
label.classList.remove("usa-label--success");
}); });
let usaFormGroups = newForm.querySelectorAll(".usa-form-group"); let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
usaFormGroups.forEach((usaFormGroup) => { usaFormGroups.forEach((usaFormGroup) => {
usaFormGroup.classList.remove("usa-form-group--error"); usaFormGroup.classList.remove("usa-form-group--error");
usaFormGroup.classList.remove("usa-form-group--success");
}); });
// Remove any existing error messages // Remove any existing error and success messages
let usaErrorMessages = newForm.querySelectorAll(".usa-error-message"); let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert");
usaErrorMessages.forEach((usaErrorMessage) => { usaMessages.forEach((usaErrorMessage) => {
let parentDiv = usaErrorMessage.closest('div'); let parentDiv = usaErrorMessage.closest('div');
if (parentDiv) { if (parentDiv) {
parentDiv.remove(); // Remove the parent div if it exists parentDiv.remove(); // Remove the parent div if it exists
@ -592,7 +666,8 @@ function hideDeletedForms() {
// Attach click event listener on the delete buttons of the new form // Attach click event listener on the delete buttons of the new form
let newDeleteButton = newForm.querySelector(".delete-record"); let newDeleteButton = newForm.querySelector(".delete-record");
prepareNewDeleteButton(newDeleteButton, formLabel); if (newDeleteButton)
prepareNewDeleteButton(newDeleteButton, formLabel);
// Disable the add more button if we have 13 forms // Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) { if (isNameserversForm && formNum == 13) {

View file

@ -143,7 +143,7 @@ h1, h2, h3,
.module h3 { .module h3 {
padding: 0; padding: 0;
color: var(--primary); color: var(--link-fg);
margin: units(2) 0 units(1) 0; margin: units(2) 0 units(1) 0;
} }
@ -258,3 +258,15 @@ h1, h2, h3,
#select2-id_user-results { #select2-id_user-results {
width: 100%; width: 100%;
} }
// Content list inside of a DjA alert, unstyled
.messagelist_content-list--unstyled {
padding-left: 0;
li {
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
font-size: 13.92px!important;
background: none!important;
padding: 0!important;
margin: 0!important;
}
}

View file

@ -44,6 +44,22 @@ a.usa-button.disabled-link:focus {
color: #454545 !important color: #454545 !important
} }
a.usa-button--unstyled.disabled-link,
a.usa-button--unstyled.disabled-link:hover,
a.usa-button--unstyled.disabled-link:focus {
cursor: not-allowed !important;
outline: none !important;
text-decoration: none !important;
}
.usa-button--unstyled.disabled-button,
.usa-button--unstyled.disabled-link:hover,
.usa-button--unstyled.disabled-link:focus {
cursor: not-allowed !important;
outline: none !important;
text-decoration: none !important;
}
a.usa-button:not(.usa-button--unstyled, .usa-button--outline) { a.usa-button:not(.usa-button--unstyled, .usa-button--outline) {
color: color('white'); color: color('white');
} }

View file

@ -0,0 +1,14 @@
@use "uswds-core" as *;
dt {
color: color('primary-dark');
margin-top: units(2);
font-weight: font-weight('semibold');
// The units mixin can only get us close, so it's between
// hardcoding the value and using in markup
font-size: 16.96px;
}
dd {
margin-left: 0;
}

View file

@ -6,12 +6,14 @@
margin-top: units(-1); margin-top: units(-1);
} }
//Tighter spacing when H2 is immediatly after H1 // Tighter spacing when h2 is immediatly after h1
.register-form-step .usa-fieldset:first-of-type h2:first-of-type, .register-form-step .usa-fieldset:first-of-type h2:first-of-type,
.register-form-step h1 + h2 { .register-form-step h1 + h2 {
margin-top: units(1); margin-top: units(1);
} }
// register-form-review-header is used on the summary page and
// should not be styled like the register form headers
.register-form-step h3 { .register-form-step h3 {
color: color('primary-dark'); color: color('primary-dark');
letter-spacing: $letter-space--xs; letter-spacing: $letter-space--xs;
@ -23,6 +25,16 @@
} }
} }
h3.register-form-review-header {
color: color('primary-dark');
margin-top: units(2);
margin-bottom: 0;
font-weight: font-weight('semibold');
// The units mixin can only get us close, so it's between
// hardcoding the value and using in markup
font-size: 16.96px;
}
.register-form-step h4 { .register-form-step h4 {
margin-bottom: 0; margin-bottom: 0;

View file

@ -10,6 +10,7 @@
--- Custom Styles ---------------------------------*/ --- Custom Styles ---------------------------------*/
@forward "base"; @forward "base";
@forward "typography"; @forward "typography";
@forward "lists";
@forward "buttons"; @forward "buttons";
@forward "forms"; @forward "forms";
@forward "fieldsets"; @forward "fieldsets";

View file

@ -142,6 +142,11 @@ urlpatterns = [
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]), views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
name="application-delete", name="application-delete",
), ),
path(
"domain/<int:pk>/users/<int:user_pk>/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete",
),
] ]
# we normally would guard these with `if settings.DEBUG` but tests run with # we normally would guard these with `if settings.DEBUG` but tests run with

View file

@ -420,7 +420,7 @@ class AlternativeDomainForm(RegistrarForm):
alternative_domain = forms.CharField( alternative_domain = forms.CharField(
required=False, required=False,
label="", label="Alternative domain",
) )

View file

@ -182,8 +182,6 @@ class LoadExtraTransitionDomain:
# STEP 5: Parse creation and expiration data # STEP 5: Parse creation and expiration data
updated_transition_domain = self.parse_creation_expiration_data(domain_name, transition_domain) updated_transition_domain = self.parse_creation_expiration_data(domain_name, transition_domain)
# Check if the instance has changed before saving
updated_transition_domain.save()
updated_transition_domains.append(updated_transition_domain) updated_transition_domains.append(updated_transition_domain)
logger.info(f"{TerminalColors.OKCYAN}" f"Successfully updated {domain_name}" f"{TerminalColors.ENDC}") logger.info(f"{TerminalColors.OKCYAN}" f"Successfully updated {domain_name}" f"{TerminalColors.ENDC}")
@ -199,6 +197,28 @@ class LoadExtraTransitionDomain:
) )
failed_transition_domains.append(domain_name) failed_transition_domains.append(domain_name)
updated_fields = [
"organization_name",
"organization_type",
"federal_type",
"federal_agency",
"first_name",
"middle_name",
"last_name",
"email",
"phone",
"epp_creation_date",
"epp_expiration_date",
]
batch_size = 1000
# Create a Paginator object. Bulk_update on the full dataset
# is too memory intensive for our current app config, so we can chunk this data instead.
paginator = Paginator(updated_transition_domains, batch_size)
for page_num in paginator.page_range:
page = paginator.page(page_num)
TransitionDomain.objects.bulk_update(page.object_list, updated_fields)
failed_count = len(failed_transition_domains) failed_count = len(failed_transition_domains)
if failed_count == 0: if failed_count == 0:
if self.debug: if self.debug:

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0035 (which populates ContentType and Permissions)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0064_alter_domainapplication_address_line1_and_more"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-01-29 22:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0065_create_groups_v06"),
]
operations = [
migrations.RenameModel(
old_name="VeryImportantPerson",
new_name="VerifiedByStaff",
),
migrations.AlterModelOptions(
name="verifiedbystaff",
options={"verbose_name_plural": "Verified by staff"},
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0035 (which populates ContentType and Permissions)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0066_rename_veryimportantperson_verifiedbystaff_and_more"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -13,7 +13,7 @@ from .user import User
from .user_group import UserGroup from .user_group import UserGroup
from .website import Website from .website import Website
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .very_important_person import VeryImportantPerson from .verified_by_staff import VerifiedByStaff
__all__ = [ __all__ = [
"Contact", "Contact",
@ -30,7 +30,7 @@ __all__ = [
"UserGroup", "UserGroup",
"Website", "Website",
"TransitionDomain", "TransitionDomain",
"VeryImportantPerson", "VerifiedByStaff",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -47,4 +47,4 @@ auditlog.register(User, m2m_fields=["user_permissions", "groups"])
auditlog.register(UserGroup, m2m_fields=["permissions"]) auditlog.register(UserGroup, m2m_fields=["permissions"])
auditlog.register(Website) auditlog.register(Website)
auditlog.register(TransitionDomain) auditlog.register(TransitionDomain)
auditlog.register(VeryImportantPerson) auditlog.register(VerifiedByStaff)

View file

@ -910,10 +910,15 @@ class Domain(TimeStampedModel, DomainHelper):
raise NotImplementedError() raise NotImplementedError()
def get_security_email(self): def get_security_email(self):
logger.info("get_security_email-> getting the contact ") logger.info("get_security_email-> getting the contact")
secContact = self.security_contact
if secContact is not None: security = PublicContact.ContactTypeChoices.SECURITY
return secContact.email security_contact = self.generic_contact_getter(security)
# If we get a valid value for security_contact, pull its email
# Otherwise, just return nothing
if security_contact is not None and isinstance(security_contact, PublicContact):
return security_contact.email
else: else:
return None return None
@ -1121,7 +1126,6 @@ class Domain(TimeStampedModel, DomainHelper):
If you wanted to setup getter logic for Security, you would call: If you wanted to setup getter logic for Security, you would call:
cache_contact_helper(PublicContact.ContactTypeChoices.SECURITY), cache_contact_helper(PublicContact.ContactTypeChoices.SECURITY),
or cache_contact_helper("security"). or cache_contact_helper("security").
""" """
# registrant_contact(s) are an edge case. They exist on # registrant_contact(s) are an edge case. They exist on
# the "registrant" property as opposed to contacts. # the "registrant" property as opposed to contacts.

View file

@ -7,7 +7,7 @@ from registrar.models.user_domain_role import UserDomainRole
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .very_important_person import VeryImportantPerson from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -91,7 +91,7 @@ class User(AbstractUser):
return False return False
# New users flagged by Staff to bypass ial2 # New users flagged by Staff to bypass ial2
if VeryImportantPerson.objects.filter(email=email).exists(): if VerifiedByStaff.objects.filter(email=email).exists():
return False return False
# A new incoming user who is being invited to be a domain manager (that is, # A new incoming user who is being invited to be a domain manager (that is,

View file

@ -66,6 +66,11 @@ class UserGroup(Group):
"model": "userdomainrole", "model": "userdomainrole",
"permissions": ["view_userdomainrole", "delete_userdomainrole"], "permissions": ["view_userdomainrole", "delete_userdomainrole"],
}, },
{
"app_label": "registrar",
"model": "verifiedbystaff",
"permissions": ["add_verifiedbystaff", "change_verifiedbystaff", "delete_verifiedbystaff"],
},
] ]
# Avoid error: You can't execute queries until the end # Avoid error: You can't execute queries until the end

View file

@ -57,6 +57,9 @@ class DomainHelper:
# If blank ok is true, just return the domain # If blank ok is true, just return the domain
return domain return domain
if domain.startswith("www."):
domain = domain[4:]
if domain.endswith(".gov"): if domain.endswith(".gov"):
domain = domain[:-4] domain = domain[:-4]

View file

@ -3,7 +3,7 @@ from django.db import models
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
class VeryImportantPerson(TimeStampedModel): class VerifiedByStaff(TimeStampedModel):
"""emails that get added to this table will bypass ial2 on login.""" """emails that get added to this table will bypass ial2 on login."""
@ -28,5 +28,8 @@ class VeryImportantPerson(TimeStampedModel):
help_text="Notes", help_text="Notes",
) )
class Meta:
verbose_name_plural = "Verified by staff"
def __str__(self): def __str__(self):
return self.email return self.email

View file

@ -48,16 +48,15 @@
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
<button <button
id="check-availability-button"
type="button" type="button"
class="usa-button" class="usa-button usa-button--outline"
validate-for="{{ forms.0.requested_domain.auto_id }}" validate-for="{{ forms.0.requested_domain.auto_id }}"
>Check availability</button> >Check availability</button>
</fieldset> </fieldset>
{{ forms.1.management_form }} {{ forms.1.management_form }}
<fieldset class="usa-fieldset margin-top-1"> <fieldset class="usa-fieldset margin-top-1 dotgov-domain-form" id="form-container">
<legend> <legend>
<h2>Alternative domains (optional)</h2> <h2>Alternative domains (optional)</h2>
</legend> </legend>
@ -66,23 +65,34 @@
you your first choice?</p> you your first choice?</p>
{% with attr_aria_describedby="alt_domain_instructions" %} {% with attr_aria_describedby="alt_domain_instructions" %}
{# attr_validate / validate="domain" invokes code in get-gov.js #} {# Will probably want to remove blank-ok and do related cleanup when we implement delete #}
{# attr_auto_validate likewise triggers behavior in get-gov.js #} {% with attr_validate="domain" append_gov=True add_label_class="usa-sr-only" add_class="blank-ok alternate-domain-input" %}
{% with append_gov=True attr_validate="domain" attr_auto_validate=True %} {% for form in forms.1 %}
{% with add_class="blank-ok alternate-domain-input" %} <div class="repeatable-form">
{% for form in forms.1 %}
{% input_with_errors form.alternative_domain %} {% input_with_errors form.alternative_domain %}
{% endfor %} </div>
{% endwith %} {% endfor %}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--unstyled"> <button type="button" value="save" class="usa-button usa-button--unstyled" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another alternative</span> </svg><span class="margin-left-05">Add another alternative</span>
</button> </button>
</fieldset> <div class="margin-bottom-3">
<button
id="validate-alt-domains-availability"
type="button"
class="usa-button usa-button--outline"
validate-for="{{ forms.1.requested_domain.auto_id }}"
>Check availability</button>
</div>
<p class="margin-top-05">If youre not sure this is the domain you want, thats ok. You can change the domain later. </p>
</fieldset>
{% endblock %} {% endblock %}

View file

@ -20,108 +20,158 @@
{% block form_fields %} {% block form_fields %}
{% for step in steps.all|slice:":-1" %} {% for step in steps.all|slice:":-1" %}
<section class="review__step"> <section class="summary-item margin-top-3">
<hr />
<div class="review__step__title display-flex flex-justify"> {% if step == Step.ORGANIZATION_TYPE %}
<div class="review__step__value"> {% namespaced_url 'application' step as application_url %}
<div class="review__step__name">{{ form_titles|get_item:step }}</div> {% if application.organization_type is not None %}
<div> {% with title=form_titles|get_item:step value=application.get_organization_type_display|default:"Incomplete" %}
{% if step == Step.ORGANIZATION_TYPE %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% if application.organization_type is not None %} {% endwith %}
{% with long_org_type=application.organization_type|get_organization_long_name %} {% else %}
{{ long_org_type }} {% with title=form_titles|get_item:step value="Incomplete" %}
{% endwith %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% else %} {% endwith %}
Incomplete
{% endif %}
{% endif %} {% endif %}
{% if step == Step.TRIBAL_GOVERNMENT %} {% endif %}
{{ application.tribe_name|default:"Incomplete" }}
{% if application.federally_recognized_tribe %}<p>Federally-recognized tribe</p>{% endif %} {% if step == Step.TRIBAL_GOVERNMENT %}
{% if application.state_recognized_tribe %}<p>State-recognized tribe</p>{% endif %} {% namespaced_url 'application' step as application_url %}
{% with title=form_titles|get_item:step value=application.tribe_name|default:"Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% if application.federally_recognized_tribe %}<p>Federally-recognized tribe</p>{% endif %}
{% if application.state_recognized_tribe %}<p>State-recognized tribe</p>{% endif %}
{% endif %}
{% if step == Step.ORGANIZATION_FEDERAL %}
{% namespaced_url 'application' step as application_url %}
{% with title=form_titles|get_item:step value=application.get_federal_type_display|default:"Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %}
{% if step == Step.ORGANIZATION_ELECTION %}
{% namespaced_url 'application' step as application_url %}
{% with title=form_titles|get_item:step value=application.is_election_board|yesno:"Yes,No,Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %}
{% if step == Step.ORGANIZATION_CONTACT %}
{% namespaced_url 'application' step as application_url %}
{% if application.organization_name %}
{% with title=form_titles|get_item:step value=application %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url address='true' %}
{% endwith %}
{% else %}
{% with title=form_titles|get_item:step value='Incomplete' %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.ORGANIZATION_FEDERAL %} {% endif %}
{{ application.get_federal_type_display|default:"Incomplete" }}
{% if step == Step.ABOUT_YOUR_ORGANIZATION %}
{% namespaced_url 'application' step as application_url %}
{% with title=form_titles|get_item:step value=application.about_your_organization|default:"Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %}
{% if step == Step.AUTHORIZING_OFFICIAL %}
{% namespaced_url 'application' step as application_url %}
{% if application.authorizing_official is not None %}
{% with title=form_titles|get_item:step value=application.authorizing_official %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url contact='true' %}
{% endwith %}
{% else %}
{% with title=form_titles|get_item:step value="Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.ORGANIZATION_ELECTION %} {% endif %}
{{ application.is_election_board|yesno:"Yes,No,Incomplete" }}
{% if step == Step.CURRENT_SITES %}
{% namespaced_url 'application' step as application_url %}
{% if application.current_websites.all %}
{% with title=form_titles|get_item:step value=application.current_websites.all %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url list='true' %}
{% endwith %}
{% else %}
{% with title=form_titles|get_item:step value='None' %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.ORGANIZATION_CONTACT %} {% endif %}
{% if application.organization_name %}
{% include "includes/organization_address.html" with organization=application %} {% if step == Step.DOTGOV_DOMAIN %}
{% else %} {% namespaced_url 'application' step as application_url %}
Incomplete {% with title=form_titles|get_item:step value=application.requested_domain.name|default:"Incomplete" %}
{% endif %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endif %} {% endwith %}
{% if step == Step.ABOUT_YOUR_ORGANIZATION %}
<p>{{ application.about_your_organization|default:"Incomplete" }}</p> {% if application.alternative_domains.all %}
{% endif %} <h3 class="register-form-review-header">Alternative domains</h3>
{% if step == Step.AUTHORIZING_OFFICIAL %} <ul class="usa-list usa-list--unstyled margin-top-0">
{% if application.authorizing_official %}
<div class="margin-bottom-105">
{% include "includes/contact.html" with contact=application.authorizing_official %}
</div>
{% else %}
Incomplete
{% endif %}
{% endif %}
{% if step == Step.CURRENT_SITES %}
<ul class="add-list-reset">
{% for site in application.current_websites.all %}
<li>{{ site.website }}</li>
{% empty %}
<li>None</li>
{% endfor %}
</ul>
{% endif %}
{% if step == Step.DOTGOV_DOMAIN %}
<ul class="add-list-reset margin-bottom-105">
<li>{{ application.requested_domain.name|default:"Incomplete" }}</li>
</ul>
<ul class="add-list-reset">
{% for site in application.alternative_domains.all %} {% for site in application.alternative_domains.all %}
<li>{{ site.website }}</li> <li>{{ site.website }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% if step == Step.PURPOSE %} {% endif %}
{{ application.purpose|default:"Incomplete" }}
{% if step == Step.PURPOSE %}
{% namespaced_url 'application' step as application_url %}
{% with title=form_titles|get_item:step value=application.purpose|default:"Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %}
{% if step == Step.YOUR_CONTACT %}
{% namespaced_url 'application' step as application_url %}
{% if application.submitter is not None %}
{% with title=form_titles|get_item:step value=application.submitter %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url contact='true' %}
{% endwith %}
{% else %}
{% with title=form_titles|get_item:step value="Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.YOUR_CONTACT %} {% endif %}
{% if application.submitter %}
<div class="margin-bottom-105"> {% if step == Step.OTHER_CONTACTS %}
{% include "includes/contact.html" with contact=application.submitter %} {% namespaced_url 'application' step as application_url %}
</div> {% if application.other_contacts.all %}
{% else %} {% with title=form_titles|get_item:step value=application.other_contacts.all %}
Incomplete {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url contact='true' list='true' %}
{% endif %} {% endwith %}
{% else %}
{% with title=form_titles|get_item:step value=application.no_other_contacts_rationale|default:"Incomplete" %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.OTHER_CONTACTS %} {% endif %}
{% for other in application.other_contacts.all %}
<div class="margin-bottom-105">
<p class="text-semibold margin-top-1 margin-bottom-0">Contact {{ forloop.counter }}</p> {% if step == Step.ANYTHING_ELSE %}
{% include "includes/contact.html" with contact=other %} {% namespaced_url 'application' step as application_url %}
</div> {% with title=form_titles|get_item:step value=application.anything_else|default:"No" %}
{% empty %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
<div class="margin-bottom-105"> {% endwith %}
<p class="text-semibold margin-top-1 margin-bottom-0">No other employees from your organization?</p> {% endif %}
{{ application.no_other_contacts_rationale|default:"Incomplete" }}
</div>
{% endfor %} {% if step == Step.REQUIREMENTS %}
{% endif %} {% namespaced_url 'application' step as application_url %}
{% if step == Step.ANYTHING_ELSE %} {% with title=form_titles|get_item:step value=application.is_policy_acknowledged|yesno:"I agree.,I do not agree.,I do not agree." %}
{{ application.anything_else|default:"No" }} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %}
{% endif %} {% endwith %}
{% if step == Step.REQUIREMENTS %} {% endif %}
{{ application.is_policy_acknowledged|yesno:"I agree.,I do not agree.,I do not agree." }}
{% endif %}
</div>
</div>
<a
aria-describedby="review_step_title__{{step}}"
href="{% namespaced_url 'application' step %}"
>Edit<span class="sr-only"> {{ form_titles|get_item:step }}</span></a>
</div>
</section> </section>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View file

@ -52,8 +52,8 @@
<div class="grid-col desktop:grid-offset-2 maxw-tablet"> <div class="grid-col desktop:grid-offset-2 maxw-tablet">
<h2 class="text-primary-darker"> Summary of your domain request </h2> <h2 class="text-primary-darker"> Summary of your domain request </h2>
{% with heading_level='h3' %} {% with heading_level='h3' %}
{% with long_org_type=domainapplication.organization_type|get_organization_long_name %} {% with org_type=domainapplication.get_organization_type_display %}
{% include "includes/summary_item.html" with title='Type of organization' value=long_org_type heading_level=heading_level %} {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %}
{% endwith %} {% endwith %}
{% if domainapplication.tribe_name %} {% if domainapplication.tribe_name %}
@ -74,7 +74,9 @@
{% endif %} {% endif %}
{% if domainapplication.is_election_board %} {% if domainapplication.is_election_board %}
{% include "includes/summary_item.html" with title='Election office' value=domainapplication.is_election_board heading_level=heading_level %} {% with value=domainapplication.is_election_board|yesno:"Yes,No,Incomplete" %}
{% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %}
{% endwith %}
{% endif %} {% endif %}
{% if domainapplication.organization_name %} {% if domainapplication.organization_name %}
@ -109,7 +111,11 @@
{% include "includes/summary_item.html" with title='Your contact information' value=domainapplication.submitter contact='true' heading_level=heading_level %} {% include "includes/summary_item.html" with title='Your contact information' value=domainapplication.submitter contact='true' heading_level=heading_level %}
{% endif %} {% endif %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=domainapplication.other_contacts.all contact='true' list='true' heading_level=heading_level %} {% if domainapplication.other_contacts.all %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=domainapplication.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% else %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=domainapplication.no_other_contacts_rationale heading_level=heading_level %}
{% endif %}
{% include "includes/summary_item.html" with title='Anything else?' value=domainapplication.anything_else|default:"No" heading_level=heading_level %} {% include "includes/summary_item.html" with title='Anything else?' value=domainapplication.anything_else|default:"No" heading_level=heading_level %}

View file

@ -3,22 +3,22 @@
{% block wrapper %} {% block wrapper %}
<div id="wrapper" class="dashboard"> <div id="wrapper" class="dashboard">
{% block messages %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}>
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
{% block section_nav %}{% endblock %} {% block section_nav %}{% endblock %}
{% block hero %}{% endblock %} {% block hero %}{% endblock %}
{% block content %}{% endblock %} {% block content %}
{% block messages %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li {% if message.tags %} class="{{ message.tags }}" {% endif %}>
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
{% endblock %}
<div role="complementary">{% block complementary %}{% endblock %}</div> <div role="complementary">{% block complementary %}{% endblock %}</div>

View file

@ -16,10 +16,8 @@
<li>There is no limit to the number of domain managers you can add.</li> <li>There is no limit to the number of domain managers you can add.</li>
<li>After adding a domain manager, an email invitation will be sent to that user with <li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li> instructions on how to set up an account.</li>
<li>To remove a domain manager, <a href="{% public_site_url 'contact/' %}"
target="_blank" rel="noopener noreferrer" class="usa-link">contact us</a> for
assistance.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li> <li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
</ul> </ul>
{% if domain.permissions %} {% if domain.permissions %}
@ -30,7 +28,8 @@
<thead> <thead>
<tr> <tr>
<th data-sortable scope="col" role="columnheader">Email</th> <th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Role</th> <th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -40,6 +39,61 @@
{{ permission.user.email }} {{ permission.user.email }}
</th> </th>
<td data-label="Role">{{ permission.role|title }}</td> <td data-label="Role">{{ permission.role|title }}</td>
<td>
{% if can_delete_users %}
<a
id="button-toggle-user-alert-{{ forloop.counter }}"
href="#toggle-user-alert-{{ forloop.counter }}"
class="usa-button--unstyled text-no-underline"
aria-controls="toggle-user-alert-{{ forloop.counter }}"
data-open-modal
aria-disabled="false"
>
Remove
</a>
{# Display a custom message if the user is trying to delete themselves #}
{% if permission.user.email == current_user_email %}
<div
class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="You will be removed from this domain"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
{% with domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button_self|safe %}
{% endwith %}
</form>
</div>
{% else %}
<div
class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="{{ permission.user.email }} will be removed"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
{% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button|safe %}
{% endwith %}
</form>
</div>
{% endif %}
{% else %}
<input
type="submit"
class="usa-button--unstyled disabled-button usa-tooltip"
value="Remove"
data-position="bottom"
title="Domains must have at least one domain manager"
data-tooltip="true"
aria-disabled="true"
role="button"
>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -66,8 +120,8 @@
<tr> <tr>
<th data-sortable scope="col" role="columnheader">Email</th> <th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</th> <th data-sortable scope="col" role="columnheader">Date created</th>
<th data-sortable scope="col" role="columnheader">Status</th> <th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="sr-only">Action</span></th> <th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -78,8 +132,9 @@
</th> </th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td> <td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td> <td data-label="Status">{{ invitation.status|title }}</td>
<td><form method="POST" action="{% url "invitation-delete" pk=invitation.id %}"> <td>
{% csrf_token %}<input type="submit" class="usa-button--unstyled" value="Cancel"> <form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline" value="Cancel">
</form> </form>
</td> </td>
</tr> </tr>

View file

@ -2,9 +2,22 @@ SUMMARY OF YOUR DOMAIN REQUEST
Type of organization: Type of organization:
{{ application.get_organization_type_display }} {{ application.get_organization_type_display }}
{% if application.show_organization_federal %}
Federal government branch:
{{ application.get_federal_type_display }}
{% elif application.show_tribal_government %}
Tribal government:
{{ application.tribe_name|default:"Incomplete" }}{% if application.federally_recognized_tribe %}
Federally-recognized tribe
{% endif %}{% if application.state_recognized_tribe %}
State-recognized tribe
{% endif %}{% endif %}{% if application.show_organization_election %}
Election office:
{{ application.is_election_board|yesno:"Yes,No,Incomplete" }}
{% endif %}
Organization name and mailing address: Organization name and mailing address:
{% spaceless %}{{ application.organization_name }} {% spaceless %}{{ application.federal_agency }}
{{ application.organization_name }}
{{ application.address_line1 }}{% if application.address_line2 %} {{ application.address_line1 }}{% if application.address_line2 %}
{{ application.address_line2 }}{% endif %} {{ application.address_line2 }}{% endif %}
{{ application.city }}, {{ application.state_territory }} {{ application.city }}, {{ application.state_territory }}
@ -22,18 +35,21 @@ Current websites: {% for site in application.current_websites.all %}
{% endfor %}{% endif %} {% endfor %}{% endif %}
.gov domain: .gov domain:
{{ application.requested_domain.name }} {{ application.requested_domain.name }}
{% if application.alternative_domains.all %}
Alternative domains:
{% for site in application.alternative_domains.all %}{% spaceless %}{{ site.website }}{% endspaceless %} {% for site in application.alternative_domains.all %}{% spaceless %}{{ site.website }}{% endspaceless %}
{% endfor %} {% endfor %}{% endif %}
Purpose of your domain: Purpose of your domain:
{{ application.purpose }} {{ application.purpose }}
Your contact information: Your contact information:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=application.submitter %}{% endspaceless %} {% spaceless %}{% include "emails/includes/contact.txt" with contact=application.submitter %}{% endspaceless %}
{% if application.other_contacts.all %}
Other employees from your organization: Other employees from your organization:{% for other in application.other_contacts.all %}
{% for other in application.other_contacts.all %}
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %} {% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}
{% endfor %}{% endif %}{% if application.anything_else %} {% empty %}
{{ application.no_other_contacts_rationale }}
{% endfor %}{% if application.anything_else %}
Anything else? Anything else?
{{ application.anything_else }} {{ application.anything_else }}
{% endif %} {% endif %}

View file

@ -10,6 +10,9 @@
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1"> <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Manage your domains</h2> <h1>Manage your domains</h2>
<p class="margin-top-4"> <p class="margin-top-4">

View file

@ -2,7 +2,7 @@
{% for message in messages %} {% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2"> <div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body"> <div class="usa-alert__body">
{{ message }} {{ message }}
</div> </div>
</div> </div>

View file

@ -1,4 +1,7 @@
<address> <address>
{% if organization.federal_agency %}
{{ organization.federal_agency }}<br />
{% endif %}
{% if organization.organization_name %} {% if organization.organization_name %}
{{ organization.organization_name }} {{ organization.organization_name }}
{% endif %} {% endif %}

View file

@ -28,17 +28,22 @@
{% if value|length == 1 %} {% if value|length == 1 %}
{% include "includes/contact.html" with contact=value|first %} {% include "includes/contact.html" with contact=value|first %}
{% else %} {% else %}
<ul class="usa-list usa-list--unstyled margin-top-0"> {% if value %}
{% for item in value %} <dl class="usa-list usa-list--unstyled margin-top-0">
<li> {% for item in value %}
<p class="text-semibold margin-top-1 margin-bottom-0"> <dt>
Contact {{forloop.counter}} Contact {{forloop.counter}}
</p> </dt>
{% include "includes/contact.html" with contact=item %}</li> <dd>
{% empty %} {% include "includes/contact.html" with contact=item %}
<li>None</li> </dd>
{% endfor %} {% endfor %}
</ul> </dl>
{% else %}
<p>
None
</p>
{% endif %}
{% endif %} {% endif %}
{% else %} {% else %}
{% include "includes/contact.html" with contact=value %} {% include "includes/contact.html" with contact=value %}
@ -57,10 +62,10 @@
{% endspaceless %}) {% endspaceless %})
{% endif %} {% endif %}
{% else %} {% else %}
<p class="margin-top-0">{{ value | first }} </p> <p class="margin-top-0 margin-bottom-0">{{ value | first }} </p>
{% endif %} {% endif %}
{% else %} {% else %}
<ul class="usa-list margin-top-0"> <ul class="usa-list usa-list--unstyled margin-top-0">
{% for item in value %} {% for item in value %}
{% if users %} {% if users %}
<li>{{ item.user.email }}</li> <li>{{ item.user.email }}</li>

View file

@ -526,6 +526,7 @@ def completed_application(
has_anything_else=True, has_anything_else=True,
status=DomainApplication.ApplicationStatus.STARTED, status=DomainApplication.ApplicationStatus.STARTED,
user=False, user=False,
submitter=False,
name="city.gov", name="city.gov",
): ):
"""A completed domain application.""" """A completed domain application."""
@ -541,13 +542,14 @@ def completed_application(
domain, _ = DraftDomain.objects.get_or_create(name=name) domain, _ = DraftDomain.objects.get_or_create(name=name)
alt, _ = Website.objects.get_or_create(website="city1.gov") alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com") current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create( if not submitter:
first_name="Testy2", submitter, _ = Contact.objects.get_or_create(
last_name="Tester2", first_name="Testy2",
title="Admin Tester", last_name="Tester2",
email="mayor@igorville.gov", title="Admin Tester",
phone="(555) 555 5556", email="mayor@igorville.gov",
) phone="(555) 555 5556",
)
other, _ = Contact.objects.get_or_create( other, _ = Contact.objects.get_or_create(
first_name="Testy", first_name="Testy",
last_name="Tester", last_name="Tester",
@ -567,7 +569,7 @@ def completed_application(
zipcode="10002", zipcode="10002",
authorizing_official=ao, authorizing_official=ao,
requested_domain=domain, requested_domain=domain,
submitter=you, submitter=submitter,
creator=user, creator=user,
status=status, status=status,
) )

View file

@ -14,11 +14,11 @@ from registrar.admin import (
ContactAdmin, ContactAdmin,
DomainInformationAdmin, DomainInformationAdmin,
UserDomainRoleAdmin, UserDomainRoleAdmin,
VeryImportantPersonAdmin, VerifiedByStaffAdmin,
) )
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.very_important_person import VeryImportantPerson from registrar.models.verified_by_staff import VerifiedByStaff
from .common import ( from .common import (
MockSESClient, MockSESClient,
AuditedAdminMockData, AuditedAdminMockData,
@ -1738,11 +1738,90 @@ class ContactAdminTest(TestCase):
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
def test_change_view_for_joined_contact_five_or_less(self):
"""Create a contact, join it to 4 domain requests. The 5th join will be a user.
Assert that the warning on the contact form lists 5 joins."""
self.client.force_login(self.superuser)
# Create an instance of the model
contact, _ = Contact.objects.get_or_create(user=self.staffuser)
# join it to 4 domain requests. The 5th join will be a user.
application1 = completed_application(submitter=contact, name="city1.gov")
application2 = completed_application(submitter=contact, name="city2.gov")
application3 = completed_application(submitter=contact, name="city3.gov")
application4 = completed_application(submitter=contact, name="city4.gov")
with patch("django.contrib.messages.warning") as mock_warning:
# Use the test client to simulate the request
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
# Assert that the error message was called with the correct argument
# Note: The 5th join will be a user.
mock_warning.assert_called_once_with(
response.wsgi_request,
"<ul class='messagelist_content-list--unstyled'>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application1.pk}/change/'>city1.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application2.pk}/change/'>city2.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application3.pk}/change/'>city3.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application4.pk}/change/'>city4.gov</a></li>"
"<li>Joined to User: <a href='/admin/registrar/"
f"user/{self.staffuser.pk}/change/'>staff@example.com</a></li>"
"</ul>",
)
def test_change_view_for_joined_contact_five_or_more(self):
"""Create a contact, join it to 5 domain requests. The 6th join will be a user.
Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis."""
self.client.force_login(self.superuser)
# Create an instance of the model
# join it to 5 domain requests. The 6th join will be a user.
contact, _ = Contact.objects.get_or_create(user=self.staffuser)
application1 = completed_application(submitter=contact, name="city1.gov")
application2 = completed_application(submitter=contact, name="city2.gov")
application3 = completed_application(submitter=contact, name="city3.gov")
application4 = completed_application(submitter=contact, name="city4.gov")
application5 = completed_application(submitter=contact, name="city5.gov")
with patch("django.contrib.messages.warning") as mock_warning:
# Use the test client to simulate the request
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
logger.info(mock_warning)
# Assert that the error message was called with the correct argument
# Note: The 6th join will be a user.
mock_warning.assert_called_once_with(
response.wsgi_request,
"<ul class='messagelist_content-list--unstyled'>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application1.pk}/change/'>city1.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application2.pk}/change/'>city2.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application3.pk}/change/'>city3.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application4.pk}/change/'>city4.gov</a></li>"
"<li>Joined to DomainApplication: <a href='/admin/registrar/"
f"domainapplication/{application5.pk}/change/'>city5.gov</a></li>"
"</ul>"
"<p class='font-sans-3xs'>And 1 more...</p>",
)
def tearDown(self): def tearDown(self):
DomainApplication.objects.all().delete()
Contact.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
class VeryImportantPersonAdminTestCase(TestCase): class VerifiedByStaffAdminTestCase(TestCase):
def setUp(self): def setUp(self):
self.superuser = create_superuser() self.superuser = create_superuser()
self.factory = RequestFactory() self.factory = RequestFactory()
@ -1751,13 +1830,13 @@ class VeryImportantPersonAdminTestCase(TestCase):
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
# Create an instance of the admin class # Create an instance of the admin class
admin_instance = VeryImportantPersonAdmin(model=VeryImportantPerson, admin_site=None) admin_instance = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=None)
# Create a VeryImportantPerson instance # Create a VerifiedByStaff instance
vip_instance = VeryImportantPerson(email="test@example.com", notes="Test Notes") vip_instance = VerifiedByStaff(email="test@example.com", notes="Test Notes")
# Create a request object # Create a request object
request = self.factory.post("/admin/yourapp/veryimportantperson/add/") request = self.factory.post("/admin/yourapp/VerifiedByStaff/add/")
request.user = self.superuser request.user = self.superuser
# Call the save_model method # Call the save_model method

View file

@ -102,9 +102,9 @@ class TestEmails(TestCase):
application.submit() application.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Other employees from your organization:", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"5556\n\nAnything else") self.assertRegex(body, r"5556\n\nOther employees")
self.assertRegex(body, r"None\n\nAnything else")
@boto3_mocking.patching @boto3_mocking.patching
def test_submission_confirmation_alternative_govdomain_spacing(self): def test_submission_confirmation_alternative_govdomain_spacing(self):
@ -117,7 +117,7 @@ class TestEmails(TestCase):
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("city1.gov", body) self.assertIn("city1.gov", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"city.gov\ncity1.gov\n\nPurpose of your domain:") self.assertRegex(body, r"city.gov\n\nAlternative domains:\ncity1.gov\n\nPurpose of your domain:")
@boto3_mocking.patching @boto3_mocking.patching
def test_submission_confirmation_no_alternative_govdomain_spacing(self): def test_submission_confirmation_no_alternative_govdomain_spacing(self):

View file

@ -64,6 +64,12 @@ class TestFormValidation(MockEppLib):
form = DotGovDomainForm(data={"requested_domain": "top-level-agency"}) form = DotGovDomainForm(data={"requested_domain": "top-level-agency"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
def test_requested_domain_starting_www(self):
"""Test a valid domain name with .www at the beginning."""
form = DotGovDomainForm(data={"requested_domain": "www.top-level-agency"})
self.assertEqual(len(form.errors), 0)
self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency")
def test_requested_domain_ending_dotgov(self): def test_requested_domain_ending_dotgov(self):
"""Just a valid domain name with .gov at the end.""" """Just a valid domain name with .gov at the end."""
form = DotGovDomainForm(data={"requested_domain": "top-level-agency.gov"}) form = DotGovDomainForm(data={"requested_domain": "top-level-agency.gov"})

View file

@ -43,6 +43,9 @@ class TestGroups(TestCase):
"change_user", "change_user",
"delete_userdomainrole", "delete_userdomainrole",
"view_userdomainrole", "view_userdomainrole",
"add_verifiedbystaff",
"change_verifiedbystaff",
"delete_verifiedbystaff",
"change_website", "change_website",
] ]

View file

@ -16,7 +16,7 @@ from registrar.models import (
import boto3_mocking import boto3_mocking
from registrar.models.transition_domain import TransitionDomain from registrar.models.transition_domain import TransitionDomain
from registrar.models.very_important_person import VeryImportantPerson # type: ignore from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from .common import MockSESClient, less_console_noise, completed_application from .common import MockSESClient, less_console_noise, completed_application
from django_fsm import TransitionNotAllowed from django_fsm import TransitionNotAllowed
@ -680,7 +680,7 @@ class TestUser(TestCase):
def test_identity_verification_with_very_important_person(self): def test_identity_verification_with_very_important_person(self):
"""A Very Important Person should return False """A Very Important Person should return False
when tested with class method needs_identity_verification""" when tested with class method needs_identity_verification"""
VeryImportantPerson.objects.get_or_create(email=self.user.email) VerifiedByStaff.objects.get_or_create(email=self.user.email)
self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username))
def test_identity_verification_with_invited_user(self): def test_identity_verification_with_invited_user(self):

View file

@ -23,6 +23,7 @@ SAMPLE_KWARGS = {
"content_type_id": "2", "content_type_id": "2",
"object_id": "3", "object_id": "3",
"domain": "whitehouse.gov", "domain": "whitehouse.gov",
"user_pk": "1",
} }
# Our test suite will ignore some namespaces. # Our test suite will ignore some namespaces.

View file

@ -712,7 +712,7 @@ class DomainApplicationTests(TestWithUser, WebTest):
# Review page contains all the previously entered data # Review page contains all the previously entered data
# Let's make sure the long org name is displayed # Let's make sure the long org name is displayed
self.assertContains(review_page, "Federal: an agency of the U.S. government") self.assertContains(review_page, "Federal")
self.assertContains(review_page, "Executive") self.assertContains(review_page, "Executive")
self.assertContains(review_page, "Testorg") self.assertContains(review_page, "Testorg")
self.assertContains(review_page, "address 1") self.assertContains(review_page, "address 1")
@ -2360,18 +2360,6 @@ class DomainApplicationTests(TestWithUser, WebTest):
self.assertContains(type_page, "Federal: an agency of the U.S. government") self.assertContains(type_page, "Federal: an agency of the U.S. government")
def test_long_org_name_in_application_manage(self):
"""
Make sure the long name is displaying in the application summary
page (manage your application)
"""
completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED, user=self.user)
home_page = self.app.get("/")
self.assertContains(home_page, "city.gov")
# click the "Edit" link
detail_page = home_page.click("Manage", index=0)
self.assertContains(detail_page, "Federal: an agency of the U.S. government")
def test_submit_modal_no_domain_text_fallback(self): def test_submit_modal_no_domain_text_fallback(self):
"""When user clicks on submit your domain request and the requested domain """When user clicks on submit your domain request and the requested domain
is null (possible through url direct access to the review page), present is null (possible through url direct access to the review page), present
@ -2674,6 +2662,7 @@ class TestDomainManagers(TestDomainOverview):
super().tearDown() super().tearDown()
self.user.is_staff = False self.user.is_staff = False
self.user.save() self.user.save()
User.objects.all().delete()
def test_domain_managers(self): def test_domain_managers(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
@ -2689,6 +2678,183 @@ class TestDomainManagers(TestDomainOverview):
response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Add a domain manager") self.assertContains(response, "Add a domain manager")
def test_domain_user_delete(self):
"""Tests if deleting a domain manager works"""
# Add additional users
dummy_user_1 = User.objects.create(
username="macncheese",
email="cheese@igorville.com",
)
dummy_user_2 = User.objects.create(
username="pastapizza",
email="pasta@igorville.com",
)
role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
# Make sure we're on the right page
self.assertContains(response, "Domain managers")
# Make sure the desired user exists
self.assertContains(response, "cheese@igorville.com")
# Delete dummy_user_1
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": dummy_user_1.id}), follow=True
)
# Grab the displayed messages
messages = list(response.context["messages"])
self.assertEqual(len(messages), 1)
# Ensure the error we recieve is in line with what we expect
message = messages[0]
self.assertEqual(message.message, "Removed cheese@igorville.com as a manager for this domain.")
self.assertEqual(message.tags, "success")
# Check that role_1 deleted in the DB after the post
deleted_user_exists = UserDomainRole.objects.filter(id=role_1.id).exists()
self.assertFalse(deleted_user_exists)
# Ensure that the current user wasn't deleted
current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=self.domain).exists()
self.assertTrue(current_user_exists)
# Ensure that the other userdomainrole was not deleted
role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists()
self.assertTrue(role_2_exists)
def test_domain_user_delete_denied_if_no_permission(self):
"""Deleting a domain manager is denied if the user has no permission to do so"""
# Create a domain object
vip_domain = Domain.objects.create(name="freeman.gov")
# Add users
dummy_user_1 = User.objects.create(
username="bagel",
email="bagel@igorville.com",
)
dummy_user_2 = User.objects.create(
username="pastapizza",
email="pasta@igorville.com",
)
role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=vip_domain, role=UserDomainRole.Roles.MANAGER)
role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=vip_domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.get(reverse("domain-users", kwargs={"pk": vip_domain.id}))
# Make sure that we can't access the domain manager page normally
self.assertEqual(response.status_code, 403)
# Try to delete dummy_user_1
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": vip_domain.id, "user_pk": dummy_user_1.id}), follow=True
)
# Ensure that we are denied access
self.assertEqual(response.status_code, 403)
# Ensure that the user wasn't deleted
role_1_exists = UserDomainRole.objects.filter(id=role_1.id).exists()
self.assertTrue(role_1_exists)
# Ensure that the other userdomainrole was not deleted
role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists()
self.assertTrue(role_2_exists)
# Make sure that the current user wasn't deleted for some reason
current_user_exists = UserDomainRole.objects.filter(user=dummy_user_1.id, domain=vip_domain.id).exists()
self.assertTrue(current_user_exists)
def test_domain_user_delete_denied_if_last_man_standing(self):
"""Deleting a domain manager is denied if the user is the only manager"""
# Create a domain object
vip_domain = Domain.objects.create(name="olive-oil.gov")
# Add the requesting user as the only manager on the domain
UserDomainRole.objects.create(user=self.user, domain=vip_domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.get(reverse("domain-users", kwargs={"pk": vip_domain.id}))
# Make sure that we can still access the domain manager page normally
self.assertContains(response, "Domain managers")
# Make sure that the logged in user exists
self.assertContains(response, "info@example.com")
# Try to delete the current user
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": vip_domain.id, "user_pk": self.user.id}), follow=True
)
# Ensure that we are denied access
self.assertEqual(response.status_code, 403)
# Make sure that the current user wasn't deleted
current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=vip_domain.id).exists()
self.assertTrue(current_user_exists)
def test_domain_user_delete_self_redirects_home(self):
"""Tests if deleting yourself redirects to home"""
# Add additional users
dummy_user_1 = User.objects.create(
username="macncheese",
email="cheese@igorville.com",
)
dummy_user_2 = User.objects.create(
username="pastapizza",
email="pasta@igorville.com",
)
role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
# Make sure we're on the right page
self.assertContains(response, "Domain managers")
# Make sure the desired user exists
self.assertContains(response, "info@example.com")
# Make sure more than one UserDomainRole exists on this object
self.assertContains(response, "cheese@igorville.com")
# Delete the current user
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True
)
# Check if we've been redirected to the home page
self.assertContains(response, "Manage your domains")
# Grab the displayed messages
messages = list(response.context["messages"])
self.assertEqual(len(messages), 1)
# Ensure the error we recieve is in line with what we expect
message = messages[0]
self.assertEqual(message.message, "You are no longer managing the domain igorville.gov.")
self.assertEqual(message.tags, "success")
# Ensure that the current user was deleted
current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=self.domain).exists()
self.assertFalse(current_user_exists)
# Ensure that the other userdomainroles are not deleted
role_1_exists = UserDomainRole.objects.filter(id=role_1.id).exists()
self.assertTrue(role_1_exists)
role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists()
self.assertTrue(role_2_exists)
@boto3_mocking.patching @boto3_mocking.patching
def test_domain_user_add_form(self): def test_domain_user_add_form(self):
"""Adding an existing user works.""" """Adding an existing user works."""

View file

@ -3,10 +3,13 @@ import logging
from datetime import datetime from datetime import datetime
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
from registrar.models.public_contact import PublicContact
from django.db.models import Value
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django.core.paginator import Paginator
from django.db.models import F, Value, CharField
from django.db.models.functions import Concat, Coalesce
from registrar.models.public_contact import PublicContact
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,50 +23,77 @@ def write_header(writer, columns):
def get_domain_infos(filter_condition, sort_fields): def get_domain_infos(filter_condition, sort_fields):
domain_infos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields) domain_infos = (
return domain_infos DomainInformation.objects.select_related("domain", "authorizing_official")
.filter(**filter_condition)
.order_by(*sort_fields)
)
# Do a mass concat of the first and last name fields for authorizing_official.
# The old operation was computationally heavy for some reason, so if we precompute
# this here, it is vastly more efficient.
domain_infos_cleaned = domain_infos.annotate(
ao=Concat(
Coalesce(F("authorizing_official__first_name"), Value("")),
Value(" "),
Coalesce(F("authorizing_official__last_name"), Value("")),
output_field=CharField(),
)
)
return domain_infos_cleaned
def write_row(writer, columns, domain_info): def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None):
security_contacts = domain_info.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY) """Given a set of columns, generate a new row from cleaned column data"""
# For linter # Domain should never be none when parsing this information
ao = " " if domain_info.domain is None:
if domain_info.authorizing_official: raise ValueError("Domain is none")
first_name = domain_info.authorizing_official.first_name or ""
last_name = domain_info.authorizing_official.last_name or ""
ao = first_name + " " + last_name
security_email = " " domain = domain_info.domain # type: ignore
if security_contacts:
security_email = security_contacts[0].email # Grab the security email from a preset dictionary.
# If nothing exists in the dictionary, grab from .contacts.
if security_emails_dict is not None and domain.name in security_emails_dict:
_email = security_emails_dict.get(domain.name)
security_email = _email if _email is not None else " "
else:
# If the dictionary doesn't contain that data, lets filter for it manually.
# This is a last resort as this is a more expensive operation.
security_contacts = domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY)
_email = security_contacts[0].email if security_contacts else None
security_email = _email if _email is not None else " "
invalid_emails = {"registrar@dotgov.gov", "dotgov@cisa.dhs.gov"}
# These are default emails that should not be displayed in the csv report # These are default emails that should not be displayed in the csv report
if security_email is not None and security_email.lower() in invalid_emails: invalid_emails = {"registrar@dotgov.gov", "dotgov@cisa.dhs.gov"}
if security_email.lower() in invalid_emails:
security_email = "(blank)" security_email = "(blank)"
if domain_info.federal_type:
domain_type = f"{domain_info.get_organization_type_display()} - {domain_info.get_federal_type_display()}"
else:
domain_type = domain_info.get_organization_type_display()
# create a dictionary of fields which can be included in output # create a dictionary of fields which can be included in output
FIELDS = { FIELDS = {
"Domain name": domain_info.domain.name, "Domain name": domain.name,
"Domain type": domain_info.get_organization_type_display() + " - " + domain_info.get_federal_type_display() "Domain type": domain_type,
if domain_info.federal_type
else domain_info.get_organization_type_display(),
"Agency": domain_info.federal_agency, "Agency": domain_info.federal_agency,
"Organization name": domain_info.organization_name, "Organization name": domain_info.organization_name,
"City": domain_info.city, "City": domain_info.city,
"State": domain_info.state_territory, "State": domain_info.state_territory,
"AO": ao, "AO": domain_info.ao, # type: ignore
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ", "AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
"Security contact email": security_email, "Security contact email": security_email,
"Status": domain_info.domain.get_state_display(), "Status": domain.get_state_display(),
"Expiration date": domain_info.domain.expiration_date, "Expiration date": domain.expiration_date,
"Created at": domain_info.domain.created_at, "Created at": domain.created_at,
"First ready": domain_info.domain.first_ready, "First ready": domain.first_ready,
"Deleted": domain_info.domain.deleted, "Deleted": domain.deleted,
} }
writer.writerow([FIELDS.get(column, "") for column in columns]) row = [FIELDS.get(column, "") for column in columns]
return row
def write_body( def write_body(
@ -78,13 +108,41 @@ def write_body(
""" """
# Get the domainInfos # Get the domainInfos
domain_infos = get_domain_infos(filter_condition, sort_fields) all_domain_infos = get_domain_infos(filter_condition, sort_fields)
all_domain_infos = list(domain_infos) # Store all security emails to avoid epp calls or excessive filters
sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
security_emails_dict = {}
public_contacts = (
PublicContact.objects.only("email", "domain__name")
.select_related("domain")
.filter(registry_id__in=sec_contact_ids)
)
# Write rows to CSV # Populate a dictionary of domain names and their security contacts
for domain_info in all_domain_infos: for contact in public_contacts:
write_row(writer, columns, domain_info) domain: Domain = contact.domain
if domain is not None and domain.name not in security_emails_dict:
security_emails_dict[domain.name] = contact.email
else:
logger.warning("csv_export -> Domain was none for PublicContact")
# Reduce the memory overhead when performing the write operation
paginator = Paginator(all_domain_infos, 1000)
for page_num in paginator.page_range:
page = paginator.page(page_num)
rows = []
for domain_info in page.object_list:
try:
row = parse_row(columns, domain_info, security_emails_dict)
rows.append(row)
except ValueError:
# This should not happen. If it does, just skip this row.
# It indicates that DomainInformation.domain is None.
logger.error("csv_export -> Error when parsing row, domain was None")
continue
writer.writerows(rows)
def export_data_type_to_csv(csv_file): def export_data_type_to_csv(csv_file):

View file

@ -12,6 +12,7 @@ from .domain import (
DomainUsersView, DomainUsersView,
DomainAddUserView, DomainAddUserView,
DomainInvitationDeleteView, DomainInvitationDeleteView,
DomainDeleteUserView,
) )
from .health import * from .health import *
from .index import * from .index import *

View file

@ -33,6 +33,7 @@ from registrar.utility.errors import (
SecurityEmailErrorCodes, SecurityEmailErrorCodes,
) )
from registrar.models.utility.contact_error import ContactError from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from ..forms import ( from ..forms import (
ContactForm, ContactForm,
@ -630,6 +631,55 @@ class DomainUsersView(DomainBaseView):
template_name = "domain_users.html" template_name = "domain_users.html"
def get_context_data(self, **kwargs):
"""The initial value for the form (which is a formset here)."""
context = super().get_context_data(**kwargs)
# Add conditionals to the context (such as "can_delete_users")
context = self._add_booleans_to_context(context)
# Add modal buttons to the context (such as for delete)
context = self._add_modal_buttons_to_context(context)
# Get the email of the current user
context["current_user_email"] = self.request.user.email
return context
def _add_booleans_to_context(self, context):
# Determine if the current user can delete managers
domain_pk = None
can_delete_users = False
if self.kwargs is not None and "pk" in self.kwargs:
domain_pk = self.kwargs["pk"]
# Prevent the end user from deleting themselves as a manager if they are the
# only manager that exists on a domain.
can_delete_users = UserDomainRole.objects.filter(domain__id=domain_pk).count() > 1
context["can_delete_users"] = can_delete_users
return context
def _add_modal_buttons_to_context(self, context):
"""Adds modal buttons (and their HTML) to the context"""
# Create HTML for the modal button
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete_domain_manager">Yes, remove domain manager</button>'
)
context["modal_button"] = modal_button
# Create HTML for the modal button when deleting yourself
modal_button_self = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete_domain_manager_self">Yes, remove myself</button>'
)
context["modal_button_self"] = modal_button_self
return context
class DomainAddUserView(DomainFormBaseView): class DomainAddUserView(DomainFormBaseView):
"""Inside of a domain's user management, a form for adding users. """Inside of a domain's user management, a form for adding users.
@ -743,3 +793,60 @@ class DomainInvitationDeleteView(DomainInvitationPermissionDeleteView, SuccessMe
def get_success_message(self, cleaned_data): def get_success_message(self, cleaned_data):
return f"Successfully canceled invitation for {self.object.email}." return f"Successfully canceled invitation for {self.object.email}."
class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
"""Inside of a domain's user management, a form for deleting users."""
object: UserDomainRole # workaround for type mismatch in DeleteView
def get_object(self, queryset=None):
"""Custom get_object definition to grab a UserDomainRole object from a domain_id and user_id"""
domain_id = self.kwargs.get("pk")
user_id = self.kwargs.get("user_pk")
return UserDomainRole.objects.get(domain=domain_id, user=user_id)
def get_success_url(self):
"""Refreshes the page after a delete is successful"""
return reverse("domain-users", kwargs={"pk": self.object.domain.id})
def get_success_message(self, delete_self=False):
"""Returns confirmation content for the deletion event"""
# Grab the text representation of the user we want to delete
email_or_name = self.object.user.email
if email_or_name is None or email_or_name.strip() == "":
email_or_name = self.object.user
# If the user is deleting themselves, return a specific message.
# If not, return something more generic.
if delete_self:
message = f"You are no longer managing the domain {self.object.domain}."
else:
message = f"Removed {email_or_name} as a manager for this domain."
return message
def form_valid(self, form):
"""Delete the specified user on this domain."""
# Delete the object
super().form_valid(form)
# Is the user deleting themselves? If so, display a different message
delete_self = self.request.user == self.object.user
# Add a success message
messages.success(self.request, self.get_success_message(delete_self))
return redirect(self.get_success_url())
def post(self, request, *args, **kwargs):
"""Custom post implementation to redirect to home in the event that the user deletes themselves"""
response = super().post(request, *args, **kwargs)
# If the user is deleting themselves, redirect to home
delete_self = self.request.user == self.object.user
if delete_self:
return redirect(reverse("home"))
return response

View file

@ -286,6 +286,43 @@ class DomainApplicationPermission(PermissionsLoginMixin):
return True return True
class UserDeleteDomainRolePermission(PermissionsLoginMixin):
"""Permission mixin for UserDomainRole if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to this domain application.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
domain_pk = self.kwargs["pk"]
user_pk = self.kwargs["user_pk"]
# Check if the user is authenticated
if not self.request.user.is_authenticated:
return False
# Check if the UserDomainRole object exists, then check
# if the user requesting the delete has permissions to do so
has_delete_permission = UserDomainRole.objects.filter(
user=user_pk,
domain=domain_pk,
domain__permissions__user=self.request.user,
).exists()
if not has_delete_permission:
return False
# Check if more than one manager exists on the domain.
# If only one exists, prevent this from happening
has_multiple_managers = len(UserDomainRole.objects.filter(domain=domain_pk)) > 1
if not has_multiple_managers:
return False
return True
class DomainApplicationPermissionWithdraw(PermissionsLoginMixin): class DomainApplicationPermissionWithdraw(PermissionsLoginMixin):
"""Permission mixin that redirects to withdraw action on domain application """Permission mixin that redirects to withdraw action on domain application

View file

@ -4,6 +4,7 @@ import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView from django.views.generic import DetailView, DeleteView, TemplateView
from registrar.models import Domain, DomainApplication, DomainInvitation from registrar.models import Domain, DomainApplication, DomainInvitation
from registrar.models.user_domain_role import UserDomainRole
from .mixins import ( from .mixins import (
DomainPermission, DomainPermission,
@ -11,6 +12,7 @@ from .mixins import (
DomainApplicationPermissionWithdraw, DomainApplicationPermissionWithdraw,
DomainInvitationPermission, DomainInvitationPermission,
ApplicationWizardPermission, ApplicationWizardPermission,
UserDeleteDomainRolePermission,
) )
import logging import logging
@ -130,3 +132,20 @@ class DomainApplicationPermissionDeleteView(DomainApplicationPermission, DeleteV
model = DomainApplication model = DomainApplication
object: DomainApplication object: DomainApplication
class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteView, abc.ABC):
"""Abstract base view for deleting a UserDomainRole.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = UserDomainRole
# workaround for type mismatch in DeleteView
object: UserDomainRole
# variable name in template context for the model object
context_object_name = "userdomainrole"