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