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 `User.domains` many-to-many relationship that works through the
`UserDomainRole` link table. `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 ## Permission decorator
The Django objects that need to be permission controlled are various views. The Django objects that need to be permission controlled are various views.

View file

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

View file

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

View file

@ -137,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin(); 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 * 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; margin-top: 5px !important;
} }
// Mimic the normal label size // Mimic the normal label size
dt { address, dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
address {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
} }
} }
td button.usa-button__clipboard-link, address.dja-address-contact-list {
font-size: unset;
}
address.dja-address-contact-list { address.dja-address-contact-list {
font-size: 0.8125rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
} }
// Mimic the normal label size // Mimic the normal label size
@media (max-width: 1024px){ @media (max-width: 1024px){
.dja-detail-list dt { .dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
} }
.dja-detail-list address {
font-size: 0.875rem; address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
color: var(--body-quiet-color); font-size: 0.875rem !important;
} }
} }
.errors span.select2-selection { .errors span.select2-selection {
@ -533,3 +535,69 @@ address.dja-address-contact-list {
color: var(--link-fg); 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 // Only apply this custom wrapping to desktop
@include at-media(desktop) { @include at-media(desktop) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 350px; width: 350px;
white-space: normal; white-space: normal;
text-align: center; text-align: center;
@ -10,7 +10,7 @@
} }
@include at-media(tablet) { @include at-media(tablet) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important; width: 250px !important;
white-space: normal !important; white-space: normal !important;
text-align: center !important; text-align: center !important;
@ -18,7 +18,7 @@
} }
@include at-media(mobile) { @include at-media(mobile) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important; width: 250px !important;
white-space: normal !important; white-space: normal !important;
text-align: center !important; text-align: center !important;

View file

@ -98,6 +98,8 @@ class DomainRequestFixture:
def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict): def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`.""" """Helper method used by `load`."""
da.status = app["status"] if "status" in app else "started" 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.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
da.federal_agency = ( da.federal_agency = (
app["federal_agency"] app["federal_agency"]
@ -235,9 +237,6 @@ class DomainFixture(DomainRequestFixture):
).last() ).last()
logger.debug(f"Approving {domain_request} for {user}") 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, # All approvals require an investigator, so if there is none,
# assign one. # assign one.
if domain_request.investigator is None: if domain_request.investigator is None:

View file

@ -2,7 +2,7 @@
import logging import logging
from django import forms from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory from django.forms import formset_factory
from registrar.models import DomainRequest from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -31,7 +31,17 @@ logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form): class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain.""" """Form for adding a user to a domain."""
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): def clean(self):
"""clean form data by lowercasing email""" """clean form data by lowercasing email"""
@ -171,6 +181,8 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm): class ContactForm(forms.ModelForm):
"""Form for updating contacts.""" """Form for updating contacts."""
email = forms.EmailField(max_length=None)
class Meta: class Meta:
model = Contact model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"] 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 # which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None) 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: for field_name in self.required:
self.fields[field_name].required = True self.fields[field_name].required = True
@ -291,10 +307,17 @@ class DomainSecurityEmailForm(forms.Form):
security_email = forms.EmailField( security_email = forms.EmailField(
label="Security email (optional)", label="Security email (optional)",
max_length=None,
required=False, required=False,
error_messages={ error_messages={
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)), "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( email = forms.EmailField(
label="Email", label="Email",
max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, 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( email = forms.EmailField(
label="Email", label="Email",
max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, 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( phone = PhoneNumberField(
label="Phone", label="Phone",
@ -621,10 +635,17 @@ class OtherContactsForm(RegistrarForm):
) )
email = forms.EmailField( email = forms.EmailField(
label="Email", label="Email",
max_length=None,
error_messages={ error_messages={
"required": ("Enter an email address in the required format, like name@example.com."), "required": ("Enter an email address in the required format, like name@example.com."),
"invalid": ("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( phone = PhoneNumberField(
label="Phone", 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, null=True,
blank=True, blank=True,
db_index=True, db_index=True,
max_length=320,
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, null=True,

View file

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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from django.db import transaction from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper from registrar.models.utility.domain_helper import DomainHelper
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from .domain_request import DomainRequest from .domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -54,7 +55,23 @@ class DomainInformation(TimeStampedModel):
choices=OrganizationChoices.choices, choices=OrganizationChoices.choices,
null=True, null=True,
blank=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( federally_recognized_tribe = models.BooleanField(
@ -219,6 +236,34 @@ class DomainInformation(TimeStampedModel):
except Exception: except Exception:
return "" 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 @classmethod
def create_from_da(cls, domain_request: DomainRequest, domain=None): def create_from_da(cls, domain_request: DomainRequest, domain=None):
"""Takes in a DomainRequest and converts it into DomainInformation""" """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_fsm import FSMField, transition # type: ignore
from django.utils import timezone from django.utils import timezone
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -100,8 +101,8 @@ class DomainRequest(TimeStampedModel):
class OrganizationChoices(models.TextChoices): class OrganizationChoices(models.TextChoices):
""" """
Primary organization choices: Primary organization choices:
For use in django admin For use in the domain request experience
Keys need to match OrganizationChoicesVerbose Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose
""" """
FEDERAL = "federal", "Federal" FEDERAL = "federal", "Federal"
@ -113,9 +114,77 @@ class DomainRequest(TimeStampedModel):
SPECIAL_DISTRICT = "special_district", "Special district" SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School 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): class OrganizationChoicesVerbose(models.TextChoices):
""" """
Secondary organization choices Tertiary organization choices
For use in the domain request form and on the templates For use in the domain request form and on the templates
Keys need to match OrganizationChoices Keys need to match OrganizationChoices
""" """
@ -406,6 +475,21 @@ class DomainRequest(TimeStampedModel):
help_text="Type of organization", 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( federally_recognized_tribe = models.BooleanField(
null=True, null=True,
help_text="Is the tribe federally recognized", help_text="Is the tribe federally recognized",
@ -437,18 +521,13 @@ class DomainRequest(TimeStampedModel):
help_text="Federal government branch", 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( organization_name = models.CharField(
null=True, null=True,
blank=True, blank=True,
help_text="Organization name", help_text="Organization name",
db_index=True, db_index=True,
) )
address_line1 = models.CharField( address_line1 = models.CharField(
null=True, null=True,
blank=True, blank=True,
@ -525,6 +604,7 @@ class DomainRequest(TimeStampedModel):
related_name="domain_request", related_name="domain_request",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
alternative_domains = models.ManyToManyField( alternative_domains = models.ManyToManyField(
"registrar.Website", "registrar.Website",
blank=True, blank=True,
@ -586,6 +666,34 @@ class DomainRequest(TimeStampedModel):
help_text="Notes about this request", 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): def __str__(self):
try: try:
if self.requested_domain and self.requested_domain.name: 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") sp = models.CharField(null=False, help_text="Contact's state or province")
pc = models.CharField(null=False, help_text="Contact's postal code") pc = models.CharField(null=False, help_text="Contact's postal code")
cc = models.CharField(null=False, help_text="Contact's country 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") voice = models.CharField(null=False, help_text="Contact's phone number. Must be in ITU.E164.2005 format")
fax = models.CharField( fax = models.CharField(
null=True, null=True,

View file

@ -5,6 +5,11 @@ logger = logging.getLogger(__name__)
class UserGroup(Group): 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: class Meta:
verbose_name = "User group" verbose_name = "User group"
verbose_name_plural = "User groups" verbose_name_plural = "User groups"

View file

@ -35,3 +35,219 @@ class Timer:
self.end = time.time() self.end = time.time()
self.duration = self.end - self.start self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds") 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.email or user.contact.email %}
{% if user.contact.email %} {% if user.contact.email %}
{{ user.contact.email }} {{ user.contact.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %} {% else %}
{{ user.email }} {{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %} {% endif %}
<br> <br class="admin-icon-group__br">
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}

View file

@ -92,8 +92,25 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<tr> <tr>
<th class="padding-left-1" scope="row">{{ contact.get_formatted_name }}</th> <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.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">{{ 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> </tr>
{% endfor %} {% endfor %}
</tbody> </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>
</div> </div>
<br> <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>{% 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"> <p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a> Withdraw request</a>

View file

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

View file

@ -58,7 +58,7 @@
{{ domain.state|capfirst }} {{ domain.state|capfirst }}
{% endif %} {% endif %}
<svg <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" data-position="top"
title="{{domain.get_state_help_text}}" title="{{domain.get_state_help_text}}"
focusable="true" 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__body {% if add_body_class %}{{ add_body_class }}{% endif %}"> >
<b>Attention:</b> You are on a test site. <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>
</div> </div>

View file

@ -158,7 +158,7 @@ class GenericTestHelper(TestCase):
Example Usage: Example Usage:
``` ```
self.assert_sort_helper( 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", generic_org_type="federal",
federal_agency="World War I Centennial Commission", federal_agency="World War I Centennial Commission",
federal_type="executive", federal_type="executive",
is_election_board=True, is_election_board=False,
) )
self.domain_information_2, _ = DomainInformation.objects.get_or_create( self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_2, generic_org_type="interstate", is_election_board=True 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, domain=self.domain_3,
generic_org_type="federal", generic_org_type="federal",
federal_agency="Armed Forces Retirement Home", federal_agency="Armed Forces Retirement Home",
is_election_board=True, is_election_board=False,
) )
self.domain_information_4, _ = DomainInformation.objects.get_or_create( self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user, creator=self.user,
domain=self.domain_4, domain=self.domain_4,
generic_org_type="federal", generic_org_type="federal",
federal_agency="Armed Forces Retirement Home", federal_agency="Armed Forces Retirement Home",
is_election_board=True, is_election_board=False,
) )
self.domain_information_5, _ = DomainInformation.objects.get_or_create( self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user, creator=self.user,
@ -652,7 +652,7 @@ class MockDb(TestCase):
generic_org_type="federal", generic_org_type="federal",
federal_agency="World War I Centennial Commission", federal_agency="World War I Centennial Commission",
federal_type="executive", federal_type="executive",
is_election_board=True, is_election_board=False,
) )
self.domain_information_12, _ = DomainInformation.objects.get_or_create( self.domain_information_12, _ = DomainInformation.objects.get_or_create(
creator=self.user, creator=self.user,
@ -693,6 +693,24 @@ class MockDb(TestCase):
user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER 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(): with less_console_noise():
self.domain_request_1 = completed_domain_request( self.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov" status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
@ -722,6 +740,7 @@ class MockDb(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
DomainInvitation.objects.all().delete()
def mock_user(): def mock_user():
@ -782,6 +801,9 @@ def completed_domain_request(
submitter=False, submitter=False,
name="city.gov", name="city.gov",
investigator=None, investigator=None,
generic_org_type="federal",
is_election_board=False,
organization_type=None,
): ):
"""A completed domain request.""" """A completed domain request."""
if not user: if not user:
@ -819,7 +841,8 @@ def completed_domain_request(
is_staff=True, is_staff=True,
) )
domain_request_kwargs = dict( domain_request_kwargs = dict(
generic_org_type="federal", generic_org_type=generic_org_type,
is_election_board=is_election_board,
federal_type="executive", federal_type="executive",
purpose="Purpose of the site", purpose="Purpose of the site",
is_policy_acknowledged=True, is_policy_acknowledged=True,
@ -840,6 +863,9 @@ def completed_domain_request(
if has_anything_else: if has_anything_else:
domain_request_kwargs["anything_else"] = "There is more" 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) domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts: 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.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack from contextlib import ExitStack
@ -845,7 +847,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",)) self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",))
def test_submitter_sortable(self): 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(): with less_console_noise():
p = "adminpass" p = "adminpass"
self.client.login(username="superuser", password=p) self.client.login(username="superuser", password=p)
@ -876,7 +878,7 @@ class TestDomainRequestAdmin(MockEppLib):
) )
def test_investigator_sortable(self): 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(): with less_console_noise():
p = "adminpass" p = "adminpass"
self.client.login(username="superuser", password=p) self.client.login(username="superuser", password=p)
@ -889,7 +891,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that our sort works correctly # Assert that our sort works correctly
self.test_helper.assert_table_sorted( self.test_helper.assert_table_sorted(
"6", "12",
( (
"investigator__first_name", "investigator__first_name",
"investigator__last_name", "investigator__last_name",
@ -898,13 +900,77 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that sorting in reverse works correctly # Assert that sorting in reverse works correctly
self.test_helper.assert_table_sorted( self.test_helper.assert_table_sorted(
"-6", "-12",
( (
"-investigator__first_name", "-investigator__first_name",
"-investigator__last_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): def test_short_org_name_in_domain_requests_list(self):
""" """
Make sure the short name is displaying in admin on the list page Make sure the short name is displaying in admin on the list page
@ -1569,9 +1635,6 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name) 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 creator == #
# Check for the right title, email, and phone number in the response. # Check for the right title, email, and phone number in the response.
@ -1587,37 +1650,38 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "Meoward Jones") self.assertContains(response, "Meoward Jones")
# == Check for the submitter == # # == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [ expected_submitter_fields = [
# Field, expected value # Field, expected value
("title", "Admin Tester"), ("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"), ("phone", "(555) 555 5556"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2") self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == # # == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [ expected_ao_fields = [
# Field, expected value # Field, expected value
("title", "Chief Tester"), ("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"), ("phone", "(555) 555 5555"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name. self.assertContains(response, "Testy Tester", count=10)
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
# == Test the other_employees field == # # == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [ expected_other_employees_fields = [
# Field, expected value # Field, expected value
("title", "Another Tester"), ("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"), ("phone", "(555) 555 5557"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) 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): def test_save_model_sets_restricted_status_on_user(self):
with less_console_noise(): with less_console_noise():
# make sure there is no user with this email # make sure there is no user with this email
@ -1717,6 +1781,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board",
"id", "id",
"created_at", "created_at",
"updated_at", "updated_at",
@ -1725,12 +1791,13 @@ class TestDomainRequestAdmin(MockEppLib):
"creator", "creator",
"investigator", "investigator",
"generic_org_type", "generic_org_type",
"is_election_board",
"organization_type",
"federally_recognized_tribe", "federally_recognized_tribe",
"state_recognized_tribe", "state_recognized_tribe",
"tribe_name", "tribe_name",
"federal_agency", "federal_agency",
"federal_type", "federal_type",
"is_election_board",
"organization_name", "organization_name",
"address_line1", "address_line1",
"address_line2", "address_line2",
@ -1765,6 +1832,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",
@ -1790,6 +1859,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -2346,37 +2417,38 @@ class TestDomainInformationAdmin(TestCase):
self.assertContains(response, "Meoward Jones") self.assertContains(response, "Meoward Jones")
# == Check for the submitter == # # == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [ expected_submitter_fields = [
# Field, expected value # Field, expected value
("title", "Admin Tester"), ("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"), ("phone", "(555) 555 5556"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2") self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == # # == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [ expected_ao_fields = [
# Field, expected value # Field, expected value
("title", "Chief Tester"), ("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"), ("phone", "(555) 555 5555"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name. self.assertContains(response, "Testy Tester", count=10)
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
# == Test the other_employees field == # # == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [ expected_other_employees_fields = [
# Field, expected value # Field, expected value
("title", "Another Tester"), ("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"), ("phone", "(555) 555 5557"),
] ]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) 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): def test_readonly_fields_for_analyst(self):
"""Ensures that analysts have their permissions setup correctly""" """Ensures that analysts have their permissions setup correctly"""
with less_console_noise(): with less_console_noise():
@ -2387,6 +2459,8 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [ expected_fields = [
"other_contacts", "other_contacts",
"generic_org_type",
"is_election_board",
"creator", "creator",
"type_of_work", "type_of_work",
"more_organization_information", "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 # 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.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")) 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, export_data_unmanaged_domains_to_csv,
get_sliced_domains, get_sliced_domains,
get_sliced_requests, get_sliced_requests,
write_domains_csv, write_csv_for_domains,
get_default_start_date, get_default_start_date,
get_default_end_date, get_default_end_date,
write_requests_csv, write_csv_for_requests,
) )
from django.core.management import call_command from django.core.management import call_command
@ -242,8 +242,13 @@ class ExportDataTest(MockDb, MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # 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() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) 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 """Test that write_body returns the
existing domain, test that sort by domain name works, existing domain, test that sort by domain name works,
test that filter works""" test that filter works"""
@ -304,8 +309,13 @@ class ExportDataTest(MockDb, MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -357,8 +367,13 @@ class ExportDataTest(MockDb, MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -433,20 +448,20 @@ class ExportDataTest(MockDb, MockEppLib):
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_condition, filter_condition,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, should_write_header=True,
) )
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, sort_fields_for_deleted_domains,
filter_conditions_for_deleted_domains, filter_conditions_for_deleted_domains,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=False, should_write_header=False,
) )
# Reset the CSV file's position to the beginning # 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): def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the """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(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
@ -508,8 +528,13 @@ class ExportDataTest(MockDb, MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=True,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
@ -521,14 +546,16 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = ( expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency," "Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email," "Organization name,City,State,AO,AO email,"
"Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" "Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n" "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n" "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
"cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.com\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,,," "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" "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, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
@ -538,7 +565,9 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_data_managed_domains_to_csv(self): def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates, """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(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
@ -562,12 +591,14 @@ class ExportDataTest(MockDb, MockEppLib):
"MANAGED DOMAINS COUNTS AT END DATE\n" "MANAGED DOMAINS COUNTS AT END DATE\n"
"Total,Federal,Interstate,State or territory,Tribal,County,City," "Total,Federal,Interstate,State or territory,Tribal,County,City,"
"Special district,School district,Election office\n" "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" "\n"
"Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" "Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"cdomain11.govFederal-Executivemeoward@rocks.com\n" "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n" "cdomain11.govFederal-Executivemeoward@rocks.com, R\n"
"zdomain12.govInterstatemeoward@rocks.com\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, # Normalize line endings and remove commas,
@ -642,7 +673,7 @@ class ExportDataTest(MockDb, MockEppLib):
"submission_date__lte": self.end_date, "submission_date__lte": self.end_date,
"submission_date__gte": self.start_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 # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
@ -686,13 +717,13 @@ class HelperFunctions(MockDb):
"domain__first_ready__lte": self.end_date, "domain__first_ready__lte": self.end_date,
} }
# Test with distinct # Test with distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True) managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [3, 2, 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) self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct # Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) 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) self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
def test_get_sliced_requests(self): def test_get_sliced_requests(self):

View file

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

View file

@ -1,8 +1,8 @@
from collections import Counter
import csv import csv
import logging import logging
from datetime import datetime from datetime import datetime
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
from django.utils import timezone 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 django.db.models.functions import Concat, Coalesce
from registrar.models.public_contact import PublicContact from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,7 +34,6 @@ def get_domain_infos(filter_condition, sort_fields):
""" """
domain_infos = ( domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official") DomainInformation.objects.select_related("domain", "authorizing_official")
.prefetch_related("domain__permissions")
.filter(**filter_condition) .filter(**filter_condition)
.order_by(*sort_fields) .order_by(*sort_fields)
.distinct() .distinct()
@ -53,7 +53,14 @@ def get_domain_infos(filter_condition, sort_fields):
return domain_infos_cleaned 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""" """Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information # 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. # Grab the security email from a preset dictionary.
# If nothing exists in the dictionary, grab from .contacts. # If nothing exists in the dictionary, grab from .contacts.
if security_emails_dict is not None and domain.name in security_emails_dict: if dict_security_emails is not None and domain.name in dict_security_emails:
_email = security_emails_dict.get(domain.name) _email = dict_security_emails.get(domain.name)
security_email = _email if _email is not None else " " security_email = _email if _email is not None else " "
else: else:
# If the dictionary doesn't contain that data, lets filter for it manually. # 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, "Deleted": domain.deleted,
} }
if get_domain_managers: if should_get_domain_managers:
# Get each domain managers email and add to list # Get lists of emails for active and invited domain managers
dm_emails = [dm.user.email for dm in domain.permissions.all()]
# Set up the "matching header" + row field data dms_active_emails = dict_user_domain_roles.get(domain_info.domain.name, [])
for i, dm_email in enumerate(dm_emails, start=1): dms_invited_emails = dict_domain_invitations_with_invited_status.get(domain_info.domain.name, [])
FIELDS[f"Domain manager email {i}"] = dm_email
# 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] row = [FIELDS.get(column, "") for column in columns]
return row return row
@ -119,7 +135,7 @@ def _get_security_emails(sec_contact_ids):
""" """
Retrieve security contact emails for the given security contact IDs. Retrieve security contact emails for the given security contact IDs.
""" """
security_emails_dict = {} dict_security_emails = {}
public_contacts = ( public_contacts = (
PublicContact.objects.only("email", "domain__name") PublicContact.objects.only("email", "domain__name")
.select_related("domain") .select_related("domain")
@ -129,65 +145,151 @@ def _get_security_emails(sec_contact_ids):
# Populate a dictionary of domain names and their security contacts # Populate a dictionary of domain names and their security contacts
for contact in public_contacts: for contact in public_contacts:
domain: Domain = contact.domain domain: Domain = contact.domain
if domain is not None and domain.name not in security_emails_dict: if domain is not None and domain.name not in dict_security_emails:
security_emails_dict[domain.name] = contact.email dict_security_emails[domain.name] = contact.email
else: else:
logger.warning("csv_export -> Domain was none for PublicContact") 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, writer,
columns, columns,
sort_fields, sort_fields,
filter_condition, filter_condition,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, should_write_header=True,
): ):
""" """
Receives params from the parent methods and outputs a CSV with filtered and sorted domains. 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. 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 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) 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) sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
dict_security_emails = _get_security_emails(sec_contact_ids)
security_emails_dict = _get_security_emails(sec_contact_ids)
# Reduce the memory overhead when performing the write operation
paginator = Paginator(all_domain_infos, 1000) paginator = Paginator(all_domain_infos, 1000)
# The maximum amount of domain managers an account has # Initialize variables
# We get the max so we can set the column header accurately dms_total = 0
max_dm_count = 0 should_update_columns = False
total_body_rows = [] 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: for page_num in paginator.page_range:
rows = [] rows = []
page = paginator.page(page_num) page = paginator.page(page_num)
for domain_info in page.object_list: for domain_info in page.object_list:
if should_get_domain_managers:
# Get count of all the domain managers for an account columns, dms_total, should_update_columns = update_columns_with_domain_managers(
if get_domain_managers: columns,
dm_count = domain_info.domain.permissions.count() domain_info,
if dm_count > max_dm_count: should_update_columns,
max_dm_count = dm_count dms_total,
for i in range(1, max_dm_count + 1): dict_domain_invitations_with_invited_status,
column_name = f"Domain manager email {i}" dict_user_domain_roles,
if column_name not in columns: )
columns.append(column_name)
try: 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) rows.append(row)
except ValueError: 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") logger.error("csv_export -> Error when parsing row, domain was None")
continue continue
total_body_rows.extend(rows) total_body_rows.extend(rows)
@ -208,7 +310,7 @@ def get_requests(filter_condition, sort_fields):
return requests 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""" """Given a set of columns, generate a new row from cleaned column data"""
requested_domain_name = "No requested domain" requested_domain_name = "No requested domain"
@ -240,7 +342,7 @@ def parse_request_row(columns, request: DomainRequest):
return row return row
def write_requests_csv( def write_csv_for_requests(
writer, writer,
columns, columns,
sort_fields, sort_fields,
@ -261,7 +363,7 @@ def write_requests_csv(
rows = [] rows = []
for request in page.object_list: for request in page.object_list:
try: try:
row = parse_request_row(columns, request) row = parse_row_for_requests(columns, request)
rows.append(row) rows.append(row)
except ValueError: except ValueError:
# This should not happen. If it does, just skip this row. # 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True 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, "domain__deleted__gte": start_date_formatted,
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
) )
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, sort_fields_for_deleted_domains,
filter_condition_for_deleted_domains, filter_condition_for_deleted_domains,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=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. """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 Pass distinct=True when filtering by permissions so we do not to count multiples
when a domain has more that one manager. when a domain has more that one manager.
""" """
# Round trip 1: Get distinct domain names based on filter condition domains = DomainInformation.objects.all().filter(**filter_condition).distinct()
domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count() domains_count = domains.count()
federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
# Round trip 2: Get counts for other slices interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count()
# This will require either 8 filterd and distinct DB round trips, state_or_territory = (
# or 2 DB round trips plus iteration on domain_permissions for each domain domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
if distinct: )
generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list( tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
"domain_id", "generic_org_type" county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
) city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
# Initialize Counter to store counts for each generic_org_type special_district = (
generic_org_type_counts = Counter() domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
)
# Keep track of domains already counted school_district = (
domains_counted = set() domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
)
# Iterate over distinct domains election_board = domains.filter(is_election_board=True).distinct().count()
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
)
# 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
)
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()
return [ return [
domains_count, domains_count,
@ -535,26 +602,23 @@ def get_sliced_domains(filter_condition, distinct=False):
def get_sliced_requests(filter_condition): def get_sliced_requests(filter_condition):
"""Get filtered requests counts sliced by org type and election office.""" """Get filtered requests counts sliced by org type and election office."""
# Round trip 1: Get distinct requests based on filter condition requests = DomainRequest.objects.all().filter(**filter_condition).distinct()
requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count() requests_count = requests.count()
federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
# Round trip 2: Get counts for other slices interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count()
generic_org_types_query = DomainRequest.objects.filter(**filter_condition).values_list( state_or_territory = (
"generic_org_type", flat=True requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
) )
generic_org_type_counts = Counter(generic_org_types_query) tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) special_district = (
state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) )
county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) school_district = (
city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) )
school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) election_board = requests.filter(is_election_board=True).distinct().count()
# Round trip 3
election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count()
return [ return [
requests_count, requests_count,
@ -588,7 +652,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False, "domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted, "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(["MANAGED DOMAINS COUNTS AT START DATE"])
writer.writerow( writer.writerow(
@ -612,7 +676,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False, "domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted, "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(["MANAGED DOMAINS COUNTS AT END DATE"])
writer.writerow( 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(managed_domains_sliced_at_end_date)
writer.writerow([]) writer.writerow([])
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_managed_domains_end_date, filter_managed_domains_end_date,
get_domain_managers=True, should_get_domain_managers=True,
should_write_header=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__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted, "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(["UNMANAGED DOMAINS AT START DATE"])
writer.writerow( writer.writerow(
@ -685,7 +749,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted, "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(["UNMANAGED DOMAINS AT END DATE"])
writer.writerow( 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(unmanaged_domains_sliced_at_end_date)
writer.writerow([]) writer.writerow([])
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_unmanaged_domains_end_date, filter_unmanaged_domains_end_date,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, 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, "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__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted, "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_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, True) managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date)
filter_unmanaged_domains_start_date = { filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
@ -60,10 +60,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted, "domain__first_ready__lte": end_date_formatted,
} }
unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains( unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_unmanaged_domains_start_date)
filter_unmanaged_domains_start_date, True unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date)
)
unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True)
filter_ready_domains_start_date = { filter_ready_domains_start_date = {
"domain__state__in": [models.Domain.State.READY], "domain__state__in": [models.Domain.State.READY],