Merge branch 'main' into za/1948-remove-draft-domain-and-websites

This commit is contained in:
zandercymatics 2024-04-10 09:17:41 -06:00
commit e1d09a02a3
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
34 changed files with 1553 additions and 246 deletions

View file

@ -19,6 +19,18 @@ role or set of permissions that they have. We use a `UserDomainRole`
`User.domains` many-to-many relationship that works through the
`UserDomainRole` link table.
## Migrating changes to Analyst Permissions model
Analysts are allowed a certain set of read/write registrar permissions.
Setting user permissions requires a migration to change the UserGroup
and Permission models, which requires us to manually make a migration
file for user permission changes.
To update analyst permissions do the following:
1. Make desired changes to analyst group permissions in user_group.py.
2. Follow the steps in the migration file0037_create_groups_v01.py to
create a duplicate migration for the updated user group permissions.
3. To migrate locally, run docker-compose up. To migrate on a sandbox,
push the new migration onto your sandbox before migrating.
## Permission decorator
The Django objects that need to be permission controlled are various views.

View file

@ -5,7 +5,7 @@ applications:
- python_buildpack
path: ../../src
instances: 2
memory: 512M
memory: 1G
stack: cflinuxfs4
timeout: 180
command: ./run.sh

View file

@ -562,6 +562,8 @@ class MyUserAdmin(BaseUserAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
change_form_template = "django/admin/email_clipboard_change_form.html"
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
@ -666,6 +668,17 @@ class ContactAdmin(ListHeaderAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
fieldsets = [
(
None,
{"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]},
)
]
autocomplete_fields = ["user"]
change_form_template = "django/admin/email_clipboard_change_form.html"
# We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@ -884,6 +897,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# error.
readonly_fields = ["status"]
change_form_template = "django/admin/email_clipboard_change_form.html"
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
@ -923,6 +938,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
},
),
@ -965,7 +981,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts",)
readonly_fields = ("other_contacts", "generic_org_type", "is_election_board")
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -1093,6 +1109,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
# Columns
list_display = [
"requested_domain",
"submission_date",
"status",
"generic_org_type",
"federal_type",
@ -1101,7 +1118,6 @@ class DomainRequestAdmin(ListHeaderAdmin):
"custom_election_board",
"city",
"state_territory",
"submission_date",
"submitter",
"investigator",
]
@ -1161,6 +1177,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
},
),
@ -1203,7 +1220,13 @@ class DomainRequestAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts", "current_websites", "alternative_domains")
readonly_fields = (
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
)
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -1229,7 +1252,9 @@ class DomainRequestAdmin(ListHeaderAdmin):
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# Table ordering
ordering = ["requested_domain__name"]
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
ordering = ["-submission_date", "requested_domain__name"]
change_form_template = "django/admin/domain_request_change_form.html"
@ -1441,6 +1466,8 @@ class TransitionDomainAdmin(ListHeaderAdmin):
search_fields = ["username", "domain_name"]
search_help_text = "Search by user or domain name."
change_form_template = "django/admin/email_clipboard_change_form.html"
class DomainInformationInline(admin.StackedInline):
"""Edit a domain information on the domain page.
@ -1947,6 +1974,13 @@ class DraftDomainAdmin(ListHeaderAdmin):
return super().response_change(request, obj)
class PublicContactAdmin(ListHeaderAdmin):
"""Custom PublicContact admin class."""
change_form_template = "django/admin/email_clipboard_change_form.html"
autocomplete_fields = ["domain"]
class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
@ -1955,6 +1989,8 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
"requestor",
]
change_form_template = "django/admin/email_clipboard_change_form.html"
def truncated_notes(self, obj):
# Truncate the 'notes' field to 50 characters
return str(obj.notes)[:50]
@ -1992,7 +2028,7 @@ admin.site.register(models.FederalAgency, FederalAgencyAdmin)
# do not propagate to registry and logic not applied
admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)

View file

@ -137,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin();
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
*/
(function (){
function copyInnerTextToClipboard(elem) {
let text = elem.innerText
navigator.clipboard.writeText(text)
}
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
let userId = input.getAttribute("user-id")
// Copy input value to clipboard
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
// Change the icon to a checkmark on successful copy
let buttonIcon = button.querySelector('.usa-button__clipboard use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
nearestSpan = button.querySelector("span")
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
if (button.classList.contains('usa-button__small-text')) {
nearestSpan.innerText = "Copy email";
} else {
nearestSpan.innerText = "Copy";
}
}, 2000);
}
}).catch(function(error) {
console.error('Clipboard copy failed', error);
});
}
}
function handleClipboardButtons() {
clipboardButtons = document.querySelectorAll(".usa-button__clipboard")
clipboardButtons.forEach((button) => {
// Handle copying the text to your clipboard,
// and changing the icon.
button.addEventListener("click", ()=>{
copyToClipboardAndChangeIcon(button);
});
// Add a class that adds the outline style on click
button.addEventListener("mousedown", function() {
this.classList.add("no-outline-on-click");
});
// But add it back in after the user clicked,
// for accessibility reasons (so we can still tab, etc)
button.addEventListener("blur", function() {
this.classList.remove("no-outline-on-click");
});
});
}
function handleClipboardLinks() {
let emailButtons = document.querySelectorAll(".usa-button__clipboard-link");
if (emailButtons){
emailButtons.forEach((button) => {
button.addEventListener("click", ()=>{
copyInnerTextToClipboard(button);
})
});
}
}
handleClipboardButtons();
handleClipboardLinks();
})();
/**
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*

View file

@ -392,32 +392,34 @@ address.margin-top-neg-1__detail-list {
margin-top: 5px !important;
}
// Mimic the normal label size
dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
address {
address, dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
}
td button.usa-button__clipboard-link, address.dja-address-contact-list {
font-size: unset;
}
address.dja-address-contact-list {
font-size: 0.8125rem;
color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
}
// Mimic the normal label size
@media (max-width: 1024px){
.dja-detail-list dt {
.dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
}
.dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
font-size: 0.875rem !important;
}
}
.errors span.select2-selection {
@ -533,3 +535,69 @@ address.dja-address-contact-list {
color: var(--link-fg);
}
}
// Make the clipboard button "float" inside of the input box
.admin-icon-group {
position: relative;
display: inline;
align-items: center;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
// Match the height of other inputs
min-height: 2.25rem !important;
}
button {
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
@media (max-width: 1000px) {
button {
display: block;
padding-top: 8px;
}
}
span {
padding-left: 0.1rem;
}
}
.admin-icon-group.admin-icon-group__clipboard-link {
position: relative;
display: inline;
align-items: center;
.usa-button__icon {
position: absolute;
right: auto;
left: 4px;
height: 100%;
}
button {
font-size: unset !important;
display: inline-flex;
padding-top: 4px;
line-height: 14px;
color: var(--link-fg);
width: max-content;
font-size: unset;
text-decoration: none !important;
}
}
.no-outline-on-click:focus {
outline: none !important;
}
.usa-button__small-text {
font-size: small;
}

View file

@ -2,7 +2,7 @@
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
@ -10,7 +10,7 @@
}
@include at-media(tablet) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
@ -18,7 +18,7 @@
}
@include at-media(mobile) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;

View file

@ -98,6 +98,8 @@ class DomainRequestFixture:
def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`."""
da.status = app["status"] if "status" in app else "started"
# TODO for a future ticket: Allow for more than just "federal" here
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
da.federal_agency = (
app["federal_agency"]
@ -235,9 +237,6 @@ class DomainFixture(DomainRequestFixture):
).last()
logger.debug(f"Approving {domain_request} for {user}")
# We don't want fixtures sending out real emails to
# fake email addresses, so we just skip that and log it instead
# All approvals require an investigator, so if there is none,
# assign one.
if domain_request.investigator is None:

View file

@ -2,7 +2,7 @@
import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -31,7 +31,17 @@ logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
def clean(self):
"""clean form data by lowercasing email"""
@ -171,6 +181,8 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
email = forms.EmailField(max_length=None)
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
@ -194,6 +206,10 @@ class ContactForm(forms.ModelForm):
# which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
# Define a custom validator for the email field with a custom error message
email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
self.fields["email"].validators.append(email_max_length_validator)
for field_name in self.required:
self.fields[field_name].required = True
@ -291,10 +307,17 @@ class DomainSecurityEmailForm(forms.Form):
security_email = forms.EmailField(
label="Security email (optional)",
max_length=None,
required=False,
error_messages={
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)

View file

@ -369,7 +369,14 @@ class AuthorizingOfficialForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
@ -566,7 +573,14 @@ class YourContactForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
phone = PhoneNumberField(
label="Phone",
@ -621,10 +635,17 @@ class OtherContactsForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={
"required": ("Enter an email address in the required format, like name@example.com."),
"invalid": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
phone = PhoneNumberField(
label="Phone",

View file

@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-04-01 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0081_create_groups_v10"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="organization_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AddField(
model_name="domainrequest",
name="organization_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="domaininformation",
name="generic_org_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
],
help_text="Type of organization",
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-04-09 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0082_domaininformation_organization_type_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="email",
field=models.EmailField(blank=True, db_index=True, max_length=320, null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="email",
field=models.EmailField(help_text="Contact's email address", max_length=320),
),
]

View file

@ -40,6 +40,7 @@ class Contact(TimeStampedModel):
null=True,
blank=True,
db_index=True,
max_length=320,
)
phone = PhoneNumberField(
null=True,

View file

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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from .domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel
@ -54,7 +55,23 @@ class DomainInformation(TimeStampedModel):
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of Organization",
help_text="Type of organization",
)
# TODO - Ticket #1911: stub this data from DomainRequest
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=DomainRequest.OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text="Type of organization - Election office",
)
federally_recognized_tribe = models.BooleanField(
@ -219,6 +236,34 @@ class DomainInformation(TimeStampedModel):
except Exception:
return ""
def save(self, *args, **kwargs):
"""Save override for custom properties"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_election" variant.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
super().save(*args, **kwargs)
@classmethod
def create_from_da(cls, domain_request: DomainRequest, domain=None):
"""Takes in a DomainRequest and converts it into DomainInformation"""

View file

@ -9,6 +9,7 @@ from django.db import models
from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone
from registrar.models.domain import Domain
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from .utility.time_stamped_model import TimeStampedModel
@ -100,8 +101,8 @@ class DomainRequest(TimeStampedModel):
class OrganizationChoices(models.TextChoices):
"""
Primary organization choices:
For use in django admin
Keys need to match OrganizationChoicesVerbose
For use in the domain request experience
Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose
"""
FEDERAL = "federal", "Federal"
@ -113,9 +114,77 @@ class DomainRequest(TimeStampedModel):
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
class OrgChoicesElectionOffice(models.TextChoices):
"""
Primary organization choices for Django admin:
Keys need to match OrganizationChoices and OrganizationChoicesVerbose.
The enums here come in two variants:
Regular (matches the choices from OrganizationChoices)
Election (Appends " - Election" to the string)
When adding the election variant, you must append "_election" to the end of the string.
"""
# We can't inherit OrganizationChoices due to models.TextChoices being an enum.
# We can redefine these values instead.
FEDERAL = "federal", "Federal"
INTERSTATE = "interstate", "Interstate"
STATE_OR_TERRITORY = "state_or_territory", "State or territory"
TRIBAL = "tribal", "Tribal"
COUNTY = "county", "County"
CITY = "city", "City"
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
# Election variants
STATE_OR_TERRITORY_ELECTION = "state_or_territory_election", "State or territory - Election"
TRIBAL_ELECTION = "tribal_election", "Tribal - Election"
COUNTY_ELECTION = "county_election", "County - Election"
CITY_ELECTION = "city_election", "City - Election"
SPECIAL_DISTRICT_ELECTION = "special_district_election", "Special district - Election"
@classmethod
def get_org_election_to_org_generic(cls):
"""
Creates and returns a dictionary mapping from election-specific organization
choice enums to their corresponding general organization choice enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY_ELECTION: cls.STATE_OR_TERRITORY,
cls.TRIBAL_ELECTION: cls.TRIBAL,
cls.COUNTY_ELECTION: cls.COUNTY,
cls.CITY_ELECTION: cls.CITY,
cls.SPECIAL_DISTRICT_ELECTION: cls.SPECIAL_DISTRICT,
}
return org_election_map
@classmethod
def get_org_generic_to_org_election(cls):
"""
Creates and returns a dictionary mapping from general organization
choice enums to their corresponding election-specific organization enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY: cls.STATE_OR_TERRITORY_ELECTION,
cls.TRIBAL: cls.TRIBAL_ELECTION,
cls.COUNTY: cls.COUNTY_ELECTION,
cls.CITY: cls.CITY_ELECTION,
cls.SPECIAL_DISTRICT: cls.SPECIAL_DISTRICT_ELECTION,
}
return org_election_map
class OrganizationChoicesVerbose(models.TextChoices):
"""
Secondary organization choices
Tertiary organization choices
For use in the domain request form and on the templates
Keys need to match OrganizationChoices
"""
@ -406,6 +475,21 @@ class DomainRequest(TimeStampedModel):
help_text="Type of organization",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text="Type of organization - Election office",
)
federally_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe federally recognized",
@ -437,18 +521,13 @@ class DomainRequest(TimeStampedModel):
help_text="Federal government branch",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
organization_name = models.CharField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
address_line1 = models.CharField(
null=True,
blank=True,
@ -525,6 +604,7 @@ class DomainRequest(TimeStampedModel):
related_name="domain_request",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(
"registrar.Website",
blank=True,
@ -586,6 +666,34 @@ class DomainRequest(TimeStampedModel):
help_text="Notes about this request",
)
def save(self, *args, **kwargs):
"""Save override for custom properties"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_election" variant.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = self.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = self.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
super().save(*args, **kwargs)
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:

View file

@ -68,7 +68,7 @@ class PublicContact(TimeStampedModel):
sp = models.CharField(null=False, help_text="Contact's state or province")
pc = models.CharField(null=False, help_text="Contact's postal code")
cc = models.CharField(null=False, help_text="Contact's country code")
email = models.EmailField(null=False, help_text="Contact's email address")
email = models.EmailField(null=False, help_text="Contact's email address", max_length=320)
voice = models.CharField(null=False, help_text="Contact's phone number. Must be in ITU.E164.2005 format")
fax = models.CharField(
null=True,

View file

@ -5,6 +5,11 @@ logger = logging.getLogger(__name__)
class UserGroup(Group):
"""
UserGroup sets read and write permissions for superusers (who have full access)
and analysts. For more details, see the dev docs for user-permissions.
"""
class Meta:
verbose_name = "User group"
verbose_name_plural = "User groups"

View file

@ -35,3 +35,219 @@ class Timer:
self.end = time.time()
self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds")
class CreateOrUpdateOrganizationTypeHelper:
"""
A helper that manages the "organization_type" field in DomainRequest and DomainInformation
"""
def __init__(self, sender, instance, generic_org_to_org_map, election_org_to_generic_org_map):
# The "model type"
self.sender = sender
self.instance = instance
self.generic_org_to_org_map = generic_org_to_org_map
self.election_org_to_generic_org_map = election_org_to_generic_org_map
def create_or_update_organization_type(self):
"""The organization_type field on DomainRequest and DomainInformation is consituted from the
generic_org_type and is_election_board fields. To keep the organization_type
field up to date, we need to update it before save based off of those field
values.
If the instance is marked as an election board and the generic_org_type is not
one of the excluded types (FEDERAL, INTERSTATE, SCHOOL_DISTRICT), the
organization_type is set to a corresponding election variant. Otherwise, it directly
mirrors the generic_org_type value.
"""
# A new record is added with organization_type not defined.
# This happens from the regular domain request flow.
is_new_instance = self.instance.id is None
if is_new_instance:
self._handle_new_instance()
else:
self._handle_existing_instance()
return self.instance
def _handle_new_instance(self):
# == Check for invalid conditions before proceeding == #
should_proceed = self._validate_new_instance()
if not should_proceed:
return None
# == Program flow will halt here if there is no reason to update == #
# == Update the linked values == #
organization_type_needs_update = self.instance.organization_type is None
generic_org_type_needs_update = self.instance.generic_org_type is None
# If a field is none, it indicates (per prior checks) that the
# related field (generic org type <-> org type) has data and we should update according to that.
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _handle_existing_instance(self):
# == Init variables == #
# Instance is already in the database, fetch its current state
current_instance = self.sender.objects.get(id=self.instance.id)
# Check the new and old values
generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
organization_type_changed = self.instance.organization_type != current_instance.organization_type
# == Check for invalid conditions before proceeding == #
if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
# Since organization type is linked with generic_org_type and election board,
# we have to update one or the other, not both.
# This will not happen in normal flow as it is not possible otherwise.
raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
# No values to update - do nothing
return None
# == Program flow will halt here if there is no reason to update == #
# == Update the linked values == #
# Find out which field needs updating
organization_type_needs_update = generic_org_type_changed or is_election_board_changed
generic_org_type_needs_update = organization_type_changed
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
"""
Validates the conditions for updating organization and generic organization types.
Raises:
ValueError: If both organization_type_needs_update and generic_org_type_needs_update are True,
indicating an attempt to update both fields simultaneously, which is not allowed.
"""
# We shouldn't update both of these at the same time.
# It is more useful to have these as seperate variables, but it does impose
# this restraint.
if organization_type_needs_update and generic_org_type_needs_update:
raise ValueError("Cannot update both org type and generic org type at the same time.")
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
def _update_org_type_from_generic_org_and_election(self):
"""Given a field values for generic_org_type and is_election_board, update the
organization_type field."""
# We convert to a string because the enum types are different.
generic_org_type = str(self.instance.generic_org_type)
if generic_org_type not in self.generic_org_to_org_map:
# Election board should always be reset to None if the record
# can't have one. For example, federal.
if self.instance.is_election_board is not None:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {generic_org_type}. Setting to None."
)
self.instance.is_election_board = None
self.instance.organization_type = generic_org_type
else:
# This can only happen with manual data tinkering, which causes these to be out of sync.
if self.instance.is_election_board is None:
logger.warning(
"create_or_update_organization_type() -> is_election_board is out of sync. Updating value."
)
self.instance.is_election_board = False
if self.instance.is_election_board:
self.instance.organization_type = self.generic_org_to_org_map[generic_org_type]
else:
self.instance.organization_type = generic_org_type
def _update_generic_org_and_election_from_org_type(self):
"""Given the field value for organization_type, update the
generic_org_type and is_election_board field."""
# We convert to a string because the enum types are different
# between OrgChoicesElectionOffice and OrganizationChoices.
# But their names are the same (for the most part).
current_org_type = str(self.instance.organization_type)
election_org_map = self.election_org_to_generic_org_map
generic_org_map = self.generic_org_to_org_map
# This essentially means: "_election" in current_org_type.
if current_org_type in election_org_map:
new_org = election_org_map[current_org_type]
self.instance.generic_org_type = new_org
self.instance.is_election_board = True
elif self.instance.organization_type is not None:
self.instance.generic_org_type = current_org_type
# This basically checks if the given org type
# can even have an election board in the first place.
# For instance, federal cannot so is_election_board = None
if current_org_type in generic_org_map:
self.instance.is_election_board = False
else:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {current_org_type}. Setting to None."
)
self.instance.is_election_board = None
else:
# if self.instance.organization_type is set to None, then this means
# we should clear the related fields.
# This will not occur if it just is None (i.e. default), only if it is set to be so.
self.instance.is_election_board = None
self.instance.generic_org_type = None
def _validate_new_instance(self):
"""
Validates whether a new instance of DomainRequest or DomainInformation can proceed with the update
based on the consistency between organization_type, generic_org_type, and is_election_board.
Returns a boolean determining if execution should proceed or not.
"""
# We conditionally accept both of these values to exist simultaneously, as long as
# those values do not intefere with eachother.
# Because this condition can only be triggered through a dev (no user flow),
# we throw an error if an invalid state is found here.
if self.instance.organization_type and self.instance.generic_org_type:
generic_org_type = str(self.instance.generic_org_type)
organization_type = str(self.instance.organization_type)
# Strip "_election" if it exists
mapped_org_type = self.election_org_to_generic_org_map.get(organization_type)
# Do tests on the org update for election board changes.
is_election_type = "_election" in organization_type
can_have_election_board = organization_type in self.generic_org_to_org_map
election_board_mismatch = (is_election_type != self.instance.is_election_board) and can_have_election_board
org_type_mismatch = mapped_org_type is not None and (generic_org_type != mapped_org_type)
if election_board_mismatch or org_type_mismatch:
message = (
"Cannot add organization_type and generic_org_type simultaneously "
"when generic_org_type, is_election_board, and organization_type values do not match."
)
raise ValueError(message)
return True
elif not self.instance.organization_type and not self.instance.generic_org_type:
return False
else:
return True

View file

@ -0,0 +1,39 @@
{% load i18n static %}
{% comment %}
Template for an input field with a clipboard
{% endcomment %}
{% if not invisible_input_field %}
<div class="admin-icon-group">
{{ field }}
<button
class="usa-button usa-button--unstyled padding-left-1 usa-button__icon usa-button__clipboard"
type="button"
>
<div class="no-outline-on-click">
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy</span>
</div>
</button>
</div>
{% else %}
<div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span class="padding-left-05">Copy</span>
</button>
</div>
{% endif %}

View file

@ -0,0 +1,8 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block field_sets %}
{% for fieldset in adminform %}
{% include "django/admin/includes/email_clipboard_fieldset.html" %}
{% endfor %}
{% endblock %}

View file

@ -26,10 +26,12 @@
{% if user.email or user.contact.email %}
{% if user.contact.email %}
{{ user.contact.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %}
{{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %}
<br>
<br class="admin-icon-group__br">
{% else %}
None<br>
{% endif %}

View file

@ -92,8 +92,25 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<tr>
<th class="padding-left-1" scope="row">{{ contact.get_formatted_name }}</th>
<td class="padding-left-1">{{ contact.title }}</td>
<td class="padding-left-1">{{ contact.email }}</td>
<td class="padding-left-1">
{{ contact.email }}
</td>
<td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small">
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard usa-button__small-text text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy email</span>
</button>
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -0,0 +1,13 @@
{% extends "django/admin/includes/detail_table_fieldset.html" %}
{% comment %}
This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %}
{% block field_other %}
{% if field.field.name == "email" %}
{% include "admin/input_with_clipboard.html" with field=field.field %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock field_other %}

View file

@ -41,7 +41,7 @@
</div>
</div>
<br>
<p> <b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}<br></p>
<p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p>
<p>{% include "includes/domain_request.html" %}</p>
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>

View file

@ -84,7 +84,7 @@
{% else %}
<input
type="submit"
class="usa-button--unstyled disabled-button usa-tooltip"
class="usa-button--unstyled disabled-button usa-tooltip usa-tooltip--registrar"
value="Remove"
data-position="bottom"
title="Domains must have at least one domain manager"

View file

@ -58,7 +58,7 @@
{{ domain.state|capfirst }}
{% endif %}
<svg
class="usa-icon usa-tooltip text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
data-position="top"
title="{{domain.get_state_help_text}}"
focusable="true"

View file

@ -1,5 +1,8 @@
<div class="usa-alert usa-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}">
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"
>
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %}">
<b>Attention:</b> You are on a test site.
</div>
</div>
</div>

View file

@ -158,7 +158,7 @@ class GenericTestHelper(TestCase):
Example Usage:
```
self.assert_sort_helper(
self.factory, self.superuser, self.admin, self.url, DomainInformation, "1", ("domain__name",)
"1", ("domain__name",)
)
```
@ -585,7 +585,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
is_election_board=True,
is_election_board=False,
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_2, generic_org_type="interstate", is_election_board=True
@ -595,14 +595,14 @@ class MockDb(TestCase):
domain=self.domain_3,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
is_election_board=False,
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
is_election_board=False,
)
self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@ -652,7 +652,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
is_election_board=True,
is_election_board=False,
)
self.domain_information_12, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@ -693,6 +693,24 @@ class MockDb(TestCase):
user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER
)
_, created = DomainInvitation.objects.get_or_create(
email=meoward_user.email, domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
)
_, created = DomainInvitation.objects.get_or_create(
email="woofwardthethird@rocks.com",
domain=self.domain_1,
status=DomainInvitation.DomainInvitationStatus.INVITED,
)
_, created = DomainInvitation.objects.get_or_create(
email="squeaker@rocks.com", domain=self.domain_2, status=DomainInvitation.DomainInvitationStatus.INVITED
)
_, created = DomainInvitation.objects.get_or_create(
email="squeaker@rocks.com", domain=self.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED
)
with less_console_noise():
self.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
@ -722,6 +740,7 @@ class MockDb(TestCase):
DomainRequest.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
DomainInvitation.objects.all().delete()
def mock_user():
@ -782,6 +801,9 @@ def completed_domain_request(
submitter=False,
name="city.gov",
investigator=None,
generic_org_type="federal",
is_election_board=False,
organization_type=None,
):
"""A completed domain request."""
if not user:
@ -819,7 +841,8 @@ def completed_domain_request(
is_staff=True,
)
domain_request_kwargs = dict(
generic_org_type="federal",
generic_org_type=generic_org_type,
is_election_board=is_election_board,
federal_type="executive",
purpose="Purpose of the site",
is_policy_acknowledged=True,
@ -840,6 +863,9 @@ def completed_domain_request(
if has_anything_else:
domain_request_kwargs["anything_else"] = "There is more"
if organization_type:
domain_request_kwargs["organization_type"] = organization_type
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:

View file

@ -1,4 +1,6 @@
from datetime import date
from datetime import date, datetime
from django.utils import timezone
import re
from django.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack
@ -845,7 +847,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",))
def test_submitter_sortable(self):
"""Tests if the DomainRequest sorts by domain correctly"""
"""Tests if the DomainRequest sorts by submitter correctly"""
with less_console_noise():
p = "adminpass"
self.client.login(username="superuser", password=p)
@ -876,7 +878,7 @@ class TestDomainRequestAdmin(MockEppLib):
)
def test_investigator_sortable(self):
"""Tests if the DomainRequest sorts by domain correctly"""
"""Tests if the DomainRequest sorts by investigator correctly"""
with less_console_noise():
p = "adminpass"
self.client.login(username="superuser", password=p)
@ -889,7 +891,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that our sort works correctly
self.test_helper.assert_table_sorted(
"6",
"12",
(
"investigator__first_name",
"investigator__last_name",
@ -898,13 +900,77 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that sorting in reverse works correctly
self.test_helper.assert_table_sorted(
"-6",
"-12",
(
"-investigator__first_name",
"-investigator__last_name",
),
)
@less_console_noise_decorator
def test_default_sorting_in_domain_requests_list(self):
"""
Make sure the default sortin in on the domain requests list page is reverse submission_date
then alphabetical requested_domain
"""
# Create domain requests with different names
domain_requests = [
completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, name=name)
for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"]
]
domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16))
domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16))
domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16))
domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16))
domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16))
domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16))
# Save the modified domain requests to update their attributes in the database
for domain_request in domain_requests:
domain_request.save()
# Refresh domain request objects from the database to reflect the changes
domain_requests = [DomainRequest.objects.get(pk=domain_request.pk) for domain_request in domain_requests]
# Login as superuser and retrieve the domain request list page
self.client.force_login(self.superuser)
response = self.client.get("/admin/registrar/domainrequest/")
# Check that the response is successful
self.assertEqual(response.status_code, 200)
# Extract the domain names from the response content using regex
domain_names_match = re.findall(r"(\w+\.gov)</a>", response.content.decode("utf-8"))
logger.info(f"domain_names_match {domain_names_match}")
# Verify that domain names are found
self.assertTrue(domain_names_match)
# Extract the domain names
domain_names = [match for match in domain_names_match]
# Verify that the domain names are displayed in the expected order
expected_order = [
"ccc.gov",
"zzz.gov",
"bbb.gov",
"aaa.gov",
"ddd.gov",
"eee.gov",
]
# Remove duplicates
# Remove duplicates from domain_names list while preserving order
unique_domain_names = []
for domain_name in domain_names:
if domain_name not in unique_domain_names:
unique_domain_names.append(domain_name)
self.assertEqual(unique_domain_names, expected_order)
def test_short_org_name_in_domain_requests_list(self):
"""
Make sure the short name is displaying in admin on the list page
@ -1569,9 +1635,6 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the modal has the right content
# Check for the header
# == Check for the creator == #
# Check for the right title, email, and phone number in the response.
@ -1587,37 +1650,38 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name.
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
def test_save_model_sets_restricted_status_on_user(self):
with less_console_noise():
# make sure there is no user with this email
@ -1717,6 +1781,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"id",
"created_at",
"updated_at",
@ -1725,12 +1791,13 @@ class TestDomainRequestAdmin(MockEppLib):
"creator",
"investigator",
"generic_org_type",
"is_election_board",
"organization_type",
"federally_recognized_tribe",
"state_recognized_tribe",
"tribe_name",
"federal_agency",
"federal_type",
"is_election_board",
"organization_name",
"address_line1",
"address_line2",
@ -1765,6 +1832,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"creator",
"about_your_organization",
"requested_domain",
@ -1790,6 +1859,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
]
self.assertEqual(readonly_fields, expected_fields)
@ -2346,37 +2417,38 @@ class TestDomainInformationAdmin(TestCase):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name.
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
def test_readonly_fields_for_analyst(self):
"""Ensures that analysts have their permissions setup correctly"""
with less_console_noise():
@ -2387,6 +2459,8 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [
"other_contacts",
"generic_org_type",
"is_election_board",
"creator",
"type_of_work",
"more_organization_information",

View file

@ -1161,3 +1161,309 @@ class TestContact(TestCase):
# test for a contact which is assigned as an authorizing official on a domain request
self.assertFalse(self.contact_as_ao.has_more_than_one_join("authorizing_official"))
self.assertTrue(self.contact_as_ao.has_more_than_one_join("submitted_domain_requests"))
class TestDomainRequestCustomSave(TestCase):
"""Tests custom save behaviour on the DomainRequest object"""
def tearDown(self):
DomainRequest.objects.all().delete()
super().tearDown()
def test_create_or_update_organization_type_new_instance(self):
"""Test create_or_update_organization_type when creating a new instance"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
"""Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
is_election_board=True,
)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
self.assertEqual(domain_request.is_election_board, None)
def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
"""Test create_or_update_organization_type for an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_request.is_election_board = True
domain_request.save()
self.assertEqual(domain_request.is_election_board, True)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value
domain_request.is_election_board = False
domain_request.save()
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
domain_request.is_election_board = None
domain_request.save()
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
"""Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_request.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
domain_request.save()
# Election board should be None because interstate cannot have an election board.
self.assertEqual(domain_request.is_election_board, None)
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
# Try changing the org Type to something that CAN have an election board.
domain_request_tribal = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedTribal.gov",
generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
is_election_board=True,
)
self.assertEqual(
domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
)
# Change the org type
domain_request_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
domain_request_tribal.save()
self.assertEqual(domain_request_tribal.is_election_board, True)
self.assertEqual(
domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION
)
def test_create_or_update_organization_type_no_update(self):
"""Test create_or_update_organization_type when there are no values to update."""
# Test for when both generic_org_type and organization_type is declared,
# and are both non-election board
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_request.save()
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for when both generic_org_type and organization_type is declared,
# and are both election board
domain_request_election = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedElection.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
)
self.assertEqual(
domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_request_election.is_election_board, True)
self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Modify an unrelated existing value for both, and ensure that everything is still consistent
domain_request.city = "Fudge"
domain_request_election.city = "Caramel"
domain_request.save()
domain_request_election.save()
self.assertEqual(domain_request.city, "Fudge")
self.assertEqual(domain_request_election.city, "Caramel")
# Test for non-election
self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_request.is_election_board, False)
self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for election
self.assertEqual(
domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_request_election.is_election_board, True)
self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
class TestDomainInformationCustomSave(TestCase):
"""Tests custom save behaviour on the DomainInformation object"""
def tearDown(self):
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()
def test_create_or_update_organization_type_new_instance(self):
"""Test create_or_update_organization_type when creating a new instance"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
"""Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
self.assertEqual(domain_information.is_election_board, None)
def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
"""Test create_or_update_organization_type for an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.is_election_board = True
domain_information.save()
self.assertEqual(domain_information.is_election_board, True)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
# Try reverting the election board value
domain_information.is_election_board = False
domain_information.save()
domain_information.refresh_from_db()
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
# Try reverting setting an invalid value for election board (should revert to False)
domain_information.is_election_board = None
domain_information.save()
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
"""Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
domain_information.save()
# Election board should be None because interstate cannot have an election board.
self.assertEqual(domain_information.is_election_board, None)
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
# Try changing the org Type to something that CAN have an election board.
domain_request_tribal = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedTribal.gov",
generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
is_election_board=True,
)
domain_information_tribal = DomainInformation.create_from_da(domain_request_tribal)
self.assertEqual(
domain_information_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
)
# Change the org type
domain_information_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
domain_information_tribal.save()
self.assertEqual(domain_information_tribal.is_election_board, True)
self.assertEqual(
domain_information_tribal.organization_type,
DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION,
)
def test_create_or_update_organization_type_no_update(self):
"""Test create_or_update_organization_type when there are no values to update."""
# Test for when both generic_org_type and organization_type is declared,
# and are both non-election board
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=False,
)
domain_information = DomainInformation.create_from_da(domain_request)
domain_information.save()
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for when both generic_org_type and organization_type is declared,
# and are both election board
domain_request_election = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="startedElection.gov",
generic_org_type=DomainRequest.OrganizationChoices.CITY,
is_election_board=True,
organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
)
domain_information_election = DomainInformation.create_from_da(domain_request_election)
self.assertEqual(
domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_information_election.is_election_board, True)
self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Modify an unrelated existing value for both, and ensure that everything is still consistent
domain_information.city = "Fudge"
domain_information_election.city = "Caramel"
domain_information.save()
domain_information_election.save()
self.assertEqual(domain_information.city, "Fudge")
self.assertEqual(domain_information_election.city, "Caramel")
# Test for non-election
self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
self.assertEqual(domain_information.is_election_board, False)
self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
# Test for election
self.assertEqual(
domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
)
self.assertEqual(domain_information_election.is_election_board, True)
self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)

View file

@ -9,10 +9,10 @@ from registrar.utility.csv_export import (
export_data_unmanaged_domains_to_csv,
get_sliced_domains,
get_sliced_requests,
write_domains_csv,
write_csv_for_domains,
get_default_start_date,
get_default_end_date,
write_requests_csv,
write_csv_for_requests,
)
from django.core.management import call_command
@ -242,8 +242,13 @@ class ExportDataTest(MockDb, MockEppLib):
}
self.maxDiff = None
# Call the export functions
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning
@ -268,7 +273,7 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_write_domains_csv(self):
def test_write_csv_for_domains(self):
"""Test that write_body returns the
existing domain, test that sort by domain name works,
test that filter works"""
@ -304,8 +309,13 @@ class ExportDataTest(MockDb, MockEppLib):
],
}
# Call the export functions
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@ -357,8 +367,13 @@ class ExportDataTest(MockDb, MockEppLib):
],
}
# Call the export functions
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@ -433,20 +448,20 @@ class ExportDataTest(MockDb, MockEppLib):
}
# Call the export functions
write_domains_csv(
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
get_domain_managers=False,
should_get_domain_managers=False,
should_write_header=True,
)
write_domains_csv(
write_csv_for_domains(
writer,
columns,
sort_fields_for_deleted_domains,
filter_conditions_for_deleted_domains,
get_domain_managers=False,
should_get_domain_managers=False,
should_write_header=False,
)
# Reset the CSV file's position to the beginning
@ -478,7 +493,12 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the
expected domain managers."""
expected domain managers.
An invited user, woofwardthethird, should also be pulled into this report.
squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers).
She should show twice in this report but not in test_export_data_managed_domains_to_csv."""
with less_console_noise():
# Create a CSV file in memory
@ -508,8 +528,13 @@ class ExportDataTest(MockDb, MockEppLib):
}
self.maxDiff = None
# Call the export functions
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=True,
should_write_header=True,
)
# Reset the CSV file's position to the beginning
@ -521,14 +546,16 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email,"
"Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n"
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n"
"cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.com\n"
"Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n"
"cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n"
"cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,,"
", , , ,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n"
", , , ,meoward@rocks.com,R,info@example.com,R,big_lebowski@dude.co,R,"
"woofwardthethird@rocks.com,I\n"
"ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
"zdomain12.govReadyInterstatemeoward@rocks.com\n"
"zdomain12.govReadyInterstatemeoward@rocks.comR\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
@ -538,7 +565,9 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates,
get list of managed domains at end_date."""
get list of managed domains at end_date.
An invited user, woofwardthethird, should also be pulled into this report."""
with less_console_noise():
# Create a CSV file in memory
@ -562,12 +591,14 @@ class ExportDataTest(MockDb, MockEppLib):
"MANAGED DOMAINS COUNTS AT END DATE\n"
"Total,Federal,Interstate,State or territory,Tribal,County,City,"
"Special district,School district,Election office\n"
"3,2,1,0,0,0,0,0,0,2\n"
"3,2,1,0,0,0,0,0,0,0\n"
"\n"
"Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n"
"cdomain11.govFederal-Executivemeoward@rocks.com\n"
"cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n"
"zdomain12.govInterstatemeoward@rocks.com\n"
"Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"cdomain11.govFederal-Executivemeoward@rocks.com, R\n"
"cdomain1.gov,Federal - Executive,meoward@rocks.com,R,info@example.com,R,"
"big_lebowski@dude.co,R,woofwardthethird@rocks.com,I\n"
"zdomain12.govInterstatemeoward@rocks.com,R\n"
)
# Normalize line endings and remove commas,
@ -642,7 +673,7 @@ class ExportDataTest(MockDb, MockEppLib):
"submission_date__lte": self.end_date,
"submission_date__gte": self.start_date,
}
write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True)
write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
@ -686,13 +717,13 @@ class HelperFunctions(MockDb):
"domain__first_ready__lte": self.end_date,
}
# Test with distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 2]
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [3, 4, 1, 0, 0, 0, 0, 0, 0, 2]
expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
def test_get_sliced_requests(self):

View file

@ -1,6 +1,5 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from registrar.models import Contact

View file

@ -1,8 +1,8 @@
from collections import Counter
import csv
import logging
from datetime import datetime
from registrar.models.domain import Domain
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation
from django.utils import timezone
@ -11,6 +11,7 @@ from django.db.models import F, Value, CharField
from django.db.models.functions import Concat, Coalesce
from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__)
@ -33,7 +34,6 @@ def get_domain_infos(filter_condition, sort_fields):
"""
domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official")
.prefetch_related("domain__permissions")
.filter(**filter_condition)
.order_by(*sort_fields)
.distinct()
@ -53,7 +53,14 @@ def get_domain_infos(filter_condition, sort_fields):
return domain_infos_cleaned
def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False):
def parse_row_for_domain(
columns,
domain_info: DomainInformation,
dict_security_emails=None,
should_get_domain_managers=False,
dict_domain_invitations_with_invited_status=None,
dict_user_domain_roles=None,
):
"""Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information
@ -65,8 +72,8 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di
# 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)
if dict_security_emails is not None and domain.name in dict_security_emails:
_email = dict_security_emails.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.
@ -103,13 +110,22 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di
"Deleted": domain.deleted,
}
if get_domain_managers:
# Get each domain managers email and add to list
dm_emails = [dm.user.email for dm in domain.permissions.all()]
if should_get_domain_managers:
# Get lists of emails for active and invited domain managers
# Set up the "matching header" + row field data
for i, dm_email in enumerate(dm_emails, start=1):
FIELDS[f"Domain manager email {i}"] = dm_email
dms_active_emails = dict_user_domain_roles.get(domain_info.domain.name, [])
dms_invited_emails = dict_domain_invitations_with_invited_status.get(domain_info.domain.name, [])
# Set up the "matching headers" + row field data for email and status
i = 0 # Declare i outside of the loop to avoid a reference before assignment in the second loop
for i, dm_email in enumerate(dms_active_emails, start=1):
FIELDS[f"Domain manager {i}"] = dm_email
FIELDS[f"DM{i} status"] = "R"
# Continue enumeration from where we left off and add data for invited domain managers
for j, dm_email in enumerate(dms_invited_emails, start=i + 1):
FIELDS[f"Domain manager {j}"] = dm_email
FIELDS[f"DM{j} status"] = "I"
row = [FIELDS.get(column, "") for column in columns]
return row
@ -119,7 +135,7 @@ def _get_security_emails(sec_contact_ids):
"""
Retrieve security contact emails for the given security contact IDs.
"""
security_emails_dict = {}
dict_security_emails = {}
public_contacts = (
PublicContact.objects.only("email", "domain__name")
.select_related("domain")
@ -129,65 +145,151 @@ def _get_security_emails(sec_contact_ids):
# Populate a dictionary of domain names and their security contacts
for contact in public_contacts:
domain: Domain = contact.domain
if domain is not None and domain.name not in security_emails_dict:
security_emails_dict[domain.name] = contact.email
if domain is not None and domain.name not in dict_security_emails:
dict_security_emails[domain.name] = contact.email
else:
logger.warning("csv_export -> Domain was none for PublicContact")
return security_emails_dict
return dict_security_emails
def write_domains_csv(
def count_domain_managers(domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles):
"""Count active and invited domain managers"""
dms_active = len(dict_user_domain_roles.get(domain_name, []))
dms_invited = len(dict_domain_invitations_with_invited_status.get(domain_name, []))
return dms_active, dms_invited
def update_columns(columns, dms_total, should_update_columns):
"""Update columns if necessary"""
if should_update_columns:
for i in range(1, dms_total + 1):
email_column_header = f"Domain manager {i}"
status_column_header = f"DM{i} status"
if email_column_header not in columns:
columns.append(email_column_header)
columns.append(status_column_header)
should_update_columns = False
return columns, should_update_columns, dms_total
def update_columns_with_domain_managers(
columns,
domain_info,
should_update_columns,
dms_total,
dict_domain_invitations_with_invited_status,
dict_user_domain_roles,
):
"""Helper function to update columns with domain manager information"""
domain_name = domain_info.domain.name
try:
dms_active, dms_invited = count_domain_managers(
domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles
)
if dms_active + dms_invited > dms_total:
dms_total = dms_active + dms_invited
should_update_columns = True
except Exception as err:
logger.error(f"Exception while parsing domain managers for reports: {err}")
return update_columns(columns, dms_total, should_update_columns)
def build_dictionaries_for_domain_managers(dict_user_domain_roles, dict_domain_invitations_with_invited_status):
"""Helper function that builds dicts for invited users and active domain
managers. We do so to avoid filtering within loops."""
user_domain_roles = UserDomainRole.objects.all()
# Iterate through each user domain role and populate the dictionary
for user_domain_role in user_domain_roles:
domain_name = user_domain_role.domain.name
email = user_domain_role.user.email
if domain_name not in dict_user_domain_roles:
dict_user_domain_roles[domain_name] = []
dict_user_domain_roles[domain_name].append(email)
domain_invitations_with_invited_status = None
domain_invitations_with_invited_status = DomainInvitation.objects.filter(
status=DomainInvitation.DomainInvitationStatus.INVITED
).select_related("domain")
# Iterate through each domain invitation and populate the dictionary
for invite in domain_invitations_with_invited_status:
domain_name = invite.domain.name
email = invite.email
if domain_name not in dict_domain_invitations_with_invited_status:
dict_domain_invitations_with_invited_status[domain_name] = []
dict_domain_invitations_with_invited_status[domain_name].append(email)
return dict_user_domain_roles, dict_domain_invitations_with_invited_status
def write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
get_domain_managers=False,
should_get_domain_managers=False,
should_write_header=True,
):
"""
Receives params from the parent methods and outputs a CSV with filtered and sorted domains.
Works with write_header as long as the same writer object is passed.
get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
should_get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice
"""
# Retrieve domain information and all sec emails
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
# 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 = _get_security_emails(sec_contact_ids)
# Reduce the memory overhead when performing the write operation
dict_security_emails = _get_security_emails(sec_contact_ids)
paginator = Paginator(all_domain_infos, 1000)
# The maximum amount of domain managers an account has
# We get the max so we can set the column header accurately
max_dm_count = 0
# Initialize variables
dms_total = 0
should_update_columns = False
total_body_rows = []
dict_user_domain_roles = {}
dict_domain_invitations_with_invited_status = {}
# Build dictionaries if necessary
if should_get_domain_managers:
dict_user_domain_roles, dict_domain_invitations_with_invited_status = build_dictionaries_for_domain_managers(
dict_user_domain_roles, dict_domain_invitations_with_invited_status
)
# Process domain information
for page_num in paginator.page_range:
rows = []
page = paginator.page(page_num)
for domain_info in page.object_list:
# Get count of all the domain managers for an account
if get_domain_managers:
dm_count = domain_info.domain.permissions.count()
if dm_count > max_dm_count:
max_dm_count = dm_count
for i in range(1, max_dm_count + 1):
column_name = f"Domain manager email {i}"
if column_name not in columns:
columns.append(column_name)
if should_get_domain_managers:
columns, dms_total, should_update_columns = update_columns_with_domain_managers(
columns,
domain_info,
should_update_columns,
dms_total,
dict_domain_invitations_with_invited_status,
dict_user_domain_roles,
)
try:
row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers)
row = parse_row_for_domain(
columns,
domain_info,
dict_security_emails,
should_get_domain_managers,
dict_domain_invitations_with_invited_status,
dict_user_domain_roles,
)
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
total_body_rows.extend(rows)
@ -208,7 +310,7 @@ def get_requests(filter_condition, sort_fields):
return requests
def parse_request_row(columns, request: DomainRequest):
def parse_row_for_requests(columns, request: DomainRequest):
"""Given a set of columns, generate a new row from cleaned column data"""
requested_domain_name = "No requested domain"
@ -240,7 +342,7 @@ def parse_request_row(columns, request: DomainRequest):
return row
def write_requests_csv(
def write_csv_for_requests(
writer,
columns,
sort_fields,
@ -261,7 +363,7 @@ def write_requests_csv(
rows = []
for request in page.object_list:
try:
row = parse_request_row(columns, request)
row = parse_row_for_requests(columns, request)
rows.append(row)
except ValueError:
# This should not happen. If it does, just skip this row.
@ -309,8 +411,8 @@ def export_data_type_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
write_csv_for_domains(
writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True
)
@ -342,8 +444,8 @@ def export_data_full_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
@ -376,8 +478,8 @@ def export_data_federal_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
@ -446,77 +548,42 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date):
"domain__deleted__gte": start_date_formatted,
}
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
write_csv_for_domains(
writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
write_domains_csv(
write_csv_for_domains(
writer,
columns,
sort_fields_for_deleted_domains,
filter_condition_for_deleted_domains,
get_domain_managers=False,
should_get_domain_managers=False,
should_write_header=False,
)
def get_sliced_domains(filter_condition, distinct=False):
def get_sliced_domains(filter_condition):
"""Get filtered domains counts sliced by org type and election office.
Pass distinct=True when filtering by permissions so we do not to count multiples
when a domain has more that one manager.
"""
# Round trip 1: Get distinct domain names based on filter condition
domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count()
# Round trip 2: Get counts for other slices
# This will require either 8 filterd and distinct DB round trips,
# or 2 DB round trips plus iteration on domain_permissions for each domain
if distinct:
generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list(
"domain_id", "generic_org_type"
domains = DomainInformation.objects.all().filter(**filter_condition).distinct()
domains_count = domains.count()
federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count()
state_or_territory = (
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
)
# Initialize Counter to store counts for each generic_org_type
generic_org_type_counts = Counter()
# Keep track of domains already counted
domains_counted = set()
# Iterate over distinct domains
for domain_id, generic_org_type in generic_org_types_query:
# Check if the domain has already been counted
if domain_id in domains_counted:
continue
# Get all permissions for the current domain
domain_permissions = DomainInformation.objects.filter(domain_id=domain_id, **filter_condition).values_list(
"domain__permissions", flat=True
tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
special_district = (
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
)
# Check if the domain has multiple permissions
if len(domain_permissions) > 0:
# Mark the domain as counted
domains_counted.add(domain_id)
# Increment the count for the corresponding generic_org_type
generic_org_type_counts[generic_org_type] += 1
else:
generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list(
"generic_org_type", flat=True
school_district = (
domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
)
generic_org_type_counts = Counter(generic_org_types_query)
# Extract counts for each generic_org_type
federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0)
interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0)
state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0)
tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0)
county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0)
city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0)
special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0)
school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0)
# Round trip 3
election_board = DomainInformation.objects.filter(is_election_board=True, **filter_condition).distinct().count()
election_board = domains.filter(is_election_board=True).distinct().count()
return [
domains_count,
@ -535,26 +602,23 @@ def get_sliced_domains(filter_condition, distinct=False):
def get_sliced_requests(filter_condition):
"""Get filtered requests counts sliced by org type and election office."""
# Round trip 1: Get distinct requests based on filter condition
requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count()
# Round trip 2: Get counts for other slices
generic_org_types_query = DomainRequest.objects.filter(**filter_condition).values_list(
"generic_org_type", flat=True
requests = DomainRequest.objects.all().filter(**filter_condition).distinct()
requests_count = requests.count()
federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count()
state_or_territory = (
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
)
generic_org_type_counts = Counter(generic_org_types_query)
federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0)
interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0)
state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0)
tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0)
county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0)
city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0)
special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0)
school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0)
# Round trip 3
election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count()
tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
special_district = (
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
)
school_district = (
requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
)
election_board = requests.filter(is_election_board=True).distinct().count()
return [
requests_count,
@ -588,7 +652,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted,
}
managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date, True)
managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date)
writer.writerow(["MANAGED DOMAINS COUNTS AT START DATE"])
writer.writerow(
@ -612,7 +676,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date, True)
managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date)
writer.writerow(["MANAGED DOMAINS COUNTS AT END DATE"])
writer.writerow(
@ -632,12 +696,12 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
writer.writerow(managed_domains_sliced_at_end_date)
writer.writerow([])
write_domains_csv(
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_managed_domains_end_date,
get_domain_managers=True,
should_get_domain_managers=True,
should_write_header=True,
)
@ -661,7 +725,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted,
}
unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True)
unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date)
writer.writerow(["UNMANAGED DOMAINS AT START DATE"])
writer.writerow(
@ -685,7 +749,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True)
unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date)
writer.writerow(["UNMANAGED DOMAINS AT END DATE"])
writer.writerow(
@ -705,12 +769,12 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
writer.writerow(unmanaged_domains_sliced_at_end_date)
writer.writerow([])
write_domains_csv(
write_csv_for_domains(
writer,
columns,
sort_fields,
filter_unmanaged_domains_end_date,
get_domain_managers=False,
should_get_domain_managers=False,
should_write_header=True,
)
@ -741,4 +805,4 @@ def export_data_requests_growth_to_csv(csv_file, start_date, end_date):
"submission_date__gte": start_date_formatted,
}
write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True)
write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)

View file

@ -49,8 +49,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date, True)
managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date, True)
managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date)
managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date)
filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True,
@ -60,10 +60,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(
filter_unmanaged_domains_start_date, True
)
unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True)
unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_unmanaged_domains_start_date)
unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date)
filter_ready_domains_start_date = {
"domain__state__in": [models.Domain.State.READY],