diff --git a/docs/developer/user-permissions.md b/docs/developer/user-permissions.md
index f7c41492d..4919c02ff 100644
--- a/docs/developer/user-permissions.md
+++ b/docs/developer/user-permissions.md
@@ -19,6 +19,18 @@ role or set of permissions that they have. We use a `UserDomainRole`
`User.domains` many-to-many relationship that works through the
`UserDomainRole` link table.
+## Migrating changes to Analyst Permissions model
+Analysts are allowed a certain set of read/write registrar permissions.
+Setting user permissions requires a migration to change the UserGroup
+and Permission models, which requires us to manually make a migration
+file for user permission changes.
+To update analyst permissions do the following:
+1. Make desired changes to analyst group permissions in user_group.py.
+2. Follow the steps in the migration file0037_create_groups_v01.py to
+create a duplicate migration for the updated user group permissions.
+3. To migrate locally, run docker-compose up. To migrate on a sandbox,
+push the new migration onto your sandbox before migrating.
+
## Permission decorator
The Django objects that need to be permission controlled are various views.
diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml
index a70035445..80c97339f 100644
--- a/ops/manifests/manifest-stable.yaml
+++ b/ops/manifests/manifest-stable.yaml
@@ -5,7 +5,7 @@ applications:
- python_buildpack
path: ../../src
instances: 2
- memory: 512M
+ memory: 1G
stack: cflinuxfs4
timeout: 180
command: ./run.sh
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index e0c98b7c2..b06111e5b 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -562,6 +562,8 @@ class MyUserAdmin(BaseUserAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
@@ -666,6 +668,17 @@ class ContactAdmin(ListHeaderAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
+ fieldsets = [
+ (
+ None,
+ {"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]},
+ )
+ ]
+
+ autocomplete_fields = ["user"]
+
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+
# We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@@ -847,6 +860,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# error.
readonly_fields = ["status"]
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
@@ -886,6 +901,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
+ "organization_type",
]
},
),
@@ -928,7 +944,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
- readonly_fields = ("other_contacts",)
+ readonly_fields = ("other_contacts", "generic_org_type", "is_election_board")
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@@ -1056,6 +1072,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
# Columns
list_display = [
"requested_domain",
+ "submission_date",
"status",
"generic_org_type",
"federal_type",
@@ -1064,7 +1081,6 @@ class DomainRequestAdmin(ListHeaderAdmin):
"custom_election_board",
"city",
"state_territory",
- "submission_date",
"submitter",
"investigator",
]
@@ -1124,6 +1140,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"fields": [
"generic_org_type",
"is_election_board",
+ "organization_type",
]
},
),
@@ -1166,7 +1183,13 @@ class DomainRequestAdmin(ListHeaderAdmin):
]
# Readonly fields for analysts and superusers
- readonly_fields = ("other_contacts", "current_websites", "alternative_domains")
+ readonly_fields = (
+ "other_contacts",
+ "current_websites",
+ "alternative_domains",
+ "generic_org_type",
+ "is_election_board",
+ )
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@@ -1192,7 +1215,9 @@ class DomainRequestAdmin(ListHeaderAdmin):
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# Table ordering
- ordering = ["requested_domain__name"]
+ # NOTE: This impacts the select2 dropdowns (combobox)
+ # Currentl, there's only one for requests on DomainInfo
+ ordering = ["-submission_date", "requested_domain__name"]
change_form_template = "django/admin/domain_request_change_form.html"
@@ -1404,6 +1429,8 @@ class TransitionDomainAdmin(ListHeaderAdmin):
search_fields = ["username", "domain_name"]
search_help_text = "Search by user or domain name."
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+
class DomainInformationInline(admin.StackedInline):
"""Edit a domain information on the domain page.
@@ -1870,6 +1897,13 @@ class DraftDomainAdmin(ListHeaderAdmin):
ordering = ["name"]
+class PublicContactAdmin(ListHeaderAdmin):
+ """Custom PublicContact admin class."""
+
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+ autocomplete_fields = ["domain"]
+
+
class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
@@ -1878,6 +1912,8 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
"requestor",
]
+ change_form_template = "django/admin/email_clipboard_change_form.html"
+
def truncated_notes(self, obj):
# Truncate the 'notes' field to 50 characters
return str(obj.notes)[:50]
@@ -1915,7 +1951,7 @@ admin.site.register(models.FederalAgency, FederalAgencyAdmin)
# do not propagate to registry and logic not applied
admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin)
-admin.site.register(models.PublicContact, AuditedAdmin)
+admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 9a92542b1..2909a48be 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -137,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin();
})();
+/** An IIFE for pages in DjangoAdmin that use a clipboard button
+*/
+(function (){
+
+ function copyInnerTextToClipboard(elem) {
+ let text = elem.innerText
+ navigator.clipboard.writeText(text)
+ }
+
+ function copyToClipboardAndChangeIcon(button) {
+ // Assuming the input is the previous sibling of the button
+ let input = button.previousElementSibling;
+ let userId = input.getAttribute("user-id")
+ // Copy input value to clipboard
+ if (input) {
+ navigator.clipboard.writeText(input.value).then(function() {
+ // Change the icon to a checkmark on successful copy
+ let buttonIcon = button.querySelector('.usa-button__clipboard use');
+ if (buttonIcon) {
+ let currentHref = buttonIcon.getAttribute('xlink:href');
+ let baseHref = currentHref.split('#')[0];
+
+ // Append the new icon reference
+ buttonIcon.setAttribute('xlink:href', baseHref + '#check');
+
+ // Change the button text
+ nearestSpan = button.querySelector("span")
+ nearestSpan.innerText = "Copied to clipboard"
+
+ setTimeout(function() {
+ // Change back to the copy icon
+ buttonIcon.setAttribute('xlink:href', currentHref);
+ if (button.classList.contains('usa-button__small-text')) {
+ nearestSpan.innerText = "Copy email";
+ } else {
+ nearestSpan.innerText = "Copy";
+ }
+ }, 2000);
+
+ }
+
+ }).catch(function(error) {
+ console.error('Clipboard copy failed', error);
+ });
+ }
+ }
+
+ function handleClipboardButtons() {
+ clipboardButtons = document.querySelectorAll(".usa-button__clipboard")
+ clipboardButtons.forEach((button) => {
+
+ // Handle copying the text to your clipboard,
+ // and changing the icon.
+ button.addEventListener("click", ()=>{
+ copyToClipboardAndChangeIcon(button);
+ });
+
+ // Add a class that adds the outline style on click
+ button.addEventListener("mousedown", function() {
+ this.classList.add("no-outline-on-click");
+ });
+
+ // But add it back in after the user clicked,
+ // for accessibility reasons (so we can still tab, etc)
+ button.addEventListener("blur", function() {
+ this.classList.remove("no-outline-on-click");
+ });
+
+ });
+ }
+
+ function handleClipboardLinks() {
+ let emailButtons = document.querySelectorAll(".usa-button__clipboard-link");
+ if (emailButtons){
+ emailButtons.forEach((button) => {
+ button.addEventListener("click", ()=>{
+ copyInnerTextToClipboard(button);
+ })
+ });
+ }
+ }
+
+ handleClipboardButtons();
+ handleClipboardLinks();
+
+})();
+
+
/**
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index d86c0e07b..fd66754bc 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -392,32 +392,34 @@ address.margin-top-neg-1__detail-list {
margin-top: 5px !important;
}
// Mimic the normal label size
- dt {
- font-size: 0.8125rem;
- color: var(--body-quiet-color);
- }
-
- address {
+ address, dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
}
+td button.usa-button__clipboard-link, address.dja-address-contact-list {
+ font-size: unset;
+}
+
address.dja-address-contact-list {
- font-size: 0.8125rem;
color: var(--body-quiet-color);
+ button.usa-button__clipboard-link {
+ font-size: unset;
+ }
}
// Mimic the normal label size
@media (max-width: 1024px){
- .dja-detail-list dt {
+ .dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
}
- .dja-detail-list address {
- font-size: 0.875rem;
- color: var(--body-quiet-color);
+
+ address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
+ font-size: 0.875rem !important;
}
+
}
.errors span.select2-selection {
@@ -533,3 +535,69 @@ address.dja-address-contact-list {
color: var(--link-fg);
}
}
+
+
+// Make the clipboard button "float" inside of the input box
+.admin-icon-group {
+ position: relative;
+ display: inline;
+ align-items: center;
+
+ input {
+ // Allow for padding around the copy button
+ padding-right: 35px !important;
+ // Match the height of other inputs
+ min-height: 2.25rem !important;
+ }
+
+ button {
+ line-height: 14px;
+ width: max-content;
+ font-size: unset;
+ text-decoration: none !important;
+ }
+
+ @media (max-width: 1000px) {
+ button {
+ display: block;
+ padding-top: 8px;
+ }
+ }
+
+ span {
+ padding-left: 0.1rem;
+ }
+
+}
+
+.admin-icon-group.admin-icon-group__clipboard-link {
+ position: relative;
+ display: inline;
+ align-items: center;
+
+
+ .usa-button__icon {
+ position: absolute;
+ right: auto;
+ left: 4px;
+ height: 100%;
+ }
+ button {
+ font-size: unset !important;
+ display: inline-flex;
+ padding-top: 4px;
+ line-height: 14px;
+ color: var(--link-fg);
+ width: max-content;
+ font-size: unset;
+ text-decoration: none !important;
+ }
+}
+
+.no-outline-on-click:focus {
+ outline: none !important;
+}
+
+.usa-button__small-text {
+ font-size: small;
+}
diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss
index 01348e1b1..04c6f3cda 100644
--- a/src/registrar/assets/sass/_theme/_tooltips.scss
+++ b/src/registrar/assets/sass/_theme/_tooltips.scss
@@ -2,7 +2,7 @@
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
- .usa-tooltip__body {
+ .usa-tooltip--registrar .usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
@@ -10,7 +10,7 @@
}
@include at-media(tablet) {
- .usa-tooltip__body {
+ .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
@@ -18,7 +18,7 @@
}
@include at-media(mobile) {
- .usa-tooltip__body {
+ .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py
index 02efae5a9..ece1d0f7f 100644
--- a/src/registrar/fixtures_domain_requests.py
+++ b/src/registrar/fixtures_domain_requests.py
@@ -98,6 +98,8 @@ class DomainRequestFixture:
def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`."""
da.status = app["status"] if "status" in app else "started"
+
+ # TODO for a future ticket: Allow for more than just "federal" here
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
da.federal_agency = (
app["federal_agency"]
@@ -235,9 +237,6 @@ class DomainFixture(DomainRequestFixture):
).last()
logger.debug(f"Approving {domain_request} for {user}")
- # We don't want fixtures sending out real emails to
- # fake email addresses, so we just skip that and log it instead
-
# All approvals require an investigator, so if there is none,
# assign one.
if domain_request.investigator is None:
diff --git a/src/registrar/migrations/0082_domaininformation_organization_type_and_more.py b/src/registrar/migrations/0082_domaininformation_organization_type_and_more.py
new file mode 100644
index 000000000..b0bb03dcf
--- /dev/null
+++ b/src/registrar/migrations/0082_domaininformation_organization_type_and_more.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 8fc697df5..079fce3bc 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -198,7 +198,6 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError"""
-
if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index b5755a3c9..2ed27504c 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -2,6 +2,7 @@ from __future__ import annotations
from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper
+from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from .domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel
@@ -54,7 +55,23 @@ class DomainInformation(TimeStampedModel):
choices=OrganizationChoices.choices,
null=True,
blank=True,
- help_text="Type of Organization",
+ help_text="Type of organization",
+ )
+
+ # TODO - Ticket #1911: stub this data from DomainRequest
+ is_election_board = models.BooleanField(
+ null=True,
+ blank=True,
+ help_text="Is your organization an election office?",
+ )
+
+ # TODO - Ticket #1911: stub this data from DomainRequest
+ organization_type = models.CharField(
+ max_length=255,
+ choices=DomainRequest.OrgChoicesElectionOffice.choices,
+ null=True,
+ blank=True,
+ help_text="Type of organization - Election office",
)
federally_recognized_tribe = models.BooleanField(
@@ -219,6 +236,34 @@ class DomainInformation(TimeStampedModel):
except Exception:
return ""
+ def save(self, *args, **kwargs):
+ """Save override for custom properties"""
+
+ # Define mappings between generic org and election org.
+ # These have to be defined here, as you'd get a cyclical import error
+ # otherwise.
+
+ # For any given organization type, return the "_election" variant.
+ # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
+ generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election()
+
+ # For any given "_election" variant, return the base org type.
+ # For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
+ election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic()
+
+ # Manages the "organization_type" variable and keeps in sync with
+ # "is_election_office" and "generic_organization_type"
+ org_type_helper = CreateOrUpdateOrganizationTypeHelper(
+ sender=self.__class__,
+ instance=self,
+ generic_org_to_org_map=generic_org_map,
+ election_org_to_generic_org_map=election_org_map,
+ )
+
+ # Actually updates the organization_type field
+ org_type_helper.create_or_update_organization_type()
+ super().save(*args, **kwargs)
+
@classmethod
def create_from_da(cls, domain_request: DomainRequest, domain=None):
"""Takes in a DomainRequest and converts it into DomainInformation"""
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index f4581de93..bd529f7e6 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -9,6 +9,7 @@ from django.db import models
from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone
from registrar.models.domain import Domain
+from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from .utility.time_stamped_model import TimeStampedModel
@@ -100,8 +101,8 @@ class DomainRequest(TimeStampedModel):
class OrganizationChoices(models.TextChoices):
"""
Primary organization choices:
- For use in django admin
- Keys need to match OrganizationChoicesVerbose
+ For use in the domain request experience
+ Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose
"""
FEDERAL = "federal", "Federal"
@@ -113,9 +114,77 @@ class DomainRequest(TimeStampedModel):
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
+ class OrgChoicesElectionOffice(models.TextChoices):
+ """
+ Primary organization choices for Django admin:
+ Keys need to match OrganizationChoices and OrganizationChoicesVerbose.
+
+ The enums here come in two variants:
+ Regular (matches the choices from OrganizationChoices)
+ Election (Appends " - Election" to the string)
+
+ When adding the election variant, you must append "_election" to the end of the string.
+ """
+
+ # We can't inherit OrganizationChoices due to models.TextChoices being an enum.
+ # We can redefine these values instead.
+ FEDERAL = "federal", "Federal"
+ INTERSTATE = "interstate", "Interstate"
+ STATE_OR_TERRITORY = "state_or_territory", "State or territory"
+ TRIBAL = "tribal", "Tribal"
+ COUNTY = "county", "County"
+ CITY = "city", "City"
+ SPECIAL_DISTRICT = "special_district", "Special district"
+ SCHOOL_DISTRICT = "school_district", "School district"
+
+ # Election variants
+ STATE_OR_TERRITORY_ELECTION = "state_or_territory_election", "State or territory - Election"
+ TRIBAL_ELECTION = "tribal_election", "Tribal - Election"
+ COUNTY_ELECTION = "county_election", "County - Election"
+ CITY_ELECTION = "city_election", "City - Election"
+ SPECIAL_DISTRICT_ELECTION = "special_district_election", "Special district - Election"
+
+ @classmethod
+ def get_org_election_to_org_generic(cls):
+ """
+ Creates and returns a dictionary mapping from election-specific organization
+ choice enums to their corresponding general organization choice enums.
+
+ If no such mapping exists, it is simple excluded from the map.
+ """
+ # This can be mapped automatically but its harder to read.
+ # For clarity reasons, we manually define this.
+ org_election_map = {
+ cls.STATE_OR_TERRITORY_ELECTION: cls.STATE_OR_TERRITORY,
+ cls.TRIBAL_ELECTION: cls.TRIBAL,
+ cls.COUNTY_ELECTION: cls.COUNTY,
+ cls.CITY_ELECTION: cls.CITY,
+ cls.SPECIAL_DISTRICT_ELECTION: cls.SPECIAL_DISTRICT,
+ }
+ return org_election_map
+
+ @classmethod
+ def get_org_generic_to_org_election(cls):
+ """
+ Creates and returns a dictionary mapping from general organization
+ choice enums to their corresponding election-specific organization enums.
+
+ If no such mapping exists, it is simple excluded from the map.
+ """
+ # This can be mapped automatically but its harder to read.
+ # For clarity reasons, we manually define this.
+ org_election_map = {
+ cls.STATE_OR_TERRITORY: cls.STATE_OR_TERRITORY_ELECTION,
+ cls.TRIBAL: cls.TRIBAL_ELECTION,
+ cls.COUNTY: cls.COUNTY_ELECTION,
+ cls.CITY: cls.CITY_ELECTION,
+ cls.SPECIAL_DISTRICT: cls.SPECIAL_DISTRICT_ELECTION,
+ }
+ return org_election_map
+
class OrganizationChoicesVerbose(models.TextChoices):
"""
- Secondary organization choices
+ Tertiary organization choices
For use in the domain request form and on the templates
Keys need to match OrganizationChoices
"""
@@ -406,6 +475,21 @@ class DomainRequest(TimeStampedModel):
help_text="Type of organization",
)
+ is_election_board = models.BooleanField(
+ null=True,
+ blank=True,
+ help_text="Is your organization an election office?",
+ )
+
+ # TODO - Ticket #1911: stub this data from DomainRequest
+ organization_type = models.CharField(
+ max_length=255,
+ choices=OrgChoicesElectionOffice.choices,
+ null=True,
+ blank=True,
+ help_text="Type of organization - Election office",
+ )
+
federally_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe federally recognized",
@@ -437,18 +521,13 @@ class DomainRequest(TimeStampedModel):
help_text="Federal government branch",
)
- is_election_board = models.BooleanField(
- null=True,
- blank=True,
- help_text="Is your organization an election office?",
- )
-
organization_name = models.CharField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
+
address_line1 = models.CharField(
null=True,
blank=True,
@@ -525,6 +604,7 @@ class DomainRequest(TimeStampedModel):
related_name="domain_request",
on_delete=models.PROTECT,
)
+
alternative_domains = models.ManyToManyField(
"registrar.Website",
blank=True,
@@ -586,6 +666,34 @@ class DomainRequest(TimeStampedModel):
help_text="Notes about this request",
)
+ def save(self, *args, **kwargs):
+ """Save override for custom properties"""
+
+ # Define mappings between generic org and election org.
+ # These have to be defined here, as you'd get a cyclical import error
+ # otherwise.
+
+ # For any given organization type, return the "_election" variant.
+ # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
+ generic_org_map = self.OrgChoicesElectionOffice.get_org_generic_to_org_election()
+
+ # For any given "_election" variant, return the base org type.
+ # For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
+ election_org_map = self.OrgChoicesElectionOffice.get_org_election_to_org_generic()
+
+ # Manages the "organization_type" variable and keeps in sync with
+ # "is_election_office" and "generic_organization_type"
+ org_type_helper = CreateOrUpdateOrganizationTypeHelper(
+ sender=self.__class__,
+ instance=self,
+ generic_org_to_org_map=generic_org_map,
+ election_org_to_generic_org_map=election_org_map,
+ )
+
+ # Actually updates the organization_type field
+ org_type_helper.create_or_update_organization_type()
+ super().save(*args, **kwargs)
+
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:
diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py
index e8636a462..82179f8dc 100644
--- a/src/registrar/models/user_group.py
+++ b/src/registrar/models/user_group.py
@@ -5,6 +5,11 @@ logger = logging.getLogger(__name__)
class UserGroup(Group):
+ """
+ UserGroup sets read and write permissions for superusers (who have full access)
+ and analysts. For more details, see the dev docs for user-permissions.
+ """
+
class Meta:
verbose_name = "User group"
verbose_name_plural = "User groups"
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index 01d4e6b33..32f767ede 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -35,3 +35,219 @@ class Timer:
self.end = time.time()
self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds")
+
+
+class CreateOrUpdateOrganizationTypeHelper:
+ """
+ A helper that manages the "organization_type" field in DomainRequest and DomainInformation
+ """
+
+ def __init__(self, sender, instance, generic_org_to_org_map, election_org_to_generic_org_map):
+ # The "model type"
+ self.sender = sender
+ self.instance = instance
+ self.generic_org_to_org_map = generic_org_to_org_map
+ self.election_org_to_generic_org_map = election_org_to_generic_org_map
+
+ def create_or_update_organization_type(self):
+ """The organization_type field on DomainRequest and DomainInformation is consituted from the
+ generic_org_type and is_election_board fields. To keep the organization_type
+ field up to date, we need to update it before save based off of those field
+ values.
+
+ If the instance is marked as an election board and the generic_org_type is not
+ one of the excluded types (FEDERAL, INTERSTATE, SCHOOL_DISTRICT), the
+ organization_type is set to a corresponding election variant. Otherwise, it directly
+ mirrors the generic_org_type value.
+ """
+
+ # A new record is added with organization_type not defined.
+ # This happens from the regular domain request flow.
+ is_new_instance = self.instance.id is None
+ if is_new_instance:
+ self._handle_new_instance()
+ else:
+ self._handle_existing_instance()
+
+ return self.instance
+
+ def _handle_new_instance(self):
+ # == Check for invalid conditions before proceeding == #
+ should_proceed = self._validate_new_instance()
+ if not should_proceed:
+ return None
+ # == Program flow will halt here if there is no reason to update == #
+
+ # == Update the linked values == #
+ organization_type_needs_update = self.instance.organization_type is None
+ generic_org_type_needs_update = self.instance.generic_org_type is None
+
+ # If a field is none, it indicates (per prior checks) that the
+ # related field (generic org type <-> org type) has data and we should update according to that.
+ if organization_type_needs_update:
+ self._update_org_type_from_generic_org_and_election()
+ elif generic_org_type_needs_update:
+ self._update_generic_org_and_election_from_org_type()
+
+ # Update the field
+ self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
+
+ def _handle_existing_instance(self):
+ # == Init variables == #
+ # Instance is already in the database, fetch its current state
+ current_instance = self.sender.objects.get(id=self.instance.id)
+
+ # Check the new and old values
+ generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
+ is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
+ organization_type_changed = self.instance.organization_type != current_instance.organization_type
+
+ # == Check for invalid conditions before proceeding == #
+ if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
+ # Since organization type is linked with generic_org_type and election board,
+ # we have to update one or the other, not both.
+ # This will not happen in normal flow as it is not possible otherwise.
+ raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
+ elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
+ # No values to update - do nothing
+ return None
+ # == Program flow will halt here if there is no reason to update == #
+
+ # == Update the linked values == #
+ # Find out which field needs updating
+ organization_type_needs_update = generic_org_type_changed or is_election_board_changed
+ generic_org_type_needs_update = organization_type_changed
+
+ # Update the field
+ self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
+
+ def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
+ """
+ Validates the conditions for updating organization and generic organization types.
+
+ Raises:
+ ValueError: If both organization_type_needs_update and generic_org_type_needs_update are True,
+ indicating an attempt to update both fields simultaneously, which is not allowed.
+ """
+ # We shouldn't update both of these at the same time.
+ # It is more useful to have these as seperate variables, but it does impose
+ # this restraint.
+ if organization_type_needs_update and generic_org_type_needs_update:
+ raise ValueError("Cannot update both org type and generic org type at the same time.")
+
+ if organization_type_needs_update:
+ self._update_org_type_from_generic_org_and_election()
+ elif generic_org_type_needs_update:
+ self._update_generic_org_and_election_from_org_type()
+
+ def _update_org_type_from_generic_org_and_election(self):
+ """Given a field values for generic_org_type and is_election_board, update the
+ organization_type field."""
+
+ # We convert to a string because the enum types are different.
+ generic_org_type = str(self.instance.generic_org_type)
+ if generic_org_type not in self.generic_org_to_org_map:
+ # Election board should always be reset to None if the record
+ # can't have one. For example, federal.
+ if self.instance.is_election_board is not None:
+ # This maintains data consistency.
+ # There is no avenue for this to occur in the UI,
+ # as such - this can only occur if the object is initialized in this way.
+ # Or if there are pre-existing data.
+ logger.warning(
+ "create_or_update_organization_type() -> is_election_board "
+ f"cannot exist for {generic_org_type}. Setting to None."
+ )
+ self.instance.is_election_board = None
+ self.instance.organization_type = generic_org_type
+ else:
+ # This can only happen with manual data tinkering, which causes these to be out of sync.
+ if self.instance.is_election_board is None:
+ logger.warning(
+ "create_or_update_organization_type() -> is_election_board is out of sync. Updating value."
+ )
+ self.instance.is_election_board = False
+
+ if self.instance.is_election_board:
+ self.instance.organization_type = self.generic_org_to_org_map[generic_org_type]
+ else:
+ self.instance.organization_type = generic_org_type
+
+ def _update_generic_org_and_election_from_org_type(self):
+ """Given the field value for organization_type, update the
+ generic_org_type and is_election_board field."""
+
+ # We convert to a string because the enum types are different
+ # between OrgChoicesElectionOffice and OrganizationChoices.
+ # But their names are the same (for the most part).
+ current_org_type = str(self.instance.organization_type)
+ election_org_map = self.election_org_to_generic_org_map
+ generic_org_map = self.generic_org_to_org_map
+
+ # This essentially means: "_election" in current_org_type.
+ if current_org_type in election_org_map:
+ new_org = election_org_map[current_org_type]
+ self.instance.generic_org_type = new_org
+ self.instance.is_election_board = True
+ elif self.instance.organization_type is not None:
+ self.instance.generic_org_type = current_org_type
+
+ # This basically checks if the given org type
+ # can even have an election board in the first place.
+ # For instance, federal cannot so is_election_board = None
+ if current_org_type in generic_org_map:
+ self.instance.is_election_board = False
+ else:
+ # This maintains data consistency.
+ # There is no avenue for this to occur in the UI,
+ # as such - this can only occur if the object is initialized in this way.
+ # Or if there are pre-existing data.
+ logger.warning(
+ "create_or_update_organization_type() -> is_election_board "
+ f"cannot exist for {current_org_type}. Setting to None."
+ )
+ self.instance.is_election_board = None
+ else:
+ # if self.instance.organization_type is set to None, then this means
+ # we should clear the related fields.
+ # This will not occur if it just is None (i.e. default), only if it is set to be so.
+ self.instance.is_election_board = None
+ self.instance.generic_org_type = None
+
+ def _validate_new_instance(self):
+ """
+ Validates whether a new instance of DomainRequest or DomainInformation can proceed with the update
+ based on the consistency between organization_type, generic_org_type, and is_election_board.
+
+ Returns a boolean determining if execution should proceed or not.
+ """
+
+ # We conditionally accept both of these values to exist simultaneously, as long as
+ # those values do not intefere with eachother.
+ # Because this condition can only be triggered through a dev (no user flow),
+ # we throw an error if an invalid state is found here.
+ if self.instance.organization_type and self.instance.generic_org_type:
+ generic_org_type = str(self.instance.generic_org_type)
+ organization_type = str(self.instance.organization_type)
+
+ # Strip "_election" if it exists
+ mapped_org_type = self.election_org_to_generic_org_map.get(organization_type)
+
+ # Do tests on the org update for election board changes.
+ is_election_type = "_election" in organization_type
+ can_have_election_board = organization_type in self.generic_org_to_org_map
+
+ election_board_mismatch = (is_election_type != self.instance.is_election_board) and can_have_election_board
+ org_type_mismatch = mapped_org_type is not None and (generic_org_type != mapped_org_type)
+ if election_board_mismatch or org_type_mismatch:
+ message = (
+ "Cannot add organization_type and generic_org_type simultaneously "
+ "when generic_org_type, is_election_board, and organization_type values do not match."
+ )
+ raise ValueError(message)
+
+ return True
+ elif not self.instance.organization_type and not self.instance.generic_org_type:
+ return False
+ else:
+ return True
diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html
new file mode 100644
index 000000000..20a029bed
--- /dev/null
+++ b/src/registrar/templates/admin/input_with_clipboard.html
@@ -0,0 +1,39 @@
+{% load i18n static %}
+
+{% comment %}
+Template for an input field with a clipboard
+{% endcomment %}
+
+{% if not invisible_input_field %}
+
+ {{ field }}
+
+
+{% else %}
+
+
+
+
+{% endif %}
\ No newline at end of file
diff --git a/src/registrar/templates/django/admin/email_clipboard_change_form.html b/src/registrar/templates/django/admin/email_clipboard_change_form.html
new file mode 100644
index 000000000..15cb11841
--- /dev/null
+++ b/src/registrar/templates/django/admin/email_clipboard_change_form.html
@@ -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 %}
diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html
index 6afe4c8d6..8ad6fb96d 100644
--- a/src/registrar/templates/django/admin/includes/contact_detail_list.html
+++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html
@@ -26,10 +26,12 @@
{% if user.email or user.contact.email %}
{% if user.contact.email %}
{{ user.contact.email }}
+ {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %}
{{ user.email }}
+ {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %}
-
+
{% else %}
None
{% endif %}
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 47145faf2..a0a679290 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -92,8 +92,25 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{{ contact.get_formatted_name }}
{{ contact.title }}
-
{{ contact.email }}
+
+ {{ contact.email }}
+
+
{{ contact.phone }}
+
+
+
+
{% endfor %}
diff --git a/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html
new file mode 100644
index 000000000..f959f8edf
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html
@@ -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 %}
diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html
index 65da4ef6b..5196b641a 100644
--- a/src/registrar/templates/domain_users.html
+++ b/src/registrar/templates/domain_users.html
@@ -84,7 +84,7 @@
{% else %}
-
- Attention: You are on a test site.
+
+
+
+ Attention: You are on a test site.
+
-
+
\ No newline at end of file
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
index 9ecc6af67..a0b0e774f 100644
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -158,7 +158,7 @@ class GenericTestHelper(TestCase):
Example Usage:
```
self.assert_sort_helper(
- self.factory, self.superuser, self.admin, self.url, DomainInformation, "1", ("domain__name",)
+ "1", ("domain__name",)
)
```
@@ -585,7 +585,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
- is_election_board=True,
+ is_election_board=False,
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_2, generic_org_type="interstate", is_election_board=True
@@ -595,14 +595,14 @@ class MockDb(TestCase):
domain=self.domain_3,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
- is_election_board=True,
+ is_election_board=False,
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
generic_org_type="federal",
federal_agency="Armed Forces Retirement Home",
- is_election_board=True,
+ is_election_board=False,
)
self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@@ -652,7 +652,7 @@ class MockDb(TestCase):
generic_org_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
- is_election_board=True,
+ is_election_board=False,
)
self.domain_information_12, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@@ -693,6 +693,24 @@ class MockDb(TestCase):
user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER
)
+ _, created = DomainInvitation.objects.get_or_create(
+ email=meoward_user.email, domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
+ )
+
+ _, created = DomainInvitation.objects.get_or_create(
+ email="woofwardthethird@rocks.com",
+ domain=self.domain_1,
+ status=DomainInvitation.DomainInvitationStatus.INVITED,
+ )
+
+ _, created = DomainInvitation.objects.get_or_create(
+ email="squeaker@rocks.com", domain=self.domain_2, status=DomainInvitation.DomainInvitationStatus.INVITED
+ )
+
+ _, created = DomainInvitation.objects.get_or_create(
+ email="squeaker@rocks.com", domain=self.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED
+ )
+
with less_console_noise():
self.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
@@ -722,6 +740,7 @@ class MockDb(TestCase):
DomainRequest.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
+ DomainInvitation.objects.all().delete()
def mock_user():
@@ -782,6 +801,9 @@ def completed_domain_request(
submitter=False,
name="city.gov",
investigator=None,
+ generic_org_type="federal",
+ is_election_board=False,
+ organization_type=None,
):
"""A completed domain request."""
if not user:
@@ -819,7 +841,8 @@ def completed_domain_request(
is_staff=True,
)
domain_request_kwargs = dict(
- generic_org_type="federal",
+ generic_org_type=generic_org_type,
+ is_election_board=is_election_board,
federal_type="executive",
purpose="Purpose of the site",
is_policy_acknowledged=True,
@@ -840,6 +863,9 @@ def completed_domain_request(
if has_anything_else:
domain_request_kwargs["anything_else"] = "There is more"
+ if organization_type:
+ domain_request_kwargs["organization_type"] = organization_type
+
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 368f30721..370051b8a 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -1,4 +1,6 @@
-from datetime import date
+from datetime import date, datetime
+from django.utils import timezone
+import re
from django.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack
@@ -716,7 +718,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",))
def test_submitter_sortable(self):
- """Tests if the DomainRequest sorts by domain correctly"""
+ """Tests if the DomainRequest sorts by submitter correctly"""
with less_console_noise():
p = "adminpass"
self.client.login(username="superuser", password=p)
@@ -747,7 +749,7 @@ class TestDomainRequestAdmin(MockEppLib):
)
def test_investigator_sortable(self):
- """Tests if the DomainRequest sorts by domain correctly"""
+ """Tests if the DomainRequest sorts by investigator correctly"""
with less_console_noise():
p = "adminpass"
self.client.login(username="superuser", password=p)
@@ -760,7 +762,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that our sort works correctly
self.test_helper.assert_table_sorted(
- "6",
+ "12",
(
"investigator__first_name",
"investigator__last_name",
@@ -769,13 +771,77 @@ class TestDomainRequestAdmin(MockEppLib):
# Assert that sorting in reverse works correctly
self.test_helper.assert_table_sorted(
- "-6",
+ "-12",
(
"-investigator__first_name",
"-investigator__last_name",
),
)
+ @less_console_noise_decorator
+ def test_default_sorting_in_domain_requests_list(self):
+ """
+ Make sure the default sortin in on the domain requests list page is reverse submission_date
+ then alphabetical requested_domain
+ """
+
+ # Create domain requests with different names
+ domain_requests = [
+ completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, name=name)
+ for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"]
+ ]
+
+ domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16))
+ domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16))
+ domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16))
+ domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16))
+ domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16))
+ domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16))
+
+ # Save the modified domain requests to update their attributes in the database
+ for domain_request in domain_requests:
+ domain_request.save()
+
+ # Refresh domain request objects from the database to reflect the changes
+ domain_requests = [DomainRequest.objects.get(pk=domain_request.pk) for domain_request in domain_requests]
+
+ # Login as superuser and retrieve the domain request list page
+ self.client.force_login(self.superuser)
+ response = self.client.get("/admin/registrar/domainrequest/")
+
+ # Check that the response is successful
+ self.assertEqual(response.status_code, 200)
+
+ # Extract the domain names from the response content using regex
+ domain_names_match = re.findall(r"(\w+\.gov)", response.content.decode("utf-8"))
+
+ logger.info(f"domain_names_match {domain_names_match}")
+
+ # Verify that domain names are found
+ self.assertTrue(domain_names_match)
+
+ # Extract the domain names
+ domain_names = [match for match in domain_names_match]
+
+ # Verify that the domain names are displayed in the expected order
+ expected_order = [
+ "ccc.gov",
+ "zzz.gov",
+ "bbb.gov",
+ "aaa.gov",
+ "ddd.gov",
+ "eee.gov",
+ ]
+
+ # Remove duplicates
+ # Remove duplicates from domain_names list while preserving order
+ unique_domain_names = []
+ for domain_name in domain_names:
+ if domain_name not in unique_domain_names:
+ unique_domain_names.append(domain_name)
+
+ self.assertEqual(unique_domain_names, expected_order)
+
def test_short_org_name_in_domain_requests_list(self):
"""
Make sure the short name is displaying in admin on the list page
@@ -1440,9 +1506,6 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
- # Check that the modal has the right content
- # Check for the header
-
# == Check for the creator == #
# Check for the right title, email, and phone number in the response.
@@ -1458,37 +1521,38 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
+ self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
- ("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
+ self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
- ("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
- # count=5 because the underlying domain has two users with this name.
- # The dropdown has 3 of these.
- self.assertContains(response, "Testy Tester", count=5)
+ self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
+ self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
- ("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
+ # Test for the copy link
+ self.assertContains(response, "usa-button__clipboard", count=4)
+
def test_save_model_sets_restricted_status_on_user(self):
with less_console_noise():
# make sure there is no user with this email
@@ -1588,6 +1652,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
+ "generic_org_type",
+ "is_election_board",
"id",
"created_at",
"updated_at",
@@ -1596,12 +1662,13 @@ class TestDomainRequestAdmin(MockEppLib):
"creator",
"investigator",
"generic_org_type",
+ "is_election_board",
+ "organization_type",
"federally_recognized_tribe",
"state_recognized_tribe",
"tribe_name",
"federal_agency",
"federal_type",
- "is_election_board",
"organization_name",
"address_line1",
"address_line2",
@@ -1636,6 +1703,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
+ "generic_org_type",
+ "is_election_board",
"creator",
"about_your_organization",
"requested_domain",
@@ -1661,6 +1730,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
+ "generic_org_type",
+ "is_election_board",
]
self.assertEqual(readonly_fields, expected_fields)
@@ -2217,37 +2288,38 @@ class TestDomainInformationAdmin(TestCase):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
+ self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
- ("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
+ self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
- ("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
- # count=5 because the underlying domain has two users with this name.
- # The dropdown has 3 of these.
- self.assertContains(response, "Testy Tester", count=5)
+ self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
+ self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
- ("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
+ # Test for the copy link
+ self.assertContains(response, "usa-button__clipboard", count=4)
+
def test_readonly_fields_for_analyst(self):
"""Ensures that analysts have their permissions setup correctly"""
with less_console_noise():
@@ -2258,6 +2330,8 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [
"other_contacts",
+ "generic_org_type",
+ "is_election_board",
"creator",
"type_of_work",
"more_organization_information",
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index c7fe5f94c..d535c9370 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -1161,3 +1161,309 @@ class TestContact(TestCase):
# test for a contact which is assigned as an authorizing official on a domain request
self.assertFalse(self.contact_as_ao.has_more_than_one_join("authorizing_official"))
self.assertTrue(self.contact_as_ao.has_more_than_one_join("submitted_domain_requests"))
+
+
+class TestDomainRequestCustomSave(TestCase):
+ """Tests custom save behaviour on the DomainRequest object"""
+
+ def tearDown(self):
+ DomainRequest.objects.all().delete()
+ super().tearDown()
+
+ def test_create_or_update_organization_type_new_instance(self):
+ """Test create_or_update_organization_type when creating a new instance"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ )
+
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
+
+ def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
+ """Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
+ is_election_board=True,
+ )
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
+ self.assertEqual(domain_request.is_election_board, None)
+
+ def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
+ """Test create_or_update_organization_type for an existing instance."""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=False,
+ )
+ domain_request.is_election_board = True
+ domain_request.save()
+
+ self.assertEqual(domain_request.is_election_board, True)
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
+
+ # Try reverting the election board value
+ domain_request.is_election_board = False
+ domain_request.save()
+
+ self.assertEqual(domain_request.is_election_board, False)
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+
+ # Try reverting setting an invalid value for election board (should revert to False)
+ domain_request.is_election_board = None
+ domain_request.save()
+
+ self.assertEqual(domain_request.is_election_board, False)
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+
+ def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
+ """Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ )
+
+ domain_request.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
+ domain_request.save()
+
+ # Election board should be None because interstate cannot have an election board.
+ self.assertEqual(domain_request.is_election_board, None)
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
+
+ # Try changing the org Type to something that CAN have an election board.
+ domain_request_tribal = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="startedTribal.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
+ is_election_board=True,
+ )
+ self.assertEqual(
+ domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
+ )
+
+ # Change the org type
+ domain_request_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
+ domain_request_tribal.save()
+
+ self.assertEqual(domain_request_tribal.is_election_board, True)
+ self.assertEqual(
+ domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION
+ )
+
+ def test_create_or_update_organization_type_no_update(self):
+ """Test create_or_update_organization_type when there are no values to update."""
+
+ # Test for when both generic_org_type and organization_type is declared,
+ # and are both non-election board
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=False,
+ )
+ domain_request.save()
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+ self.assertEqual(domain_request.is_election_board, False)
+ self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Test for when both generic_org_type and organization_type is declared,
+ # and are both election board
+ domain_request_election = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="startedElection.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
+ )
+
+ self.assertEqual(
+ domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
+ )
+ self.assertEqual(domain_request_election.is_election_board, True)
+ self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Modify an unrelated existing value for both, and ensure that everything is still consistent
+ domain_request.city = "Fudge"
+ domain_request_election.city = "Caramel"
+ domain_request.save()
+ domain_request_election.save()
+
+ self.assertEqual(domain_request.city, "Fudge")
+ self.assertEqual(domain_request_election.city, "Caramel")
+
+ # Test for non-election
+ self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+ self.assertEqual(domain_request.is_election_board, False)
+ self.assertEqual(domain_request.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Test for election
+ self.assertEqual(
+ domain_request_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
+ )
+ self.assertEqual(domain_request_election.is_election_board, True)
+ self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+
+class TestDomainInformationCustomSave(TestCase):
+ """Tests custom save behaviour on the DomainInformation object"""
+
+ def tearDown(self):
+ DomainInformation.objects.all().delete()
+ DomainRequest.objects.all().delete()
+ Domain.objects.all().delete()
+ super().tearDown()
+
+ def test_create_or_update_organization_type_new_instance(self):
+ """Test create_or_update_organization_type when creating a new instance"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ )
+
+ domain_information = DomainInformation.create_from_da(domain_request)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
+
+ def test_create_or_update_organization_type_new_instance_federal_does_nothing(self):
+ """Test if create_or_update_organization_type does nothing when creating a new instance for federal"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
+ is_election_board=True,
+ )
+
+ domain_information = DomainInformation.create_from_da(domain_request)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL)
+ self.assertEqual(domain_information.is_election_board, None)
+
+ def test_create_or_update_organization_type_existing_instance_updates_election_board(self):
+ """Test create_or_update_organization_type for an existing instance."""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=False,
+ )
+ domain_information = DomainInformation.create_from_da(domain_request)
+ domain_information.is_election_board = True
+ domain_information.save()
+
+ self.assertEqual(domain_information.is_election_board, True)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION)
+
+ # Try reverting the election board value
+ domain_information.is_election_board = False
+ domain_information.save()
+ domain_information.refresh_from_db()
+
+ self.assertEqual(domain_information.is_election_board, False)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+
+ # Try reverting setting an invalid value for election board (should revert to False)
+ domain_information.is_election_board = None
+ domain_information.save()
+
+ self.assertEqual(domain_information.is_election_board, False)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+
+ def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self):
+ """Test create_or_update_organization_type when modifying generic_org_type on an existing instance."""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ )
+ domain_information = DomainInformation.create_from_da(domain_request)
+
+ domain_information.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
+ domain_information.save()
+
+ # Election board should be None because interstate cannot have an election board.
+ self.assertEqual(domain_information.is_election_board, None)
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.INTERSTATE)
+
+ # Try changing the org Type to something that CAN have an election board.
+ domain_request_tribal = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="startedTribal.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.TRIBAL,
+ is_election_board=True,
+ )
+ domain_information_tribal = DomainInformation.create_from_da(domain_request_tribal)
+ self.assertEqual(
+ domain_information_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.TRIBAL_ELECTION
+ )
+
+ # Change the org type
+ domain_information_tribal.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
+ domain_information_tribal.save()
+
+ self.assertEqual(domain_information_tribal.is_election_board, True)
+ self.assertEqual(
+ domain_information_tribal.organization_type,
+ DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION,
+ )
+
+ def test_create_or_update_organization_type_no_update(self):
+ """Test create_or_update_organization_type when there are no values to update."""
+
+ # Test for when both generic_org_type and organization_type is declared,
+ # and are both non-election board
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=False,
+ )
+ domain_information = DomainInformation.create_from_da(domain_request)
+ domain_information.save()
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+ self.assertEqual(domain_information.is_election_board, False)
+ self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Test for when both generic_org_type and organization_type is declared,
+ # and are both election board
+ domain_request_election = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="startedElection.gov",
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ is_election_board=True,
+ organization_type=DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION,
+ )
+ domain_information_election = DomainInformation.create_from_da(domain_request_election)
+
+ self.assertEqual(
+ domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
+ )
+ self.assertEqual(domain_information_election.is_election_board, True)
+ self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Modify an unrelated existing value for both, and ensure that everything is still consistent
+ domain_information.city = "Fudge"
+ domain_information_election.city = "Caramel"
+ domain_information.save()
+ domain_information_election.save()
+
+ self.assertEqual(domain_information.city, "Fudge")
+ self.assertEqual(domain_information_election.city, "Caramel")
+
+ # Test for non-election
+ self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY)
+ self.assertEqual(domain_information.is_election_board, False)
+ self.assertEqual(domain_information.generic_org_type, DomainRequest.OrganizationChoices.CITY)
+
+ # Test for election
+ self.assertEqual(
+ domain_information_election.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION
+ )
+ self.assertEqual(domain_information_election.is_election_board, True)
+ self.assertEqual(domain_information_election.generic_org_type, DomainRequest.OrganizationChoices.CITY)
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index 5bd594a15..be66cb876 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -9,10 +9,10 @@ from registrar.utility.csv_export import (
export_data_unmanaged_domains_to_csv,
get_sliced_domains,
get_sliced_requests,
- write_domains_csv,
+ write_csv_for_domains,
get_default_start_date,
get_default_end_date,
- write_requests_csv,
+ write_csv_for_requests,
)
from django.core.management import call_command
@@ -242,8 +242,13 @@ class ExportDataTest(MockDb, MockEppLib):
}
self.maxDiff = None
# Call the export functions
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer,
+ columns,
+ sort_fields,
+ filter_condition,
+ should_get_domain_managers=False,
+ should_write_header=True,
)
# Reset the CSV file's position to the beginning
@@ -268,7 +273,7 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
- def test_write_domains_csv(self):
+ def test_write_csv_for_domains(self):
"""Test that write_body returns the
existing domain, test that sort by domain name works,
test that filter works"""
@@ -304,8 +309,13 @@ class ExportDataTest(MockDb, MockEppLib):
],
}
# Call the export functions
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer,
+ columns,
+ sort_fields,
+ filter_condition,
+ should_get_domain_managers=False,
+ should_write_header=True,
)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@@ -357,8 +367,13 @@ class ExportDataTest(MockDb, MockEppLib):
],
}
# Call the export functions
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer,
+ columns,
+ sort_fields,
+ filter_condition,
+ should_get_domain_managers=False,
+ should_write_header=True,
)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@@ -433,20 +448,20 @@ class ExportDataTest(MockDb, MockEppLib):
}
# Call the export functions
- write_domains_csv(
+ write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
- get_domain_managers=False,
+ should_get_domain_managers=False,
should_write_header=True,
)
- write_domains_csv(
+ write_csv_for_domains(
writer,
columns,
sort_fields_for_deleted_domains,
filter_conditions_for_deleted_domains,
- get_domain_managers=False,
+ should_get_domain_managers=False,
should_write_header=False,
)
# Reset the CSV file's position to the beginning
@@ -478,7 +493,12 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the
- expected domain managers."""
+ expected domain managers.
+
+ An invited user, woofwardthethird, should also be pulled into this report.
+
+ squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers).
+ She should show twice in this report but not in test_export_data_managed_domains_to_csv."""
with less_console_noise():
# Create a CSV file in memory
@@ -508,8 +528,13 @@ class ExportDataTest(MockDb, MockEppLib):
}
self.maxDiff = None
# Call the export functions
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
+ write_csv_for_domains(
+ writer,
+ columns,
+ sort_fields,
+ filter_condition,
+ should_get_domain_managers=True,
+ should_write_header=True,
)
# Reset the CSV file's position to the beginning
@@ -521,14 +546,16 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email,"
- "Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n"
- "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n"
- "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n"
- "cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.com\n"
+ "Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
+ "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
+ "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
+ "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n"
+ "cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n"
"cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,,"
- ", , , ,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n"
+ ", , , ,meoward@rocks.com,R,info@example.com,R,big_lebowski@dude.co,R,"
+ "woofwardthethird@rocks.com,I\n"
"ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
- "zdomain12.govReadyInterstatemeoward@rocks.com\n"
+ "zdomain12.govReadyInterstatemeoward@rocks.comR\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
@@ -538,7 +565,9 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates,
- get list of managed domains at end_date."""
+ get list of managed domains at end_date.
+
+ An invited user, woofwardthethird, should also be pulled into this report."""
with less_console_noise():
# Create a CSV file in memory
@@ -562,12 +591,14 @@ class ExportDataTest(MockDb, MockEppLib):
"MANAGED DOMAINS COUNTS AT END DATE\n"
"Total,Federal,Interstate,State or territory,Tribal,County,City,"
"Special district,School district,Election office\n"
- "3,2,1,0,0,0,0,0,0,2\n"
+ "3,2,1,0,0,0,0,0,0,0\n"
"\n"
- "Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n"
- "cdomain11.govFederal-Executivemeoward@rocks.com\n"
- "cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n"
- "zdomain12.govInterstatemeoward@rocks.com\n"
+ "Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
+ "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
+ "cdomain11.govFederal-Executivemeoward@rocks.com, R\n"
+ "cdomain1.gov,Federal - Executive,meoward@rocks.com,R,info@example.com,R,"
+ "big_lebowski@dude.co,R,woofwardthethird@rocks.com,I\n"
+ "zdomain12.govInterstatemeoward@rocks.com,R\n"
)
# Normalize line endings and remove commas,
@@ -642,7 +673,7 @@ class ExportDataTest(MockDb, MockEppLib):
"submission_date__lte": self.end_date,
"submission_date__gte": self.start_date,
}
- write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True)
+ write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
@@ -686,13 +717,13 @@ class HelperFunctions(MockDb):
"domain__first_ready__lte": self.end_date,
}
# Test with distinct
- managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True)
- expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 2]
+ managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
+ expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
- expected_content = [3, 4, 1, 0, 0, 0, 0, 0, 0, 2]
+ expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
def test_get_sliced_requests(self):
diff --git a/src/registrar/tests/test_signals.py b/src/registrar/tests/test_signals.py
index 4e2cbc83b..e796bd12a 100644
--- a/src/registrar/tests/test_signals.py
+++ b/src/registrar/tests/test_signals.py
@@ -1,6 +1,5 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
-
from registrar.models import Contact
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
index 7fc710827..949b0adcd 100644
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -1,8 +1,8 @@
-from collections import Counter
import csv
import logging
from datetime import datetime
from registrar.models.domain import Domain
+from registrar.models.domain_invitation import DomainInvitation
from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation
from django.utils import timezone
@@ -11,6 +11,7 @@ from django.db.models import F, Value, CharField
from django.db.models.functions import Concat, Coalesce
from registrar.models.public_contact import PublicContact
+from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__)
@@ -33,7 +34,6 @@ def get_domain_infos(filter_condition, sort_fields):
"""
domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official")
- .prefetch_related("domain__permissions")
.filter(**filter_condition)
.order_by(*sort_fields)
.distinct()
@@ -53,7 +53,14 @@ def get_domain_infos(filter_condition, sort_fields):
return domain_infos_cleaned
-def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False):
+def parse_row_for_domain(
+ columns,
+ domain_info: DomainInformation,
+ dict_security_emails=None,
+ should_get_domain_managers=False,
+ dict_domain_invitations_with_invited_status=None,
+ dict_user_domain_roles=None,
+):
"""Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information
@@ -65,8 +72,8 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di
# Grab the security email from a preset dictionary.
# If nothing exists in the dictionary, grab from .contacts.
- if security_emails_dict is not None and domain.name in security_emails_dict:
- _email = security_emails_dict.get(domain.name)
+ if dict_security_emails is not None and domain.name in dict_security_emails:
+ _email = dict_security_emails.get(domain.name)
security_email = _email if _email is not None else " "
else:
# If the dictionary doesn't contain that data, lets filter for it manually.
@@ -103,13 +110,22 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di
"Deleted": domain.deleted,
}
- if get_domain_managers:
- # Get each domain managers email and add to list
- dm_emails = [dm.user.email for dm in domain.permissions.all()]
+ if should_get_domain_managers:
+ # Get lists of emails for active and invited domain managers
- # Set up the "matching header" + row field data
- for i, dm_email in enumerate(dm_emails, start=1):
- FIELDS[f"Domain manager email {i}"] = dm_email
+ dms_active_emails = dict_user_domain_roles.get(domain_info.domain.name, [])
+ dms_invited_emails = dict_domain_invitations_with_invited_status.get(domain_info.domain.name, [])
+
+ # Set up the "matching headers" + row field data for email and status
+ i = 0 # Declare i outside of the loop to avoid a reference before assignment in the second loop
+ for i, dm_email in enumerate(dms_active_emails, start=1):
+ FIELDS[f"Domain manager {i}"] = dm_email
+ FIELDS[f"DM{i} status"] = "R"
+
+ # Continue enumeration from where we left off and add data for invited domain managers
+ for j, dm_email in enumerate(dms_invited_emails, start=i + 1):
+ FIELDS[f"Domain manager {j}"] = dm_email
+ FIELDS[f"DM{j} status"] = "I"
row = [FIELDS.get(column, "") for column in columns]
return row
@@ -119,7 +135,7 @@ def _get_security_emails(sec_contact_ids):
"""
Retrieve security contact emails for the given security contact IDs.
"""
- security_emails_dict = {}
+ dict_security_emails = {}
public_contacts = (
PublicContact.objects.only("email", "domain__name")
.select_related("domain")
@@ -129,65 +145,151 @@ def _get_security_emails(sec_contact_ids):
# Populate a dictionary of domain names and their security contacts
for contact in public_contacts:
domain: Domain = contact.domain
- if domain is not None and domain.name not in security_emails_dict:
- security_emails_dict[domain.name] = contact.email
+ if domain is not None and domain.name not in dict_security_emails:
+ dict_security_emails[domain.name] = contact.email
else:
logger.warning("csv_export -> Domain was none for PublicContact")
- return security_emails_dict
+ return dict_security_emails
-def write_domains_csv(
+def count_domain_managers(domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles):
+ """Count active and invited domain managers"""
+ dms_active = len(dict_user_domain_roles.get(domain_name, []))
+ dms_invited = len(dict_domain_invitations_with_invited_status.get(domain_name, []))
+ return dms_active, dms_invited
+
+
+def update_columns(columns, dms_total, should_update_columns):
+ """Update columns if necessary"""
+ if should_update_columns:
+ for i in range(1, dms_total + 1):
+ email_column_header = f"Domain manager {i}"
+ status_column_header = f"DM{i} status"
+ if email_column_header not in columns:
+ columns.append(email_column_header)
+ columns.append(status_column_header)
+ should_update_columns = False
+ return columns, should_update_columns, dms_total
+
+
+def update_columns_with_domain_managers(
+ columns,
+ domain_info,
+ should_update_columns,
+ dms_total,
+ dict_domain_invitations_with_invited_status,
+ dict_user_domain_roles,
+):
+ """Helper function to update columns with domain manager information"""
+
+ domain_name = domain_info.domain.name
+
+ try:
+ dms_active, dms_invited = count_domain_managers(
+ domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles
+ )
+
+ if dms_active + dms_invited > dms_total:
+ dms_total = dms_active + dms_invited
+ should_update_columns = True
+
+ except Exception as err:
+ logger.error(f"Exception while parsing domain managers for reports: {err}")
+
+ return update_columns(columns, dms_total, should_update_columns)
+
+
+def build_dictionaries_for_domain_managers(dict_user_domain_roles, dict_domain_invitations_with_invited_status):
+ """Helper function that builds dicts for invited users and active domain
+ managers. We do so to avoid filtering within loops."""
+
+ user_domain_roles = UserDomainRole.objects.all()
+
+ # Iterate through each user domain role and populate the dictionary
+ for user_domain_role in user_domain_roles:
+ domain_name = user_domain_role.domain.name
+ email = user_domain_role.user.email
+ if domain_name not in dict_user_domain_roles:
+ dict_user_domain_roles[domain_name] = []
+ dict_user_domain_roles[domain_name].append(email)
+
+ domain_invitations_with_invited_status = None
+ domain_invitations_with_invited_status = DomainInvitation.objects.filter(
+ status=DomainInvitation.DomainInvitationStatus.INVITED
+ ).select_related("domain")
+
+ # Iterate through each domain invitation and populate the dictionary
+ for invite in domain_invitations_with_invited_status:
+ domain_name = invite.domain.name
+ email = invite.email
+ if domain_name not in dict_domain_invitations_with_invited_status:
+ dict_domain_invitations_with_invited_status[domain_name] = []
+ dict_domain_invitations_with_invited_status[domain_name].append(email)
+
+ return dict_user_domain_roles, dict_domain_invitations_with_invited_status
+
+
+def write_csv_for_domains(
writer,
columns,
sort_fields,
filter_condition,
- get_domain_managers=False,
+ should_get_domain_managers=False,
should_write_header=True,
):
"""
Receives params from the parent methods and outputs a CSV with filtered and sorted domains.
Works with write_header as long as the same writer object is passed.
- get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
+ should_get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice
"""
+ # Retrieve domain information and all sec emails
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
-
- # Store all security emails to avoid epp calls or excessive filters
sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
-
- security_emails_dict = _get_security_emails(sec_contact_ids)
-
- # Reduce the memory overhead when performing the write operation
+ dict_security_emails = _get_security_emails(sec_contact_ids)
paginator = Paginator(all_domain_infos, 1000)
- # The maximum amount of domain managers an account has
- # We get the max so we can set the column header accurately
- max_dm_count = 0
+ # Initialize variables
+ dms_total = 0
+ should_update_columns = False
total_body_rows = []
+ dict_user_domain_roles = {}
+ dict_domain_invitations_with_invited_status = {}
+ # Build dictionaries if necessary
+ if should_get_domain_managers:
+ dict_user_domain_roles, dict_domain_invitations_with_invited_status = build_dictionaries_for_domain_managers(
+ dict_user_domain_roles, dict_domain_invitations_with_invited_status
+ )
+
+ # Process domain information
for page_num in paginator.page_range:
rows = []
page = paginator.page(page_num)
for domain_info in page.object_list:
-
- # Get count of all the domain managers for an account
- if get_domain_managers:
- dm_count = domain_info.domain.permissions.count()
- if dm_count > max_dm_count:
- max_dm_count = dm_count
- for i in range(1, max_dm_count + 1):
- column_name = f"Domain manager email {i}"
- if column_name not in columns:
- columns.append(column_name)
+ if should_get_domain_managers:
+ columns, dms_total, should_update_columns = update_columns_with_domain_managers(
+ columns,
+ domain_info,
+ should_update_columns,
+ dms_total,
+ dict_domain_invitations_with_invited_status,
+ dict_user_domain_roles,
+ )
try:
- row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers)
+ row = parse_row_for_domain(
+ columns,
+ domain_info,
+ dict_security_emails,
+ should_get_domain_managers,
+ dict_domain_invitations_with_invited_status,
+ dict_user_domain_roles,
+ )
rows.append(row)
except ValueError:
- # This should not happen. If it does, just skip this row.
- # It indicates that DomainInformation.domain is None.
logger.error("csv_export -> Error when parsing row, domain was None")
continue
total_body_rows.extend(rows)
@@ -208,7 +310,7 @@ def get_requests(filter_condition, sort_fields):
return requests
-def parse_request_row(columns, request: DomainRequest):
+def parse_row_for_requests(columns, request: DomainRequest):
"""Given a set of columns, generate a new row from cleaned column data"""
requested_domain_name = "No requested domain"
@@ -240,7 +342,7 @@ def parse_request_row(columns, request: DomainRequest):
return row
-def write_requests_csv(
+def write_csv_for_requests(
writer,
columns,
sort_fields,
@@ -261,7 +363,7 @@ def write_requests_csv(
rows = []
for request in page.object_list:
try:
- row = parse_request_row(columns, request)
+ row = parse_row_for_requests(columns, request)
rows.append(row)
except ValueError:
# This should not happen. If it does, just skip this row.
@@ -309,8 +411,8 @@ def export_data_type_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
+ write_csv_for_domains(
+ writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True
)
@@ -342,8 +444,8 @@ def export_data_full_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
@@ -376,8 +478,8 @@ def export_data_federal_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
@@ -446,77 +548,42 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date):
"domain__deleted__gte": start_date_formatted,
}
- write_domains_csv(
- writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
+ write_csv_for_domains(
+ writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
)
- write_domains_csv(
+ write_csv_for_domains(
writer,
columns,
sort_fields_for_deleted_domains,
filter_condition_for_deleted_domains,
- get_domain_managers=False,
+ should_get_domain_managers=False,
should_write_header=False,
)
-def get_sliced_domains(filter_condition, distinct=False):
+def get_sliced_domains(filter_condition):
"""Get filtered domains counts sliced by org type and election office.
Pass distinct=True when filtering by permissions so we do not to count multiples
when a domain has more that one manager.
"""
- # Round trip 1: Get distinct domain names based on filter condition
- domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count()
-
- # Round trip 2: Get counts for other slices
- # This will require either 8 filterd and distinct DB round trips,
- # or 2 DB round trips plus iteration on domain_permissions for each domain
- if distinct:
- generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list(
- "domain_id", "generic_org_type"
- )
- # Initialize Counter to store counts for each generic_org_type
- generic_org_type_counts = Counter()
-
- # Keep track of domains already counted
- domains_counted = set()
-
- # Iterate over distinct domains
- for domain_id, generic_org_type in generic_org_types_query:
- # Check if the domain has already been counted
- if domain_id in domains_counted:
- continue
-
- # Get all permissions for the current domain
- domain_permissions = DomainInformation.objects.filter(domain_id=domain_id, **filter_condition).values_list(
- "domain__permissions", flat=True
- )
-
- # 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()
+ domains = DomainInformation.objects.all().filter(**filter_condition).distinct()
+ domains_count = domains.count()
+ federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
+ interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count()
+ state_or_territory = (
+ domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
+ )
+ tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
+ county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
+ city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
+ special_district = (
+ domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
+ )
+ school_district = (
+ domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
+ )
+ election_board = domains.filter(is_election_board=True).distinct().count()
return [
domains_count,
@@ -535,26 +602,23 @@ def get_sliced_domains(filter_condition, distinct=False):
def get_sliced_requests(filter_condition):
"""Get filtered requests counts sliced by org type and election office."""
- # Round trip 1: Get distinct requests based on filter condition
- requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count()
-
- # Round trip 2: Get counts for other slices
- generic_org_types_query = DomainRequest.objects.filter(**filter_condition).values_list(
- "generic_org_type", flat=True
+ requests = DomainRequest.objects.all().filter(**filter_condition).distinct()
+ requests_count = requests.count()
+ federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
+ interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count()
+ state_or_territory = (
+ requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
)
- generic_org_type_counts = Counter(generic_org_types_query)
-
- federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0)
- interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0)
- state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0)
- tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0)
- county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0)
- city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0)
- special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0)
- school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0)
-
- # Round trip 3
- election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count()
+ tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
+ county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
+ city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
+ special_district = (
+ requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
+ )
+ school_district = (
+ requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
+ )
+ election_board = requests.filter(is_election_board=True).distinct().count()
return [
requests_count,
@@ -588,7 +652,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted,
}
- managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date, True)
+ managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date)
writer.writerow(["MANAGED DOMAINS COUNTS AT START DATE"])
writer.writerow(
@@ -612,7 +676,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
- managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date, True)
+ managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date)
writer.writerow(["MANAGED DOMAINS COUNTS AT END DATE"])
writer.writerow(
@@ -632,12 +696,12 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
writer.writerow(managed_domains_sliced_at_end_date)
writer.writerow([])
- write_domains_csv(
+ write_csv_for_domains(
writer,
columns,
sort_fields,
filter_managed_domains_end_date,
- get_domain_managers=True,
+ should_get_domain_managers=True,
should_write_header=True,
)
@@ -661,7 +725,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted,
}
- unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True)
+ unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date)
writer.writerow(["UNMANAGED DOMAINS AT START DATE"])
writer.writerow(
@@ -685,7 +749,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
- unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True)
+ unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date)
writer.writerow(["UNMANAGED DOMAINS AT END DATE"])
writer.writerow(
@@ -705,12 +769,12 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
writer.writerow(unmanaged_domains_sliced_at_end_date)
writer.writerow([])
- write_domains_csv(
+ write_csv_for_domains(
writer,
columns,
sort_fields,
filter_unmanaged_domains_end_date,
- get_domain_managers=False,
+ should_get_domain_managers=False,
should_write_header=True,
)
@@ -741,4 +805,4 @@ def export_data_requests_growth_to_csv(csv_file, start_date, end_date):
"submission_date__gte": start_date_formatted,
}
- write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True)
+ write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)
diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py
index eba8423ed..01a8157f9 100644
--- a/src/registrar/views/admin_views.py
+++ b/src/registrar/views/admin_views.py
@@ -49,8 +49,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
- managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date, True)
- managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date, True)
+ managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date)
+ managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date)
filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True,
@@ -60,10 +60,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
- unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(
- filter_unmanaged_domains_start_date, True
- )
- unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True)
+ unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_unmanaged_domains_start_date)
+ unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date)
filter_ready_domains_start_date = {
"domain__state__in": [models.Domain.State.READY],