From 196ab6323b7c8a3d63821b35a971b567c18cde43 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:06:15 -0600 Subject: [PATCH 01/80] Add copy button --- src/registrar/admin.py | 19 ++++++- src/registrar/assets/sass/_theme/_admin.scss | 19 +++++++ src/registrar/templates/admin/fieldset.html | 49 +++++++++++++++++++ .../admin/includes/contact_fieldset.html | 48 ++++++++++++++++++ .../admin/public_contact_change_form.html | 8 +++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/registrar/templates/admin/fieldset.html create mode 100644 src/registrar/templates/django/admin/includes/contact_fieldset.html create mode 100644 src/registrar/templates/django/admin/public_contact_change_form.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ff8046da9..01ad5e960 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -640,6 +640,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"] + }, + ) + ] + + change_form_template = "django/admin/public_contact_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. @@ -1805,6 +1816,12 @@ class DraftDomainAdmin(ListHeaderAdmin): ordering = ["name"] +class PublicContactAdmin(ListHeaderAdmin): + """Custom PublicContact admin class.""" + + change_form_template = "django/admin/public_contact_change_form.html" + + class VerifiedByStaffAdmin(ListHeaderAdmin): list_display = ("email", "requestor", "truncated_notes", "created_at") search_fields = ["email"] @@ -1845,7 +1862,7 @@ admin.site.register(models.DraftDomain, DraftDomainAdmin) # 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/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index dc67bc8b6..6909f77c2 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -299,3 +299,22 @@ input.admin-confirm-button { display: contents !important; } } + +.admin-icon-group { + position: relative; + display: flex; + align-items: center; + + .usa-button__icon { + position: absolute; + right: 0; + top: 0; + height: 100%; + border: none; + background: transparent; + padding: 0 1rem; + margin: 0; + } +} + + diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html new file mode 100644 index 000000000..dadae5811 --- /dev/null +++ b/src/registrar/templates/admin/fieldset.html @@ -0,0 +1,49 @@ +{% load i18n static %} + +{% comment %} +This is copied from Djangos implementation of this template, with added "blocks" +It is not inherently customizable on its own, so we can modify this instead. +https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html +{% endcomment %} +
+ {% block fieldset_title %} + {% if fieldset.name %}

{{ fieldset.name }}

{% endif %} + {% endblock fieldset_title %} + + {% block fieldset_description %} + {% if fieldset.description %} +
{{ fieldset.description|safe }}
+ {% endif %} + {% endblock fieldset_description %} + + {% block fieldset_lines %} + {% for line in fieldset %} +
+ {% if line.fields|length == 1 %}{{ line.errors }}{% else %}
{% endif %} + {% for field in line %} +
+ {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} +
+ {% if field.is_checkbox %} + {{ field.field }}{{ field.label_tag }} + {% else %} + {{ field.label_tag }} + {% if field.is_readonly %} +
{{ field.contents }}
+ {% else %} + {{ field.field }} + {% endif %} + {% endif %} +
+ {% if field.field.help_text %} +
+
{{ field.field.help_text|safe }}
+
+ {% endif %} +
+ {% endfor %} + {% if not line.fields|length == 1 %}
{% endif %} +
+ {% endfor %} + {% endblock fieldset_lines %} +
\ No newline at end of file diff --git a/src/registrar/templates/django/admin/includes/contact_fieldset.html b/src/registrar/templates/django/admin/includes/contact_fieldset.html new file mode 100644 index 000000000..c2a10f4fd --- /dev/null +++ b/src/registrar/templates/django/admin/includes/contact_fieldset.html @@ -0,0 +1,48 @@ +{% extends "admin/fieldset.html" %} +{% load i18n static %} + +{% comment %} +This is using a custom implementation fieldset.html (see admin/fieldset.html) +{% endcomment %} +{% block fieldset_lines %} +{% for line in fieldset %} +
+ {% if line.fields|length == 1 %}{{ line.errors }}{% else %}
{% endif %} + {% for field in line %} +
+ {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} +
+ {% if field.is_checkbox %} + {{ field.field }}{{ field.label_tag }} + {% else %} + {{ field.label_tag }} + {% if field.is_readonly %} +
{{ field.contents }}
+ {% elif field.field.name == "email" %} +
+ {{ field.field }} + +
+ {% else %} + {{ field.field }} + {% endif %} + {% endif %} +
+ {% if field.field.help_text %} +
+
{{ field.field.help_text|safe }}
+
+ {% endif %} +
+ {% endfor %} + {% if not line.fields|length == 1 %}
{% endif %} +
+{% endfor %} + +{% endblock fieldset_lines %} diff --git a/src/registrar/templates/django/admin/public_contact_change_form.html b/src/registrar/templates/django/admin/public_contact_change_form.html new file mode 100644 index 000000000..181201d0b --- /dev/null +++ b/src/registrar/templates/django/admin/public_contact_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/contact_fieldset.html" %} + {% endfor %} +{% endblock %} From 44c4e2ef26ca07f8e576ab6bca7ff5783e8431cb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:46:29 -0600 Subject: [PATCH 02/80] Add checkbox --- src/registrar/assets/js/get-gov-admin.js | 42 +++++++++++++++++++ .../admin/includes/contact_fieldset.html | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index ff73acb65..df748c840 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -76,6 +76,48 @@ function openInNewTab(el, removeAttribute = false){ prepareDjangoAdmin(); })(); +/** An IIFE for pages in DjangoAdmin that use a clipboard button +*/ +(function (){ + function copyToClipboardAndChangeIcon(button) { + // Assuming the input is the previous sibling of the button + let input = button.previousElementSibling; + + // 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'); + setTimeout(function() { + // Change back to the copy icon + buttonIcon.setAttribute('xlink:href', currentHref); + }, 2000); + } + + }).catch(function(error) { + console.error('Clipboard copy failed', error); + }); + } + } + + function handleClipboardButtons() { + clipboardButtons = document.querySelectorAll(".usa-button__clipboard") + clipboardButtons.forEach((button) => { + button.addEventListener("click", ()=>{copyToClipboardAndChangeIcon(button)}); + }); + } + + handleClipboardButtons(); + +})(); + + /** * 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/templates/django/admin/includes/contact_fieldset.html b/src/registrar/templates/django/admin/includes/contact_fieldset.html index c2a10f4fd..90e8f911f 100644 --- a/src/registrar/templates/django/admin/includes/contact_fieldset.html +++ b/src/registrar/templates/django/admin/includes/contact_fieldset.html @@ -21,7 +21,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "email" %}
{{ field.field }} - +
\ No newline at end of file diff --git a/src/registrar/templates/django/admin/includes/contact_fieldset.html b/src/registrar/templates/django/admin/includes/contact_fieldset.html index 90e8f911f..ce8777420 100644 --- a/src/registrar/templates/django/admin/includes/contact_fieldset.html +++ b/src/registrar/templates/django/admin/includes/contact_fieldset.html @@ -1,5 +1,4 @@ {% extends "admin/fieldset.html" %} -{% load i18n static %} {% comment %} This is using a custom implementation fieldset.html (see admin/fieldset.html) @@ -19,16 +18,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if field.is_readonly %}
{{ field.contents }}
{% elif field.field.name == "email" %} -
- {{ field.field }} - -
+ {% include "admin/input_with_clipboard.html" with field=field.field %} {% else %} {{ field.field }} {% endif %} From 48a4278240ec7d5581a68da7ddc436a362460ab2 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 15 Mar 2024 10:22:17 -0600 Subject: [PATCH 05/80] Add emails to other pages --- src/registrar/admin.py | 11 +++++++++-- ...nge_form.html => email_clipboard_change_form.html} | 2 +- ...ct_fieldset.html => email_clipboard_fieldset.html} | 0 3 files changed, 10 insertions(+), 3 deletions(-) rename src/registrar/templates/django/admin/{public_contact_change_form.html => email_clipboard_change_form.html} (66%) rename src/registrar/templates/django/admin/includes/{contact_fieldset.html => email_clipboard_fieldset.html} (100%) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 01ad5e960..e86b9f282 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -649,7 +649,9 @@ class ContactAdmin(ListHeaderAdmin): ) ] - change_form_template = "django/admin/public_contact_change_form.html" + 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 @@ -832,6 +834,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.""" @@ -1327,6 +1331,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. @@ -1819,7 +1825,8 @@ class DraftDomainAdmin(ListHeaderAdmin): class PublicContactAdmin(ListHeaderAdmin): """Custom PublicContact admin class.""" - change_form_template = "django/admin/public_contact_change_form.html" + change_form_template = "django/admin/email_clipboard_change_form.html" + autocomplete_fields = ["domain"] class VerifiedByStaffAdmin(ListHeaderAdmin): diff --git a/src/registrar/templates/django/admin/public_contact_change_form.html b/src/registrar/templates/django/admin/email_clipboard_change_form.html similarity index 66% rename from src/registrar/templates/django/admin/public_contact_change_form.html rename to src/registrar/templates/django/admin/email_clipboard_change_form.html index 181201d0b..15cb11841 100644 --- a/src/registrar/templates/django/admin/public_contact_change_form.html +++ b/src/registrar/templates/django/admin/email_clipboard_change_form.html @@ -3,6 +3,6 @@ {% block field_sets %} {% for fieldset in adminform %} - {% include "django/admin/includes/contact_fieldset.html" %} + {% include "django/admin/includes/email_clipboard_fieldset.html" %} {% endfor %} {% endblock %} diff --git a/src/registrar/templates/django/admin/includes/contact_fieldset.html b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html similarity index 100% rename from src/registrar/templates/django/admin/includes/contact_fieldset.html rename to src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html From 438df069d646982776f87640f442542a393dc5b8 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 20 Mar 2024 15:10:08 -0600 Subject: [PATCH 06/80] WIP for new copy fields --- .../includes/domain_request_detail_table.html | 27 +++++++++++++++---- .../includes/domain_request_fieldset.html | 16 ++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/registrar/templates/django/admin/includes/domain_request_detail_table.html b/src/registrar/templates/django/admin/includes/domain_request_detail_table.html index c25812b4d..32c8aa49a 100644 --- a/src/registrar/templates/django/admin/includes/domain_request_detail_table.html +++ b/src/registrar/templates/django/admin/includes/domain_request_detail_table.html @@ -17,11 +17,28 @@ Email {% if user.email or user.contact.email %} - {% if user.contact.email %} - {{ user.contact.email }} - {% else %} - {{ user.email }} - {% endif %} + + + {% if user.contact.email %} + {{ user.contact.email }} + + {% else %} + {{ user.email }} + + {% endif %} + + + + {% else %} Nothing found {% endif %} diff --git a/src/registrar/templates/django/admin/includes/domain_request_fieldset.html b/src/registrar/templates/django/admin/includes/domain_request_fieldset.html index 980fe0bfd..11e1e21c6 100644 --- a/src/registrar/templates/django/admin/includes/domain_request_fieldset.html +++ b/src/registrar/templates/django/admin/includes/domain_request_fieldset.html @@ -62,7 +62,21 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {{contact.first_name}} {{contact.last_name}} {{ contact.title }} - {{ contact.email }} + + + + {{ contact.email }} + + {{ contact.phone }} {% endfor %} From 2931900d57a914e04969d75365cc4ac2dd09d7fc Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:07:33 -0600 Subject: [PATCH 07/80] Unit test, styling --- src/registrar/assets/js/get-gov-admin.js | 5 ++- src/registrar/assets/sass/_theme/_admin.scss | 15 ++++--- .../includes/domain_request_detail_table.html | 42 +++++++++++-------- .../includes/domain_request_fieldset.html | 13 +++--- src/registrar/tests/test_admin.py | 21 ++++++---- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b78362dc6..d4b309a4a 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -177,9 +177,10 @@ function openInNewTab(el, removeAttribute = false){ copyToClipboardAndChangeIcon(button); }); - // Add a class that removes the outline style on click + // Add a class that adds the outline style on click button.addEventListener("mousedown", function() { - this.classList.remove("no-outline-on-click"); + console.log(`applying mousedown on ${this} vs ${button}`); + this.classList.add("no-outline-on-click"); }); // But add it back in after the user clicked, diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 0a29eedff..a4a9fca54 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -381,14 +381,19 @@ table.dja-user-detail-table { input { // Allow for padding around the copy button - padding-right: 35px; + padding-right: 35px !important; // Match the height of other inputs - min-height: 2.25rem; + min-height: 2.25rem !important; } - .no-outline-on-click:focus { - outline: none !important; +} + +td.font-size-sm { + button.usa-button__icon { + font-size: 16px; } } - +.no-outline-on-click:focus { + outline: none !important; +} diff --git a/src/registrar/templates/django/admin/includes/domain_request_detail_table.html b/src/registrar/templates/django/admin/includes/domain_request_detail_table.html index 32c8aa49a..b1afd6a68 100644 --- a/src/registrar/templates/django/admin/includes/domain_request_detail_table.html +++ b/src/registrar/templates/django/admin/includes/domain_request_detail_table.html @@ -13,34 +13,39 @@ {% else %} Nothing found {% endif %} + {# Placeholder col for actions (like copy) or additional padding #} + Email {% if user.email or user.contact.email %} + {% if user.contact.email %} + {{ user.contact.email }} + {% else %} + {{ user.email }} + {% endif %} + - - {% if user.contact.email %} - {{ user.contact.email }} - - {% else %} - {{ user.email }} - - {% endif %} - - + + + {% else %} Nothing found + {% endif %} @@ -54,6 +59,7 @@ {% else %} Nothing found {% endif %} + diff --git a/src/registrar/templates/django/admin/includes/domain_request_fieldset.html b/src/registrar/templates/django/admin/includes/domain_request_fieldset.html index 11e1e21c6..976969667 100644 --- a/src/registrar/templates/django/admin/includes/domain_request_fieldset.html +++ b/src/registrar/templates/django/admin/includes/domain_request_fieldset.html @@ -62,10 +62,13 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {{contact.first_name}} {{contact.last_name}} {{ contact.title }} - - + {{ contact.email }} + {{ contact.phone }} + {# Copy button for the email #} + + - {{ contact.email }} - - {{ contact.phone }} {% endfor %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index b29776f81..7bcfc388f 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1304,18 +1304,19 @@ 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. # We only need to check for the end tag # (Otherwise this test will fail if we change classes, etc) + expected_email = "meoward.jones@igorville.gov" expected_creator_fields = [ # Field, expected value ("title", "Treat inspector"), - ("email", "meoward.jones@igorville.gov"), + ("email", f"{expected_email}"), + # Check for the existence of the copy button input. + # Lets keep things simple to minimize future conflicts. + ("email_copy_button_input", f'"), ] self.assert_response_contains_distinct_values(response, expected_creator_fields) @@ -1324,20 +1325,24 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "Meoward Jones") # == Check for the submitter == # + expected_email = "mayor@igorville.gov" expected_submitter_fields = [ # Field, expected value ("title", "Admin Tester"), - ("email", "mayor@igorville.gov"), + ("email", f"{expected_email}"), + ("email_copy_button_input", f'"), ] self.assert_response_contains_distinct_values(response, expected_submitter_fields) self.assertContains(response, "Testy2 Tester2") # == Check for the authorizing_official == # + expected_email = "testy@town.com" expected_ao_fields = [ # Field, expected value ("title", "Chief Tester"), - ("email", "testy@town.com"), + ("email", f"{expected_email}"), + ("email_copy_button_input", f'"), ] self.assert_response_contains_distinct_values(response, expected_ao_fields) @@ -1359,10 +1364,12 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "Phone", count=3) # == Test the other_employees field == # + expected_email = "testy@town.com" expected_other_employees_fields = [ # Field, expected value ("title", "Another Tester"), - ("email", "testy2@town.com"), + ("email", f"{expected_email}"), + ("email_copy_button_input", f'"), ] self.assert_response_contains_distinct_values(response, expected_other_employees_fields) From 54072a479e8699b8488472a9b7c21b1185d22a5c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:49:09 -0600 Subject: [PATCH 08/80] Update detail_table_fieldset.html --- .../admin/includes/detail_table_fieldset.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 f5a5b71ee..3d9d038d6 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -51,6 +51,21 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {{ contact.title }} {{ contact.email }} {{ contact.phone }} + {# Copy button for the email #} + + + + {% endfor %} From be07a06a15bc71fcb75ebf10ef2d51ea74df0bf6 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:14:16 -0600 Subject: [PATCH 09/80] Add additional copy buttons --- src/registrar/admin.py | 4 ++ .../includes/email_clipboard_fieldset.html | 41 ++++--------------- src/registrar/tests/common.py | 3 +- src/registrar/tests/test_admin.py | 16 ++++++-- 4 files changed, 26 insertions(+), 38 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4a2a4515a..61409c31f 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -536,6 +536,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, @@ -1808,6 +1810,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] diff --git a/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html index ce8777420..f959f8edf 100644 --- a/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html +++ b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html @@ -1,38 +1,13 @@ -{% extends "admin/fieldset.html" %} +{% extends "django/admin/includes/detail_table_fieldset.html" %} {% comment %} This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endcomment %} -{% block fieldset_lines %} -{% for line in fieldset %} -
- {% if line.fields|length == 1 %}{{ line.errors }}{% else %}
{% endif %} - {% for field in line %} -
- {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} -
- {% if field.is_checkbox %} - {{ field.field }}{{ field.label_tag }} - {% else %} - {{ field.label_tag }} - {% if field.is_readonly %} -
{{ field.contents }}
- {% elif field.field.name == "email" %} - {% include "admin/input_with_clipboard.html" with field=field.field %} - {% else %} - {{ field.field }} - {% endif %} - {% endif %} -
- {% if field.field.help_text %} -
-
{{ field.field.help_text|safe }}
-
- {% endif %} -
- {% endfor %} - {% if not line.fields|length == 1 %}
{% endif %} -
-{% endfor %} -{% endblock fieldset_lines %} +{% 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/tests/common.py b/src/registrar/tests/common.py index 655fda02b..afc30a16f 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",) ) ``` @@ -204,7 +204,6 @@ class GenericTestHelper(TestCase): {"action": "delete_selected", "select_across": selected_across, "index": index, "_selected_action": "23"}, follow=True, ) - print(f"what is the response? {response}") return response diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index c69759fb2..7e42cb0d5 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2022,10 +2022,14 @@ class TestDomainInformationAdmin(TestCase): # Check for the right title, email, and phone number in the response. # We only need to check for the end tag # (Otherwise this test will fail if we change classes, etc) + expected_email = "meoward.jones@igorville.gov" expected_creator_fields = [ # Field, expected value ("title", "Treat inspector"), - ("email", "meoward.jones@igorville.gov"), + ("email", f"{expected_email}"), + # Check for the existence of the copy button input. + # Lets keep things simple to minimize future conflicts. + ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) @@ -2034,20 +2038,24 @@ class TestDomainInformationAdmin(TestCase): self.assertContains(response, "Meoward Jones") # == Check for the submitter == # + expected_email = "mayor@igorville.gov" expected_submitter_fields = [ # Field, expected value ("title", "Admin Tester"), - ("email", "mayor@igorville.gov"), + ("email", f"{expected_email}"), + ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) self.assertContains(response, "Testy2 Tester2") # == Check for the authorizing_official == # + expected_email = "testy@town.com" expected_ao_fields = [ # Field, expected value ("title", "Chief Tester"), ("email", "testy@town.com"), + ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields) @@ -2069,10 +2077,12 @@ class TestDomainInformationAdmin(TestCase): self.assertContains(response, "Phone", count=3) # == Test the other_employees field == # + expected_email = "testy@town.com" expected_other_employees_fields = [ # Field, expected value ("title", "Another Tester"), - ("email", "testy2@town.com"), + ("email", f"{expected_email}"), + ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) From 058955100a81120aed1bcd1e668998b39463a62d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:21:55 -0600 Subject: [PATCH 10/80] Linting --- src/registrar/admin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 61409c31f..f18fdd26a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -645,9 +645,7 @@ class ContactAdmin(ListHeaderAdmin): fieldsets = [ ( None, - { - "fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"] - }, + {"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]}, ) ] From 44487b40b2116b8bde626a7f6785c678a14de605 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 27 Mar 2024 17:38:22 -0400 Subject: [PATCH 11/80] wip --- src/registrar/utility/csv_export.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 7fc710827..22d2fc46d 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -3,6 +3,7 @@ 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 @@ -33,7 +34,7 @@ def get_domain_infos(filter_condition, sort_fields): """ domain_infos = ( DomainInformation.objects.select_related("domain", "authorizing_official") - .prefetch_related("domain__permissions") + .prefetch_related("domain__permissions", "domain__invitations") .filter(**filter_condition) .order_by(*sort_fields) .distinct() @@ -154,6 +155,10 @@ def write_domains_csv( all_domain_infos = get_domain_infos(filter_condition, sort_fields) + # td_agencies = all_domain_infos.filter(domain__invitation__status='invited').annotate(invitations_count=Count('invitations')).values_list('domain_name', 'invitations_count').distinct() + # Create a dictionary mapping of domain_name to federal_agency + # td_dict = dict(td_agencies) + # 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) @@ -164,6 +169,8 @@ def write_domains_csv( # The maximum amount of domain managers an account has # We get the max so we can set the column header accurately + max_dm_active = 0 + max_dm_invited = 0 max_dm_count = 0 total_body_rows = [] @@ -172,15 +179,26 @@ def write_domains_csv( page = paginator.page(page_num) for domain_info in page.object_list: - # Get count of all the domain managers for an account + # Get max number of domain managers if get_domain_managers: - dm_count = domain_info.domain.permissions.count() - if dm_count > max_dm_count: - max_dm_count = dm_count + dm_active = domain_info.domain.permissions.count() + if dm_active > max_dm_active: + max_dm_active = dm_active + + # Now let's get the domain managers who have not retrieved their invite yet + # Let's get the max number of whose + dm_invited = domain_info.domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).count() + if dm_invited > max_dm_invited: + max_dm_invited = dm_invited + + if dm_active > max_dm_active or dm_invited > max_dm_invited: + max_dm_count = max_dm_active + max_dm_invited for i in range(1, max_dm_count + 1): column_name = f"Domain manager email {i}" + column2_name = f"DM{i} status" if column_name not in columns: columns.append(column_name) + columns.append(column2_name) try: row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) From 15e99af5f03680da839ae1017c9caf555a81285c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:37:47 -0600 Subject: [PATCH 12/80] Add copy button --- src/registrar/assets/js/get-gov-admin.js | 19 +++++- src/registrar/assets/sass/_theme/_admin.scss | 21 ++++-- .../admin/includes/contact_detail_list.html | 9 +++ .../admin/includes/contact_detail_table.html | 65 ------------------- .../admin/includes/detail_table_fieldset.html | 12 +++- 5 files changed, 55 insertions(+), 71 deletions(-) delete mode 100644 src/registrar/templates/django/admin/includes/contact_detail_table.html diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 19f6b7dbd..2ac16d919 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -140,6 +140,12 @@ function openInNewTab(el, removeAttribute = false){ /** 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; @@ -179,7 +185,6 @@ function openInNewTab(el, removeAttribute = false){ // Add a class that adds the outline style on click button.addEventListener("mousedown", function() { - console.log(`applying mousedown on ${this} vs ${button}`); this.classList.add("no-outline-on-click"); }); @@ -192,7 +197,19 @@ function openInNewTab(el, removeAttribute = false){ }); } + function handleClipboardLinks() { + let emailButtons = document.querySelectorAll(".usa-button__clipboard-link"); + if (emailButtons){ + emailButtons.forEach((button) => { + button.addEventListener("click", ()=>{ + copyInnerTextToClipboard(button); + }) + }); + } + } + handleClipboardButtons(); + handleClipboardLinks(); })(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index c777ef291..d1e000033 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -404,6 +404,13 @@ address.margin-top-neg-1__detail-list { address.dja-address-contact-list { font-size: 0.8125rem; color: var(--body-quiet-color); + button.usa-button__clipboard-link { + font-size: 0.8125rem !important; + } +} + +td button.usa-button__clipboard-link { + font-size: 0.8125rem !important; } // Mimic the normal label size @@ -416,6 +423,14 @@ address.dja-address-contact-list { font-size: 0.875rem; color: var(--body-quiet-color); } + + address button.usa-button__clipboard-link { + font-size: 0.875rem !important; + } + + td button.usa-button__clipboard-link { + font-size: 0.875rem !important; + } } .errors span.select2-selection { @@ -442,8 +457,6 @@ address.dja-address-contact-list { } -td.font-size-sm { - button.usa-button__icon { - font-size: 16px; - } +.no-outline-on-click:focus { + outline: none !important; } \ No newline at end of file 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..cded7526b 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -25,9 +25,18 @@ {# Email #} {% if user.email or user.contact.email %} {% if user.contact.email %} + {% else %} + {% endif %}
{% else %} diff --git a/src/registrar/templates/django/admin/includes/contact_detail_table.html b/src/registrar/templates/django/admin/includes/contact_detail_table.html deleted file mode 100644 index b1afd6a68..000000000 --- a/src/registrar/templates/django/admin/includes/contact_detail_table.html +++ /dev/null @@ -1,65 +0,0 @@ -{% load i18n static %} - - - - - - {% if user.title or user.contact.title %} - {% if user.contact.title %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - {# Placeholder col for actions (like copy) or additional padding #} - - - - - {% if user.email or user.contact.email %} - {% if user.contact.email %} - - {% else %} - - {% endif %} - - - - {% else %} - - - {% endif %} - - - - {% if user.phone or user.contact.phone %} - {% if user.contact.phone %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - - - -
Title{{ user.contact.title }}{{ user.title }}Nothing found
Email{{ user.contact.email }}{{ user.email }} - {% if user.contact.email %} - - {% else %} - - {% endif %} - - Nothing found
Phone{{ user.contact.phone }}{{ user.phone }}Nothing found
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..53bdbe821 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -92,7 +92,17 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {{ contact.get_formatted_name }} {{ contact.title }} - {{ contact.email }} + + + {{ contact.phone }} {% endfor %} From cf2bf5dfaf3aed03427f4c3af0df7cd1b3d29322 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Thu, 28 Mar 2024 16:00:24 -0700 Subject: [PATCH 13/80] Update production alert to use standard emergency site alert --- .../templates/includes/non-production-alert.html | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html index 8e40892bc..aa8d3f8bb 100644 --- a/src/registrar/templates/includes/non-production-alert.html +++ b/src/registrar/templates/includes/non-production-alert.html @@ -1,5 +1,9 @@ -
-
- Attention: You are on a test site. +
+
+
+ Attention: You are on a test site. +
-
+
\ No newline at end of file From 03b9eb914cc8b620881d0dc374305129043ac201 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Thu, 28 Mar 2024 16:01:11 -0700 Subject: [PATCH 14/80] Update TEST ENV not prod to standard emergency site alert --- src/registrar/templates/includes/non-production-alert.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html index aa8d3f8bb..911eea9d6 100644 --- a/src/registrar/templates/includes/non-production-alert.html +++ b/src/registrar/templates/includes/non-production-alert.html @@ -1,5 +1,4 @@ -
From 5cd70dad349ffafc9154a1dea8fcf8cf648a5c8e Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 28 Mar 2024 19:03:00 -0400 Subject: [PATCH 15/80] add invited DMs to reports --- src/registrar/tests/common.py | 19 ++++++++++ src/registrar/tests/test_reports.py | 35 +++++++++++------- src/registrar/utility/csv_export.py | 55 ++++++++++++++++++----------- 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 87af54669..a4d16387e 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -669,6 +669,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" @@ -698,6 +716,7 @@ class MockDb(TestCase): DomainRequest.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() + DomainInvitation.objects.all().delete() def mock_user(): diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 5bd594a15..cd882c4f8 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -478,7 +478,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 @@ -521,14 +526,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 +545,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 @@ -564,10 +573,12 @@ class ExportDataTest(MockDb, MockEppLib): "Special district,School district,Election office\n" "3,2,1,0,0,0,0,0,0,2\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, diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 22d2fc46d..d0f81fc45 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -105,12 +105,22 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di } if get_domain_managers: - # Get each domain managers email and add to list - dm_emails = [dm.user.email for dm in domain.permissions.all()] + # Get lists of emails for active and invited domain managers + dm_active_emails = [dm.user.email for dm in domain.permissions.all()] + dm_invited_emails = [ + invite.email for invite in domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED) + ] - # 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 + # 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(dm_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(dm_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 @@ -158,7 +168,7 @@ def write_domains_csv( # td_agencies = all_domain_infos.filter(domain__invitation__status='invited').annotate(invitations_count=Count('invitations')).values_list('domain_name', 'invitations_count').distinct() # Create a dictionary mapping of domain_name to federal_agency # td_dict = dict(td_agencies) - + # 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) @@ -167,11 +177,15 @@ def write_domains_csv( # Reduce the memory overhead when performing the write operation 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 + # We get the number of domain managers (DMs) an the domain + # that has the most DMs so we can set the header row appropriately max_dm_active = 0 max_dm_invited = 0 - max_dm_count = 0 + max_dm_total = 0 + update_columns = False + + # This var will live outside of the nested for loops to aggregate + # the data from those loops total_body_rows = [] for page_num in paginator.page_range: @@ -182,23 +196,24 @@ def write_domains_csv( # Get max number of domain managers if get_domain_managers: dm_active = domain_info.domain.permissions.count() - if dm_active > max_dm_active: - max_dm_active = dm_active - - # Now let's get the domain managers who have not retrieved their invite yet - # Let's get the max number of whose - dm_invited = domain_info.domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).count() - if dm_invited > max_dm_invited: - max_dm_invited = dm_invited + dm_invited = domain_info.domain.invitations.filter( + status=DomainInvitation.DomainInvitationStatus.INVITED + ).count() if dm_active > max_dm_active or dm_invited > max_dm_invited: - max_dm_count = max_dm_active + max_dm_invited - for i in range(1, max_dm_count + 1): - column_name = f"Domain manager email {i}" + max_dm_active = max(dm_active, max_dm_active) + max_dm_invited = max(dm_invited, max_dm_invited) + max_dm_total = max_dm_active + max_dm_invited + update_columns = True + + if update_columns: + for i in range(1, max_dm_total + 1): + column_name = f"Domain manager {i}" column2_name = f"DM{i} status" if column_name not in columns: columns.append(column_name) columns.append(column2_name) + update_columns = False try: row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) From 7aefce86db56e97fe6a3adb4053b1f8608d16d9d Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 28 Mar 2024 19:33:19 -0400 Subject: [PATCH 16/80] Lint --- src/registrar/utility/csv_export.py | 63 ++++++++++++++++++----------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index d0f81fc45..98e3786c5 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -148,6 +148,41 @@ def _get_security_emails(sec_contact_ids): return security_emails_dict +def update_columns_with_domain_managers( + domain_info, update_columns, columns, max_dm_active, max_dm_invited, max_dm_total +): + """Helper function that works with 'global' variables set in write_domains_csv + Accepts: + domain_info -> Domains to parse + update_columns -> A control to make sure we only run the columns test and update when needed + columns -> The header cells in the csv that's under construction + max_dm_active -> Starts at 0 and gets updated and passed again through this method + max_dm_invited -> Starts at 0 and gets updated and passed again through this method + max_dm_total -> Starts at 0 and gets updated and passed again through this method + Returns: + Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" + + dm_active = domain_info.domain.permissions.count() + dm_invited = domain_info.domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).count() + + if dm_active > max_dm_active or dm_invited > max_dm_invited: + max_dm_active = max(dm_active, max_dm_active) + max_dm_invited = max(dm_invited, max_dm_invited) + max_dm_total = max_dm_active + max_dm_invited + update_columns = True + + if update_columns: + for i in range(1, max_dm_total + 1): + column_name = f"Domain manager {i}" + column2_name = f"DM{i} status" + if column_name not in columns: + columns.append(column_name) + columns.append(column2_name) + update_columns = False + + return update_columns, columns, max_dm_active, max_dm_invited, max_dm_total + + def write_domains_csv( writer, columns, @@ -165,10 +200,6 @@ def write_domains_csv( all_domain_infos = get_domain_infos(filter_condition, sort_fields) - # td_agencies = all_domain_infos.filter(domain__invitation__status='invited').annotate(invitations_count=Count('invitations')).values_list('domain_name', 'invitations_count').distinct() - # Create a dictionary mapping of domain_name to federal_agency - # td_dict = dict(td_agencies) - # 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) @@ -195,25 +226,11 @@ def write_domains_csv( # Get max number of domain managers if get_domain_managers: - dm_active = domain_info.domain.permissions.count() - dm_invited = domain_info.domain.invitations.filter( - status=DomainInvitation.DomainInvitationStatus.INVITED - ).count() - - if dm_active > max_dm_active or dm_invited > max_dm_invited: - max_dm_active = max(dm_active, max_dm_active) - max_dm_invited = max(dm_invited, max_dm_invited) - max_dm_total = max_dm_active + max_dm_invited - update_columns = True - - if update_columns: - for i in range(1, max_dm_total + 1): - column_name = f"Domain manager {i}" - column2_name = f"DM{i} status" - if column_name not in columns: - columns.append(column_name) - columns.append(column2_name) - update_columns = False + update_columns, columns, max_dm_active, max_dm_invited, max_dm_total = ( + update_columns_with_domain_managers( + domain_info, update_columns, columns, max_dm_active, max_dm_invited, max_dm_total + ) + ) try: row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) From 63953a2471619b97a2c1ab54df622fada0fdd394 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 29 Mar 2024 14:40:27 -0400 Subject: [PATCH 17/80] tweak update_columns_with_domain_managers --- src/registrar/utility/csv_export.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 98e3786c5..5db51b539 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -165,13 +165,16 @@ def update_columns_with_domain_managers( dm_active = domain_info.domain.permissions.count() dm_invited = domain_info.domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).count() - if dm_active > max_dm_active or dm_invited > max_dm_invited: + if dm_active > max_dm_active: max_dm_active = max(dm_active, max_dm_active) + update_columns = True + + if dm_invited > max_dm_invited: max_dm_invited = max(dm_invited, max_dm_invited) - max_dm_total = max_dm_active + max_dm_invited update_columns = True if update_columns: + max_dm_total = max_dm_active + max_dm_invited for i in range(1, max_dm_total + 1): column_name = f"Domain manager {i}" column2_name = f"DM{i} status" From ed8535b695cbb0d281a74ac07ac1fcebd45bd86f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 29 Mar 2024 13:06:53 -0600 Subject: [PATCH 18/80] Add signals --- src/registrar/admin.py | 3 +- src/registrar/fixtures_domain_requests.py | 5 +- .../0081_domainrequest_organization_type.py | 38 ++++++ src/registrar/models/domain.py | 2 +- src/registrar/models/domain_request.py | 45 ++++++- src/registrar/signals.py | 111 +++++++++++++++++- 6 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 src/registrar/migrations/0081_domainrequest_organization_type.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e58251743..d179d5549 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1095,8 +1095,7 @@ class DomainRequestAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ - "generic_org_type", - "is_election_board", + "organization_type", "federal_type", "federal_agency", "tribe_name", 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/0081_domainrequest_organization_type.py b/src/registrar/migrations/0081_domainrequest_organization_type.py new file mode 100644 index 000000000..1d2185fbb --- /dev/null +++ b/src/registrar/migrations/0081_domainrequest_organization_type.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.10 on 2024-03-29 15:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0080_create_groups_v09"), + ] + + operations = [ + 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, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 8fc697df5..b3d5b19ce 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -198,7 +198,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" - + return True 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_request.py b/src/registrar/models/domain_request.py index f4581de93..2b08bf1d0 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -100,8 +100,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 request experience + Keys need to match OrganizationChoicesElectionOffice and OrganizationChoicesVerbose """ FEDERAL = "federal", "Federal" @@ -113,9 +113,38 @@ class DomainRequest(TimeStampedModel): SPECIAL_DISTRICT = "special_district", "Special district" SCHOOL_DISTRICT = "school_district", "School district" + class OrganizationChoicesElectionOffice(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" + 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 +435,14 @@ class DomainRequest(TimeStampedModel): help_text="Type of organization", ) + organization_type = models.CharField( + max_length=255, + choices=OrganizationChoicesElectionOffice.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", @@ -449,6 +486,7 @@ class DomainRequest(TimeStampedModel): help_text="Organization name", db_index=True, ) + address_line1 = models.CharField( null=True, blank=True, @@ -525,6 +563,7 @@ class DomainRequest(TimeStampedModel): related_name="domain_request", on_delete=models.PROTECT, ) + alternative_domains = models.ManyToManyField( "registrar.Website", blank=True, diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 4e7768ef4..e44e53ace 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -1,14 +1,121 @@ import logging -from django.db.models.signals import post_save +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver -from .models import User, Contact +from .models import User, Contact, DomainRequest logger = logging.getLogger(__name__) +@receiver(pre_save, sender=DomainRequest) +def create_or_update_organization_type(sender, instance, **kwargs): + """The organization_type field on DomainRequest 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. + """ + if not isinstance(instance, DomainRequest): + # I don't see how this could possibly happen - but its still a good check to have. + # Lets force a fail condition rather than wait for one to happen, if this occurs. + raise ValueError("Type mismatch. The instance was not DomainRequest.") + + # == Init variables == # + # We can't grab the election variant if it is in federal, interstate, or school_district. + # The "election variant" is just the org name, with " - Election" appended to the end. + # For example, "School district - Election". + invalid_types = [ + DomainRequest.OrganizationChoices.FEDERAL, + DomainRequest.OrganizationChoices.INTERSTATE, + DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, + ] + + # TODO - maybe we need a check here for .filter then .get + is_new_instance = instance.id is None + + # A new record is added with organization_type not defined. + # This happens from the regular domain request flow. + if is_new_instance: + + # == Check for invalid conditions before proceeding == # + if instance.organization_type and instance.generic_org_type: + # Since organization type is linked with generic_org_type and election board, + # we have to update one or the other, not both. + raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") + + # If no changes occurred, do nothing + if not instance.organization_type and not instance.generic_org_type: + 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 = instance.organization_type is None + generic_org_type_needs_update = instance.generic_org_type is None + + # Update that field + if organization_type_needs_update: + _update_org_type_from_generic_org_and_election(instance, invalid_types) + elif generic_org_type_needs_update: + _update_generic_org_and_election_from_org_type(instance) + else: + + # Instance is already in the database, fetch its current state + current_instance = DomainRequest.objects.get(id=instance.id) + + # Check the new and old values + generic_org_type_changed = instance.generic_org_type != current_instance.generic_org_type + is_election_board_changed = instance.is_election_board != current_instance.is_election_board + organization_type_changed = 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. + raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") + + # If no changes occured, do nothing + if not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed): + 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 that field + if organization_type_needs_update: + _update_org_type_from_generic_org_and_election(instance, invalid_types) + elif generic_org_type_needs_update: + _update_generic_org_and_election_from_org_type(instance) + +def _update_org_type_from_generic_org_and_election(instance, invalid_types): + # TODO handle if generic_org_type is None + if instance.generic_org_type not in invalid_types and instance.is_election_board: + instance.organization_type = f"{instance.generic_org_type}_election" + else: + instance.organization_type = str(instance.generic_org_type) + + +def _update_generic_org_and_election_from_org_type(instance): + """Given a value for organization_type, update the + generic_org_type and is_election_board values.""" + # TODO find a better solution than this + current_org_type = str(instance.organization_type) + if "_election" in current_org_type: + instance.generic_org_type = current_org_type.split("_election")[0] + instance.is_election_board = True + else: + instance.organization_type = str(instance.generic_org_type) + instance.is_election_board = False + @receiver(post_save, sender=User) def handle_profile(sender, instance, **kwargs): """Method for when a User is saved. From 94132bb9a27603c15a116fcd163bbbae30a80890 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:11:05 -0600 Subject: [PATCH 19/80] Update signals.py --- src/registrar/signals.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index e44e53ace..c7dc8821d 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -48,9 +48,8 @@ def create_or_update_organization_type(sender, instance, **kwargs): # Since organization type is linked with generic_org_type and election board, # we have to update one or the other, not both. raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") - - # If no changes occurred, do nothing - if not instance.organization_type and not instance.generic_org_type: + elif not instance.organization_type and not instance.generic_org_type: + # Do values to update - do nothing return None # == Program flow will halt here if there is no reason to update == # @@ -79,9 +78,8 @@ def create_or_update_organization_type(sender, instance, **kwargs): # Since organization type is linked with generic_org_type and election board, # we have to update one or the other, not both. raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") - - # If no changes occured, do nothing - if not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed): + elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed): + # Do values to update - do nothing return None # == Program flow will halt here if there is no reason to update == # From 8c8d7b6b63914234981b7a85200c547b7a3a4bcf Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 29 Mar 2024 16:32:51 -0400 Subject: [PATCH 20/80] Fix logic, optimize analytics page load --- src/registrar/assets/js/get-gov-reports.js | 4 +- src/registrar/templates/admin/analytics.html | 4 +- src/registrar/utility/csv_export.py | 146 +++++++------------ src/registrar/views/admin_views.py | 52 +++---- 4 files changed, 84 insertions(+), 122 deletions(-) diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js index da0781411..92bba4a1f 100644 --- a/src/registrar/assets/js/get-gov-reports.js +++ b/src/registrar/assets/js/get-gov-reports.js @@ -117,8 +117,8 @@ function initComparativeColumnCharts() { document.addEventListener("DOMContentLoaded", function () { - // createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date"); - // createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date"); createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date"); createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date"); createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date"); diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index 6812c919d..e73f22ec5 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -113,7 +113,7 @@ - +
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 5db51b539..0521a71be 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1,4 +1,3 @@ -from collections import Counter import csv import logging from datetime import datetime @@ -8,7 +7,7 @@ from registrar.models.domain_request import DomainRequest from registrar.models.domain_information import DomainInformation from django.utils import timezone from django.core.paginator import Paginator -from django.db.models import F, Value, CharField +from django.db.models import F, Value, CharField, Q, Count from django.db.models.functions import Concat, Coalesce from registrar.models.public_contact import PublicContact @@ -54,7 +53,7 @@ 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_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False, invites_with_invited_status=None): """Given a set of columns, generate a new row from cleaned column data""" # Domain should never be none when parsing this information @@ -108,7 +107,7 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di # Get lists of emails for active and invited domain managers dm_active_emails = [dm.user.email for dm in domain.permissions.all()] dm_invited_emails = [ - invite.email for invite in domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED) + invite.email for invite in invites_with_invited_status.filter(domain=domain) ] # Set up the "matching headers" + row field data for email and status @@ -149,32 +148,25 @@ def _get_security_emails(sec_contact_ids): def update_columns_with_domain_managers( - domain_info, update_columns, columns, max_dm_active, max_dm_invited, max_dm_total + domain_info,invites_with_invited_status, update_columns, columns, max_dm_total ): """Helper function that works with 'global' variables set in write_domains_csv Accepts: domain_info -> Domains to parse update_columns -> A control to make sure we only run the columns test and update when needed columns -> The header cells in the csv that's under construction - max_dm_active -> Starts at 0 and gets updated and passed again through this method - max_dm_invited -> Starts at 0 and gets updated and passed again through this method max_dm_total -> Starts at 0 and gets updated and passed again through this method Returns: Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" dm_active = domain_info.domain.permissions.count() - dm_invited = domain_info.domain.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).count() + dm_invited = invites_with_invited_status.filter(domain=domain_info.domain).count() - if dm_active > max_dm_active: - max_dm_active = max(dm_active, max_dm_active) - update_columns = True - - if dm_invited > max_dm_invited: - max_dm_invited = max(dm_invited, max_dm_invited) + if dm_active + dm_invited > max_dm_total: + max_dm_total = dm_active + dm_invited update_columns = True if update_columns: - max_dm_total = max_dm_active + max_dm_invited for i in range(1, max_dm_total + 1): column_name = f"Domain manager {i}" column2_name = f"DM{i} status" @@ -183,7 +175,7 @@ def update_columns_with_domain_managers( columns.append(column2_name) update_columns = False - return update_columns, columns, max_dm_active, max_dm_invited, max_dm_total + return update_columns, columns, max_dm_total def write_domains_csv( @@ -213,10 +205,18 @@ def write_domains_csv( # We get the number of domain managers (DMs) an the domain # that has the most DMs so we can set the header row appropriately - max_dm_active = 0 - max_dm_invited = 0 + max_dm_total = 0 update_columns = False + invites_with_invited_status=None + + if get_domain_managers: + invites_with_invited_status = DomainInvitation.objects.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).prefetch_related("domain") + + # zander = DomainInformation.objects.filter(**filter_condition).annotate(invitations_count=Count('invitation', filter=Q(invitation__status='invited'))).values_list('domain_name', 'invitations_count') + # logger.info(f'zander {zander}') + # zander_dict = dict(zander) + # logger.info(f'zander_dict {zander_dict}') # This var will live outside of the nested for loops to aggregate # the data from those loops @@ -229,14 +229,14 @@ def write_domains_csv( # Get max number of domain managers if get_domain_managers: - update_columns, columns, max_dm_active, max_dm_invited, max_dm_total = ( + update_columns, columns, max_dm_total = ( update_columns_with_domain_managers( - domain_info, update_columns, columns, max_dm_active, max_dm_invited, max_dm_total + domain_info,invites_with_invited_status, update_columns, columns, max_dm_total ) ) try: - row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) + row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers, invites_with_invited_status) rows.append(row) except ValueError: # This should not happen. If it does, just skip this row. @@ -518,58 +518,23 @@ def get_sliced_domains(filter_condition, distinct=False): 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, @@ -588,26 +553,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, diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py index e33dea407..eba8423ed 100644 --- a/src/registrar/views/admin_views.py +++ b/src/registrar/views/admin_views.py @@ -41,29 +41,29 @@ class AnalyticsView(View): start_date_formatted = csv_export.format_start_date(start_date) end_date_formatted = csv_export.format_end_date(end_date) - # filter_managed_domains_start_date = { - # "domain__permissions__isnull": False, - # "domain__first_ready__lte": start_date_formatted, - # } - # filter_managed_domains_end_date = { - # "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) + filter_managed_domains_start_date = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": start_date_formatted, + } + filter_managed_domains_end_date = { + "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) - # filter_unmanaged_domains_start_date = { - # "domain__permissions__isnull": True, - # "domain__first_ready__lte": start_date_formatted, - # } - # filter_unmanaged_domains_end_date = { - # "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) + filter_unmanaged_domains_start_date = { + "domain__permissions__isnull": True, + "domain__first_ready__lte": start_date_formatted, + } + filter_unmanaged_domains_end_date = { + "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) filter_ready_domains_start_date = { "domain__state__in": [models.Domain.State.READY], @@ -120,10 +120,10 @@ class AnalyticsView(View): last_30_days_applications=last_30_days_applications.count(), last_30_days_approved_applications=last_30_days_approved_applications.count(), average_application_approval_time_last_30_days=avg_approval_time_display, - # managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date, - # unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date, - # managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date, - # unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date, + managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date, + unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date, + managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date, + unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date, ready_domains_sliced_at_start_date=ready_domains_sliced_at_start_date, deleted_domains_sliced_at_start_date=deleted_domains_sliced_at_start_date, ready_domains_sliced_at_end_date=ready_domains_sliced_at_end_date, From 58b8e4649dfb61ed24c5f193d4f1bf9538b131bc Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 10:12:25 -0600 Subject: [PATCH 21/80] Refactor --- src/registrar/admin.py | 3 +- ...ninformation_organization_type_and_more.py | 83 +++++++++++++ .../0081_domainrequest_organization_type.py | 38 ------ src/registrar/models/domain_information.py | 18 ++- src/registrar/models/domain_request.py | 57 +++++++-- src/registrar/signals.py | 109 ++++++++++++------ 6 files changed, 224 insertions(+), 84 deletions(-) create mode 100644 src/registrar/migrations/0081_domaininformation_organization_type_and_more.py delete mode 100644 src/registrar/migrations/0081_domainrequest_organization_type.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d179d5549..f2204e543 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -883,8 +883,7 @@ class DomainInformationAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ - "generic_org_type", - "is_election_board", + "organization_type", "federal_type", "federal_agency", "tribe_name", diff --git a/src/registrar/migrations/0081_domaininformation_organization_type_and_more.py b/src/registrar/migrations/0081_domaininformation_organization_type_and_more.py new file mode 100644 index 000000000..8b3818b16 --- /dev/null +++ b/src/registrar/migrations/0081_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", "0080_create_groups_v09"), + ] + + 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/migrations/0081_domainrequest_organization_type.py b/src/registrar/migrations/0081_domainrequest_organization_type.py deleted file mode 100644 index 1d2185fbb..000000000 --- a/src/registrar/migrations/0081_domainrequest_organization_type.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.10 on 2024-03-29 15:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("registrar", "0080_create_groups_v09"), - ] - - operations = [ - 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, - ), - ), - ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index b5755a3c9..f41e7d5c6 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -54,7 +54,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( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 2b08bf1d0..0293fd124 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -101,7 +101,7 @@ class DomainRequest(TimeStampedModel): """ Primary organization choices: For use in the request experience - Keys need to match OrganizationChoicesElectionOffice and OrganizationChoicesVerbose + Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose """ FEDERAL = "federal", "Federal" @@ -113,7 +113,7 @@ class DomainRequest(TimeStampedModel): SPECIAL_DISTRICT = "special_district", "Special district" SCHOOL_DISTRICT = "school_district", "School district" - class OrganizationChoicesElectionOffice(models.TextChoices): + class OrgChoicesElectionOffice(models.TextChoices): """ Primary organization choices for Django admin: Keys need to match OrganizationChoices and OrganizationChoicesVerbose. @@ -142,6 +142,44 @@ class DomainRequest(TimeStampedModel): 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): """ Tertiary organization choices @@ -435,9 +473,16 @@ 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=OrganizationChoicesElectionOffice.choices, + choices=OrgChoicesElectionOffice.choices, null=True, blank=True, help_text="Type of organization - Election office", @@ -474,12 +519,6 @@ 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, diff --git a/src/registrar/signals.py b/src/registrar/signals.py index c7dc8821d..02f13a57b 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -3,15 +3,16 @@ import logging from django.db.models.signals import pre_save, post_save from django.dispatch import receiver -from .models import User, Contact, DomainRequest +from .models import User, Contact, DomainRequest, DomainInformation logger = logging.getLogger(__name__) @receiver(pre_save, sender=DomainRequest) +@receiver(pre_save, sender=DomainInformation) def create_or_update_organization_type(sender, instance, **kwargs): - """The organization_type field on DomainRequest is consituted from the + """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. @@ -21,50 +22,67 @@ def create_or_update_organization_type(sender, instance, **kwargs): organization_type is set to a corresponding election variant. Otherwise, it directly mirrors the generic_org_type value. """ - if not isinstance(instance, DomainRequest): + if not isinstance(instance, DomainRequest) and not isinstance(instance, DomainInformation): # I don't see how this could possibly happen - but its still a good check to have. # Lets force a fail condition rather than wait for one to happen, if this occurs. - raise ValueError("Type mismatch. The instance was not DomainRequest.") + raise ValueError("Type mismatch. The instance was not DomainRequest or DomainInformation.") # == Init variables == # - # We can't grab the election variant if it is in federal, interstate, or school_district. - # The "election variant" is just the org name, with " - Election" appended to the end. - # For example, "School district - Election". - invalid_types = [ - DomainRequest.OrganizationChoices.FEDERAL, - DomainRequest.OrganizationChoices.INTERSTATE, - DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, - ] - - # TODO - maybe we need a check here for .filter then .get is_new_instance = instance.id is None + election_org_choices = DomainRequest.OrgChoicesElectionOffice + + # For any given organization type, return the "_election" variant. + # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION + generic_org_to_org_map = election_org_choices.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_to_generic_org_map = election_org_choices.get_org_election_to_org_generic() # A new record is added with organization_type not defined. # This happens from the regular domain request flow. if is_new_instance: # == Check for invalid conditions before proceeding == # + # Since organization type is linked with generic_org_type and election board, + # we have to update one or the other, not both. if instance.organization_type and instance.generic_org_type: - # Since organization type is linked with generic_org_type and election board, - # we have to update one or the other, not both. - raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") + organization_type = str(instance.organization_type) + generic_org_type = str(instance.generic_org_type) + + # We can only proceed if all values match (fixtures, load_from_da). + # Otherwise, we're overwriting data so lets forbid this. + if ( + "_election" in organization_type != instance.is_election_board or + organization_type != generic_org_type + ): + 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) elif not instance.organization_type and not instance.generic_org_type: - # Do values to update - do nothing + # 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 = instance.organization_type is None generic_org_type_needs_update = instance.generic_org_type is None - # Update that field + # 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: - _update_org_type_from_generic_org_and_election(instance, invalid_types) + _update_org_type_from_generic_org_and_election(instance) elif generic_org_type_needs_update: _update_generic_org_and_election_from_org_type(instance) + else: + # This indicates that all data already matches, + # so we should just do nothing because there is nothing to update. + pass else: - + + # == Init variables == # # Instance is already in the database, fetch its current state current_instance = DomainRequest.objects.get(id=instance.id) @@ -77,6 +95,7 @@ def create_or_update_organization_type(sender, instance, **kwargs): 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): # Do values to update - do nothing @@ -90,28 +109,50 @@ def create_or_update_organization_type(sender, instance, **kwargs): # Update that field if organization_type_needs_update: - _update_org_type_from_generic_org_and_election(instance, invalid_types) + _update_org_type_from_generic_org_and_election(instance) elif generic_org_type_needs_update: _update_generic_org_and_election_from_org_type(instance) -def _update_org_type_from_generic_org_and_election(instance, invalid_types): - # TODO handle if generic_org_type is None - if instance.generic_org_type not in invalid_types and instance.is_election_board: - instance.organization_type = f"{instance.generic_org_type}_election" +def _update_org_type_from_generic_org_and_election(instance): + """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(instance.generic_org_type) + + # For any given organization type, return the "_election" variant. + # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION + election_org_choices = DomainRequest.OrgChoicesElectionOffice + org_map = election_org_choices.get_org_generic_to_org_election() + + # This essentially means: instance.generic_org_type not in invalid_types + if generic_org_type in org_map and instance.is_election_board: + instance.organization_type = org_map[generic_org_type] else: - instance.organization_type = str(instance.generic_org_type) + instance.organization_type = generic_org_type def _update_generic_org_and_election_from_org_type(instance): - """Given a value for organization_type, update the - generic_org_type and is_election_board values.""" - # TODO find a better solution than this + """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(instance.organization_type) - if "_election" in current_org_type: - instance.generic_org_type = current_org_type.split("_election")[0] + + # For any given organization type, return the generic variant. + # For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY + election_org_choices = DomainRequest.OrgChoicesElectionOffice + org_map = election_org_choices.get_org_election_to_org_generic() + + # This essentially means: "_election" in current_org_type + if current_org_type in org_map: + new_org = org_map[current_org_type] + instance.generic_org_type = new_org instance.is_election_board = True else: - instance.organization_type = str(instance.generic_org_type) + instance.generic_org_type = current_org_type instance.is_election_board = False @receiver(post_save, sender=User) From 198977bb6ea92846107583cd496e6cc49f30009e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 12:41:59 -0600 Subject: [PATCH 22/80] Test cases --- src/registrar/signals.py | 88 +++++++++++------ src/registrar/tests/common.py | 9 +- src/registrar/tests/test_signals.py | 148 ++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 32 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 02f13a57b..3a90c6a5c 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -30,7 +30,7 @@ def create_or_update_organization_type(sender, instance, **kwargs): # == Init variables == # is_new_instance = instance.id is None election_org_choices = DomainRequest.OrgChoicesElectionOffice - + # For any given organization type, return the "_election" variant. # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION generic_org_to_org_map = election_org_choices.get_org_generic_to_org_election() @@ -48,19 +48,29 @@ def create_or_update_organization_type(sender, instance, **kwargs): # we have to update one or the other, not both. if instance.organization_type and instance.generic_org_type: organization_type = str(instance.organization_type) + # Strip "_election" if it exists + mapped_org_type = election_org_to_generic_org_map.get(organization_type) generic_org_type = str(instance.generic_org_type) + should_proceed = True # We can only proceed if all values match (fixtures, load_from_da). # Otherwise, we're overwriting data so lets forbid this. - if ( - "_election" in organization_type != instance.is_election_board or - organization_type != generic_org_type - ): + is_election_type = "_election" in organization_type + can_have_election_board = organization_type in generic_org_to_org_map + if is_election_type != instance.is_election_board and can_have_election_board: + # This means that there is a mismatch between the booleans + # (i.e. FEDERAL is not equal to is_election_board = True) + should_proceed = False + elif mapped_org_type is not None and generic_org_type != mapped_org_type: + # This means that there is as mismatch between the org types + should_proceed = False + + if not should_proceed: 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) + raise ValueError(message) elif not instance.organization_type and not instance.generic_org_type: # No values to update - do nothing return None @@ -73,15 +83,17 @@ def create_or_update_organization_type(sender, instance, **kwargs): # 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: - _update_org_type_from_generic_org_and_election(instance) + _update_org_type_from_generic_org_and_election(instance, generic_org_to_org_map) elif generic_org_type_needs_update: - _update_generic_org_and_election_from_org_type(instance) + _update_generic_org_and_election_from_org_type( + instance, election_org_to_generic_org_map, generic_org_to_org_map + ) else: # This indicates that all data already matches, # so we should just do nothing because there is nothing to update. pass else: - + # == Init variables == # # Instance is already in the database, fetch its current state current_instance = DomainRequest.objects.get(id=instance.id) @@ -109,30 +121,41 @@ def create_or_update_organization_type(sender, instance, **kwargs): # Update that field if organization_type_needs_update: - _update_org_type_from_generic_org_and_election(instance) + _update_org_type_from_generic_org_and_election(instance, generic_org_to_org_map) elif generic_org_type_needs_update: - _update_generic_org_and_election_from_org_type(instance) + _update_generic_org_and_election_from_org_type( + instance, election_org_to_generic_org_map, generic_org_to_org_map + ) -def _update_org_type_from_generic_org_and_election(instance): + +def _update_org_type_from_generic_org_and_election(instance, org_map): """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(instance.generic_org_type) - # For any given organization type, return the "_election" variant. - # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION - election_org_choices = DomainRequest.OrgChoicesElectionOffice - org_map = election_org_choices.get_org_generic_to_org_election() - - # This essentially means: instance.generic_org_type not in invalid_types - if generic_org_type in org_map and instance.is_election_board: - instance.organization_type = org_map[generic_org_type] - else: + # If the election board is none, then it tells us that it is an invalid field. + # Such as federal, interstate, or school_district. + if instance.is_election_board is None and generic_org_type not in org_map: instance.organization_type = generic_org_type + return instance + elif instance.is_election_board is None and generic_org_type in org_map: + # This can only happen with manual data tinkering, which causes these to be out of sync. + instance.is_election_board = False + logger.warning("create_or_update_organization_type() -> is_election_board is out of sync. Updating value.") + + if generic_org_type in org_map: + # Swap to the election type if it is an election board. Otherwise, stick to the normal one. + instance.organization_type = org_map[generic_org_type] if instance.is_election_board else generic_org_type + elif generic_org_type not in org_map: + # Election board should be reset to None if the record + # can't have one. For example, federal. + instance.organization_type = generic_org_type + instance.is_election_board = None -def _update_generic_org_and_election_from_org_type(instance): +def _update_generic_org_and_election_from_org_type(instance, election_org_map, generic_org_map): """Given the field value for organization_type, update the generic_org_type and is_election_board field.""" @@ -141,19 +164,22 @@ def _update_generic_org_and_election_from_org_type(instance): # But their names are the same (for the most part). current_org_type = str(instance.organization_type) - # For any given organization type, return the generic variant. - # For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY - election_org_choices = DomainRequest.OrgChoicesElectionOffice - org_map = election_org_choices.get_org_election_to_org_generic() - - # This essentially means: "_election" in current_org_type - if current_org_type in org_map: - new_org = org_map[current_org_type] + # This essentially means: "_election" in current_org_type. + if current_org_type in election_org_map: + new_org = election_org_map[current_org_type] instance.generic_org_type = new_org instance.is_election_board = True else: instance.generic_org_type = current_org_type - instance.is_election_board = False + + # 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: + instance.is_election_board = False + else: + instance.is_election_board = None + @receiver(post_save, sender=User) def handle_profile(sender, instance, **kwargs): diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 9ecc6af67..1a4120106 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -782,6 +782,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 +822,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 +844,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_signals.py b/src/registrar/tests/test_signals.py index 4e2cbc83b..a6f8adb44 100644 --- a/src/registrar/tests/test_signals.py +++ b/src/registrar/tests/test_signals.py @@ -2,6 +2,8 @@ from django.test import TestCase from django.contrib.auth import get_user_model from registrar.models import Contact +from registrar.models.domain_request import DomainRequest +from registrar.tests.common import completed_domain_request class TestUserPostSave(TestCase): @@ -99,3 +101,149 @@ class TestUserPostSave(TestCase): self.assertEqual(actual.last_name, self.last_name) self.assertEqual(actual.email, self.email) self.assertEqual(actual.phone, self.phone) + + +class TestDomainRequestSignals(TestCase): + """Tests hooked signals 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) + + 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) From 941512c70412004ed2f95e8d5640a1469cae2b15 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:47:27 -0600 Subject: [PATCH 23/80] Linting --- src/registrar/models/domain.py | 1 - src/registrar/models/domain_request.py | 3 +- src/registrar/signals.py | 81 +++++++++++++------------- 3 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index b3d5b19ce..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""" - return True 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_request.py b/src/registrar/models/domain_request.py index 0293fd124..fc2864fe4 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -124,6 +124,7 @@ class DomainRequest(TimeStampedModel): 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" @@ -160,7 +161,7 @@ class DomainRequest(TimeStampedModel): cls.SPECIAL_DISTRICT_ELECTION: cls.SPECIAL_DISTRICT, } return org_election_map - + @classmethod def get_org_generic_to_org_election(cls): """ diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 3a90c6a5c..22a04b39c 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -22,13 +22,8 @@ def create_or_update_organization_type(sender, instance, **kwargs): organization_type is set to a corresponding election variant. Otherwise, it directly mirrors the generic_org_type value. """ - if not isinstance(instance, DomainRequest) and not isinstance(instance, DomainInformation): - # I don't see how this could possibly happen - but its still a good check to have. - # Lets force a fail condition rather than wait for one to happen, if this occurs. - raise ValueError("Type mismatch. The instance was not DomainRequest or DomainInformation.") # == Init variables == # - is_new_instance = instance.id is None election_org_choices = DomainRequest.OrgChoicesElectionOffice # For any given organization type, return the "_election" variant. @@ -41,38 +36,13 @@ def create_or_update_organization_type(sender, instance, **kwargs): # A new record is added with organization_type not defined. # This happens from the regular domain request flow. + is_new_instance = instance.id is None + if is_new_instance: # == Check for invalid conditions before proceeding == # - # Since organization type is linked with generic_org_type and election board, - # we have to update one or the other, not both. - if instance.organization_type and instance.generic_org_type: - organization_type = str(instance.organization_type) - # Strip "_election" if it exists - mapped_org_type = election_org_to_generic_org_map.get(organization_type) - generic_org_type = str(instance.generic_org_type) - should_proceed = True - - # We can only proceed if all values match (fixtures, load_from_da). - # Otherwise, we're overwriting data so lets forbid this. - is_election_type = "_election" in organization_type - can_have_election_board = organization_type in generic_org_to_org_map - if is_election_type != instance.is_election_board and can_have_election_board: - # This means that there is a mismatch between the booleans - # (i.e. FEDERAL is not equal to is_election_board = True) - should_proceed = False - elif mapped_org_type is not None and generic_org_type != mapped_org_type: - # This means that there is as mismatch between the org types - should_proceed = False - - if not should_proceed: - 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) - elif not instance.organization_type and not instance.generic_org_type: - # No values to update - do nothing + should_proceed = _validate_new_instance(instance, election_org_to_generic_org_map, generic_org_to_org_map) + if not should_proceed: return None # == Program flow will halt here if there is no reason to update == # @@ -88,10 +58,6 @@ def create_or_update_organization_type(sender, instance, **kwargs): _update_generic_org_and_election_from_org_type( instance, election_org_to_generic_org_map, generic_org_to_org_map ) - else: - # This indicates that all data already matches, - # so we should just do nothing because there is nothing to update. - pass else: # == Init variables == # @@ -148,7 +114,7 @@ def _update_org_type_from_generic_org_and_election(instance, org_map): if generic_org_type in org_map: # Swap to the election type if it is an election board. Otherwise, stick to the normal one. instance.organization_type = org_map[generic_org_type] if instance.is_election_board else generic_org_type - elif generic_org_type not in org_map: + else: # Election board should be reset to None if the record # can't have one. For example, federal. instance.organization_type = generic_org_type @@ -181,6 +147,43 @@ def _update_generic_org_and_election_from_org_type(instance, election_org_map, g instance.is_election_board = None +def _validate_new_instance(instance, election_org_to_generic_org_map, generic_org_to_org_map): + """ + 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. + """ + + # 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 instance.organization_type and instance.generic_org_type: + generic_org_type = str(instance.generic_org_type) + organization_type = str(instance.organization_type) + + # Strip "_election" if it exists + mapped_org_type = 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 generic_org_to_org_map + + election_board_mismatch = is_election_type != 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) + + should_proceed = True + return should_proceed + else: + should_proceed = not instance.organization_type and not instance.generic_org_type + return should_proceed + + @receiver(post_save, sender=User) def handle_profile(sender, instance, **kwargs): """Method for when a User is saved. From 6c22cf7169f098af8bcfbaac7d240a6567c9b522 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:56:27 -0600 Subject: [PATCH 24/80] Fix unit tests --- src/registrar/assets/sass/_theme/_admin.scss | 28 ++++++-------------- src/registrar/tests/test_admin.py | 4 +-- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index d1e000033..ebd0d1b5b 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -390,53 +390,41 @@ 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: 0.8125rem !important; +} + address.dja-address-contact-list { - font-size: 0.8125rem; color: var(--body-quiet-color); button.usa-button__clipboard-link { font-size: 0.8125rem !important; } } -td button.usa-button__clipboard-link { - font-size: 0.8125rem !important; -} - // Mimic the normal label size @media (max-width: 1024px){ - .dja-detail-list dt { - font-size: 0.875rem; - color: var(--body-quiet-color); - } - .dja-detail-list address { + .dja-detail-list dt, .dja-detail-list address { font-size: 0.875rem; color: var(--body-quiet-color); } - address button.usa-button__clipboard-link { + address button.usa-button__clipboard-link, td button.usa-button__clipboard-link { font-size: 0.875rem !important; } - td button.usa-button__clipboard-link { - font-size: 0.875rem !important; - } } .errors span.select2-selection { border: 1px solid var(--error-fg) !important; } +// Make the clipboard button "float" inside of the input box .admin-icon-group { position: relative; display: flex; diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index bbbf385d9..1f7ba7c71 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1306,7 +1306,7 @@ class TestDomainRequestAdmin(MockEppLib): ("title", "Treat inspector"), ("email", "meoward.jones@igorville.gov"), ("phone", "(555) 123 12345"), - ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) @@ -2035,7 +2035,7 @@ class TestDomainInformationAdmin(TestCase): ("title", "Treat inspector"), ("email", "meoward.jones@igorville.gov"), ("phone", "(555) 123 12345"), - ("email_copy_button_input", f'"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) From 022eb9bbaf398ad46e5d45bff9beec49b416b652 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:05:09 -0600 Subject: [PATCH 25/80] Fix bug --- src/registrar/signals.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 22a04b39c..a6fabe873 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -151,6 +151,8 @@ def _validate_new_instance(instance, election_org_to_generic_org_map, generic_or """ 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 @@ -168,8 +170,8 @@ def _validate_new_instance(instance, election_org_to_generic_org_map, generic_or is_election_type = "_election" in organization_type can_have_election_board = organization_type in generic_org_to_org_map - election_board_mismatch = is_election_type != 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 + election_board_mismatch = (is_election_type != 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 " @@ -177,11 +179,11 @@ def _validate_new_instance(instance, election_org_to_generic_org_map, generic_or ) raise ValueError(message) - should_proceed = True - return should_proceed + return True + elif not instance.organization_type and not instance.generic_org_type: + return False else: - should_proceed = not instance.organization_type and not instance.generic_org_type - return should_proceed + return True @receiver(post_save, sender=User) From 892607bd658b780a29d08d015acfd90738173e07 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:06:36 -0600 Subject: [PATCH 26/80] Update test_admin.py --- src/registrar/tests/test_admin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 1f7ba7c71..c285c8f37 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1306,7 +1306,6 @@ class TestDomainRequestAdmin(MockEppLib): ("title", "Treat inspector"), ("email", "meoward.jones@igorville.gov"), ("phone", "(555) 123 12345"), - ("email_copy_button_input", f"{expected_email}"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) @@ -2035,7 +2034,6 @@ class TestDomainInformationAdmin(TestCase): ("title", "Treat inspector"), ("email", "meoward.jones@igorville.gov"), ("phone", "(555) 123 12345"), - ("email_copy_button_input", f"{expected_email}"), ] self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) From 843158b3ef2de255017618fda8930c60004b92a1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:08:59 -0600 Subject: [PATCH 27/80] Update src/registrar/signals.py --- src/registrar/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index a6fabe873..4e2020731 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -76,7 +76,7 @@ def create_or_update_organization_type(sender, instance, **kwargs): # 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): - # Do values to update - do nothing + # No values to update - do nothing return None # == Program flow will halt here if there is no reason to update == # From 47b7fb8f492976d6c1337f41396bf6eba72348d1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:11:28 -0600 Subject: [PATCH 28/80] Remove bad tests No longer applicable here --- src/registrar/tests/test_admin.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index c285c8f37..759fd8b9e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1319,7 +1319,6 @@ class TestDomainRequestAdmin(MockEppLib): ("title", "Admin Tester"), ("email", "mayor@igorville.gov"), ("phone", "(555) 555 5556"), - ("email_copy_button_input", f'"), ("email", "testy@town.com"), ("phone", "(555) 555 5555"), - ("email_copy_button_input", f' Date: Mon, 1 Apr 2024 14:13:48 -0600 Subject: [PATCH 29/80] Revert "Remove bad tests" This reverts commit 47b7fb8f492976d6c1337f41396bf6eba72348d1. --- src/registrar/tests/test_admin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 759fd8b9e..c285c8f37 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1319,6 +1319,7 @@ class TestDomainRequestAdmin(MockEppLib): ("title", "Admin Tester"), ("email", "mayor@igorville.gov"), ("phone", "(555) 555 5556"), + ("email_copy_button_input", f'"), ("email", "testy@town.com"), ("phone", "(555) 555 5555"), + ("email_copy_button_input", f' Date: Mon, 1 Apr 2024 14:21:35 -0600 Subject: [PATCH 30/80] New unit tests --- src/registrar/tests/test_admin.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index c285c8f37..3999acc4e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1319,7 +1319,6 @@ class TestDomainRequestAdmin(MockEppLib): ("title", "Admin Tester"), ("email", "mayor@igorville.gov"), ("phone", "(555) 555 5556"), - ("email_copy_button_input", f'"), - ("email", "testy@town.com"), - ("phone", "(555) 555 5555"), - ("email_copy_button_input", f' Date: Mon, 1 Apr 2024 15:38:53 -0600 Subject: [PATCH 31/80] Add DomainInformatoin tests --- src/registrar/admin.py | 1 + src/registrar/signals.py | 10 +- src/registrar/tests/common.py | 8 +- src/registrar/tests/test_admin.py | 3 +- src/registrar/tests/test_reports.py | 4 +- src/registrar/tests/test_signals.py | 164 +++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index f2204e543..7c88c34a0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -883,6 +883,7 @@ class DomainInformationAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ + "is_election_board", "organization_type", "federal_type", "federal_agency", diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 4e2020731..5cf035eb9 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -62,7 +62,15 @@ def create_or_update_organization_type(sender, instance, **kwargs): # == Init variables == # # Instance is already in the database, fetch its current state - current_instance = DomainRequest.objects.get(id=instance.id) + if isinstance(instance, DomainRequest): + current_instance = DomainRequest.objects.get(id=instance.id) + elif isinstance(instance, DomainInformation): + current_instance = DomainInformation.objects.get(id=instance.id) + else: + # This should never occur. But it never hurts to have this check anyway. + raise ValueError( + "create_or_update_organization_type() -> instance was not DomainRequest or DomainInformation" + ) # Check the new and old values generic_org_type_changed = instance.generic_org_type != current_instance.generic_org_type diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 1a4120106..9681b8cb7 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -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, diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 7c0c81db4..46b5e104a 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1453,12 +1453,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", diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 5bd594a15..d3eec946d 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -687,12 +687,12 @@ class HelperFunctions(MockDb): } # 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] + 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, 4, 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 a6f8adb44..e950f39fb 100644 --- a/src/registrar/tests/test_signals.py +++ b/src/registrar/tests/test_signals.py @@ -1,8 +1,6 @@ from django.test import TestCase from django.contrib.auth import get_user_model - -from registrar.models import Contact -from registrar.models.domain_request import DomainRequest +from registrar.models import Contact, DomainRequest, Domain, DomainInformation from registrar.tests.common import completed_domain_request @@ -130,6 +128,7 @@ class TestDomainRequestSignals(TestCase): 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.""" @@ -247,3 +246,162 @@ class TestDomainRequestSignals(TestCase): ) self.assertEqual(domain_request_election.is_election_board, True) self.assertEqual(domain_request_election.generic_org_type, DomainRequest.OrganizationChoices.CITY) + + +class TestDomainInformationSignals(TestCase): + """Tests hooked signals on the DomainRequest 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) From 92f1a0dab4d551e1117713d3e79a1775cbcaef09 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 1 Apr 2024 15:47:15 -0600 Subject: [PATCH 32/80] Update test_admin.py --- src/registrar/tests/test_admin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 3999acc4e..b73d9bce1 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1324,7 +1324,7 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "Testy2 Tester2") # == Check for the authorizing_official == # - expected_email = "testy@town.com" + expected_ao_fields = [ # Field, expected value ("title", "Chief Tester"), @@ -1338,7 +1338,7 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "Testy Tester", count=5) # == Test the other_employees field == # - expected_email = "testy@town.com" + expected_other_employees_fields = [ # Field, expected value ("title", "Another Tester"), @@ -2052,7 +2052,7 @@ class TestDomainInformationAdmin(TestCase): self.assertContains(response, "Testy2 Tester2") # == Check for the authorizing_official == # - expected_email = "testy@town.com" + expected_ao_fields = [ # Field, expected value ("title", "Chief Tester"), @@ -2066,7 +2066,7 @@ class TestDomainInformationAdmin(TestCase): self.assertContains(response, "Testy Tester", count=5) # == Test the other_employees field == # - expected_email = "testy@town.com" + expected_other_employees_fields = [ # Field, expected value ("title", "Another Tester"), From 45c047dc8a5cf32a888d620dde33036b55b40df9 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 1 Apr 2024 21:35:29 -0400 Subject: [PATCH 33/80] optimize by removing filters from inside iterations --- src/registrar/utility/csv_export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 0521a71be..d4485e11f 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -107,7 +107,7 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di # Get lists of emails for active and invited domain managers dm_active_emails = [dm.user.email for dm in domain.permissions.all()] dm_invited_emails = [ - invite.email for invite in invites_with_invited_status.filter(domain=domain) + invite.email for invite in invites_with_invited_status if invite.domain_id == domain_info.domain_id ] # Set up the "matching headers" + row field data for email and status @@ -160,7 +160,7 @@ def update_columns_with_domain_managers( Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" dm_active = domain_info.domain.permissions.count() - dm_invited = invites_with_invited_status.filter(domain=domain_info.domain).count() + dm_invited = sum(1 for invite in invites_with_invited_status if invite.domain_id == domain_info.domain_id) if dm_active + dm_invited > max_dm_total: max_dm_total = dm_active + dm_invited From dba2dfb6c2034668fc658f461262ba4f5a6c1c89 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 1 Apr 2024 21:46:06 -0400 Subject: [PATCH 34/80] Revert last experiment --- src/registrar/utility/csv_export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index d4485e11f..0521a71be 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -107,7 +107,7 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di # Get lists of emails for active and invited domain managers dm_active_emails = [dm.user.email for dm in domain.permissions.all()] dm_invited_emails = [ - invite.email for invite in invites_with_invited_status if invite.domain_id == domain_info.domain_id + invite.email for invite in invites_with_invited_status.filter(domain=domain) ] # Set up the "matching headers" + row field data for email and status @@ -160,7 +160,7 @@ def update_columns_with_domain_managers( Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" dm_active = domain_info.domain.permissions.count() - dm_invited = sum(1 for invite in invites_with_invited_status if invite.domain_id == domain_info.domain_id) + dm_invited = invites_with_invited_status.filter(domain=domain_info.domain).count() if dm_active + dm_invited > max_dm_total: max_dm_total = dm_active + dm_invited From 79daf4c65176060d62fe82af2c3811bb9f0148ab Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 2 Apr 2024 09:11:05 -0600 Subject: [PATCH 35/80] Update test --- src/registrar/signals.py | 2 +- src/registrar/tests/test_reports.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 5cf035eb9..301459f93 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -93,7 +93,7 @@ def create_or_update_organization_type(sender, instance, **kwargs): organization_type_needs_update = generic_org_type_changed or is_election_board_changed generic_org_type_needs_update = organization_type_changed - # Update that field + # Update the field if organization_type_needs_update: _update_org_type_from_generic_org_and_election(instance, generic_org_to_org_map) elif generic_org_type_needs_update: diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index d3eec946d..459ccde0f 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -562,7 +562,7 @@ 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" From 410440ee709b26e086f3c85b80c04a6ba1c5bb63 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 2 Apr 2024 12:39:29 -0400 Subject: [PATCH 36/80] Optimize by using prebuilt dicts --- src/registrar/utility/csv_export.py | 139 ++++++++++++++++++---------- 1 file changed, 88 insertions(+), 51 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 0521a71be..d8638d0d9 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -11,6 +11,8 @@ from django.db.models import F, Value, CharField, Q, Count 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.models.utility.generic_helper import Timer from registrar.utility.enums import DefaultEmail logger = logging.getLogger(__name__) @@ -33,7 +35,6 @@ def get_domain_infos(filter_condition, sort_fields): """ domain_infos = ( DomainInformation.objects.select_related("domain", "authorizing_official") - .prefetch_related("domain__permissions", "domain__invitations") .filter(**filter_condition) .order_by(*sort_fields) .distinct() @@ -53,7 +54,7 @@ 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, invites_with_invited_status=None): +def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False, domain_invitation_emails=None, domain_permissions_emails=None): """Given a set of columns, generate a new row from cleaned column data""" # Domain should never be none when parsing this information @@ -105,10 +106,9 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di if get_domain_managers: # Get lists of emails for active and invited domain managers - dm_active_emails = [dm.user.email for dm in domain.permissions.all()] - dm_invited_emails = [ - invite.email for invite in invites_with_invited_status.filter(domain=domain) - ] + + dm_active_emails = domain_permissions_emails.get(domain_info.domain.name, []) + dm_invited_emails = domain_invitation_emails.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 @@ -148,7 +148,7 @@ def _get_security_emails(sec_contact_ids): def update_columns_with_domain_managers( - domain_info,invites_with_invited_status, update_columns, columns, max_dm_total + domain_info,domain_invitation_emails, domain_permissions_emails, update_columns, columns, max_dm_total ): """Helper function that works with 'global' variables set in write_domains_csv Accepts: @@ -159,8 +159,25 @@ def update_columns_with_domain_managers( Returns: Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" - dm_active = domain_info.domain.permissions.count() - dm_invited = invites_with_invited_status.filter(domain=domain_info.domain).count() + dm_active = 0 + dm_invited = 0 + try: + # logger.info(f'domain_invitation_emails {domain_invitation_emails[domain_info.domain.name]}') + + # Get the list of invitation emails for the domain name if it exists, otherwise, return an empty list + invitation_emails = domain_invitation_emails.get(domain_info.domain.name, []) + # Count the number of invitation emails + dm_invited = len(invitation_emails) + except KeyError: + pass + + try: + active_emails = domain_permissions_emails.get(domain_info.domain.name, []) + # Count the number of invitation emails + dm_active = len(active_emails) + except KeyError: + pass + if dm_active + dm_invited > max_dm_total: max_dm_total = dm_active + dm_invited @@ -193,61 +210,80 @@ def write_domains_csv( should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice """ - all_domain_infos = get_domain_infos(filter_condition, sort_fields) + with Timer(): + 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) + # 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) + security_emails_dict = _get_security_emails(sec_contact_ids) - # Reduce the memory overhead when performing the write operation - paginator = Paginator(all_domain_infos, 1000) + # Reduce the memory overhead when performing the write operation + paginator = Paginator(all_domain_infos, 1000) - # We get the number of domain managers (DMs) an the domain - # that has the most DMs so we can set the header row appropriately + # We get the number of domain managers (DMs) an the domain + # that has the most DMs so we can set the header row appropriately - max_dm_total = 0 - update_columns = False - invites_with_invited_status=None + max_dm_total = 0 + update_columns = False + + invites_with_invited_status=None + domain_invitation_emails = {} + domain_permissions_emails = {} - if get_domain_managers: - invites_with_invited_status = DomainInvitation.objects.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).prefetch_related("domain") + if get_domain_managers: + invites_with_invited_status = DomainInvitation.objects.filter(status=DomainInvitation.DomainInvitationStatus.INVITED).select_related("domain") - # zander = DomainInformation.objects.filter(**filter_condition).annotate(invitations_count=Count('invitation', filter=Q(invitation__status='invited'))).values_list('domain_name', 'invitations_count') - # logger.info(f'zander {zander}') - # zander_dict = dict(zander) - # logger.info(f'zander_dict {zander_dict}') + # Iterate through each domain invitation and populate the dictionary + for invite in invites_with_invited_status: + domain_name = invite.domain.name + email = invite.email + if domain_name not in domain_invitation_emails: + domain_invitation_emails[domain_name] = [] + domain_invitation_emails[domain_name].append(email) - # This var will live outside of the nested for loops to aggregate - # the data from those loops - total_body_rows = [] + domain_permissions = UserDomainRole.objects.all() - for page_num in paginator.page_range: - rows = [] - page = paginator.page(page_num) - for domain_info in page.object_list: + # Iterate through each domain invitation and populate the dictionary + for permission in domain_permissions: + domain_name = permission.domain.name + email = permission.user.email + if domain_name not in domain_permissions_emails: + domain_permissions_emails[domain_name] = [] + domain_permissions_emails[domain_name].append(email) - # Get max number of domain managers - if get_domain_managers: - update_columns, columns, max_dm_total = ( - update_columns_with_domain_managers( - domain_info,invites_with_invited_status, update_columns, columns, max_dm_total + # logger.info(f'domain_invitation_emails {domain_invitation_emails}') + + # This var will live outside of the nested for loops to aggregate + # the data from those loops + total_body_rows = [] + + for page_num in paginator.page_range: + rows = [] + page = paginator.page(page_num) + for domain_info in page.object_list: + + # Get max number of domain managers + if get_domain_managers: + update_columns, columns, max_dm_total = ( + update_columns_with_domain_managers( + domain_info, domain_invitation_emails,domain_permissions_emails, update_columns, columns, max_dm_total + ) ) - ) - try: - row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers, invites_with_invited_status) - 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) + try: + row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers, domain_invitation_emails,domain_permissions_emails) + 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) - if should_write_header: - write_header(writer, columns) - writer.writerows(total_body_rows) + if should_write_header: + write_header(writer, columns) + writer.writerows(total_body_rows) def get_requests(filter_condition, sort_fields): @@ -360,6 +396,7 @@ def export_data_type_to_csv(csv_file): Domain.State.READY, Domain.State.DNS_NEEDED, Domain.State.ON_HOLD, + Domain.State.UNKNOWN, ], } write_domains_csv( From 045140091b11254b389eb0329fb70b342d2cedf3 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 2 Apr 2024 12:44:00 -0400 Subject: [PATCH 37/80] do not pull unknown domains --- src/registrar/utility/csv_export.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index d8638d0d9..913fe95db 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -396,7 +396,6 @@ def export_data_type_to_csv(csv_file): Domain.State.READY, Domain.State.DNS_NEEDED, Domain.State.ON_HOLD, - Domain.State.UNKNOWN, ], } write_domains_csv( From f753f3e6f1209b7c12934d327bc11dc5ce80ba09 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:06:41 -0600 Subject: [PATCH 38/80] Linter --- src/registrar/tests/test_admin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index b73d9bce1..804b94711 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1300,7 +1300,6 @@ class TestDomainRequestAdmin(MockEppLib): # == Check for the creator == # # Check for the right title, email, and phone number in the response. - expected_email = "meoward.jones@igorville.gov" expected_creator_fields = [ # Field, expected value ("title", "Treat inspector"), @@ -1313,7 +1312,6 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "Meoward Jones") # == Check for the submitter == # - expected_email = "mayor@igorville.gov" expected_submitter_fields = [ # Field, expected value ("title", "Admin Tester"), @@ -2028,7 +2026,6 @@ class TestDomainInformationAdmin(TestCase): # Check for the right title, email, and phone number in the response. # We only need to check for the end tag # (Otherwise this test will fail if we change classes, etc) - expected_email = "meoward.jones@igorville.gov" expected_creator_fields = [ # Field, expected value ("title", "Treat inspector"), @@ -2041,7 +2038,6 @@ class TestDomainInformationAdmin(TestCase): self.assertContains(response, "Meoward Jones") # == Check for the submitter == # - expected_email = "mayor@igorville.gov" expected_submitter_fields = [ # Field, expected value ("title", "Admin Tester"), From 7c8d7d293c5fda1c430fb6749190864a2d44b845 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:50:15 -0600 Subject: [PATCH 39/80] Simplify _update_org_type_from_generic_org_and_election --- src/registrar/signals.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 301459f93..135336b04 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -108,25 +108,18 @@ def _update_org_type_from_generic_org_and_election(instance, org_map): # We convert to a string because the enum types are different. generic_org_type = str(instance.generic_org_type) - - # If the election board is none, then it tells us that it is an invalid field. - # Such as federal, interstate, or school_district. - if instance.is_election_board is None and generic_org_type not in org_map: - instance.organization_type = generic_org_type - return instance - elif instance.is_election_board is None and generic_org_type in org_map: - # This can only happen with manual data tinkering, which causes these to be out of sync. - instance.is_election_board = False - logger.warning("create_or_update_organization_type() -> is_election_board is out of sync. Updating value.") - - if generic_org_type in org_map: - # Swap to the election type if it is an election board. Otherwise, stick to the normal one. - instance.organization_type = org_map[generic_org_type] if instance.is_election_board else generic_org_type - else: - # Election board should be reset to None if the record + if generic_org_type not in generic_org_type: + # Election board should always be reset to None if the record # can't have one. For example, federal. - instance.organization_type = generic_org_type instance.is_election_board = None + instance.organization_type = generic_org_type + else: + # This can only happen with manual data tinkering, which causes these to be out of sync. + if instance.is_election_board is None: + logger.warning("create_or_update_organization_type() -> is_election_board is out of sync. Updating value.") + instance.is_election_board = False + + instance.organization_type = org_map[generic_org_type] if instance.is_election_board else generic_org_type def _update_generic_org_and_election_from_org_type(instance, election_org_map, generic_org_map): From 20067eec699715607cd4f19b69494eb2fa2fa8b2 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 2 Apr 2024 14:19:04 -0600 Subject: [PATCH 40/80] Fix typo --- src/registrar/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 135336b04..ad287219d 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -108,7 +108,7 @@ def _update_org_type_from_generic_org_and_election(instance, org_map): # We convert to a string because the enum types are different. generic_org_type = str(instance.generic_org_type) - if generic_org_type not in generic_org_type: + if generic_org_type not in org_map: # Election board should always be reset to None if the record # can't have one. For example, federal. instance.is_election_board = None From 70bd79516abc00ef875aec3013eff9b93bbcb8ec Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 2 Apr 2024 15:38:12 -0600 Subject: [PATCH 41/80] Use sender --- src/registrar/signals.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index ad287219d..d106f974c 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) @receiver(pre_save, sender=DomainRequest) @receiver(pre_save, sender=DomainInformation) -def create_or_update_organization_type(sender, instance, **kwargs): +def create_or_update_organization_type(sender: DomainRequest | DomainInformation, instance, **kwargs): """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 @@ -62,15 +62,7 @@ def create_or_update_organization_type(sender, instance, **kwargs): # == Init variables == # # Instance is already in the database, fetch its current state - if isinstance(instance, DomainRequest): - current_instance = DomainRequest.objects.get(id=instance.id) - elif isinstance(instance, DomainInformation): - current_instance = DomainInformation.objects.get(id=instance.id) - else: - # This should never occur. But it never hurts to have this check anyway. - raise ValueError( - "create_or_update_organization_type() -> instance was not DomainRequest or DomainInformation" - ) + current_instance = sender.objects.get(id=instance.id) # Check the new and old values generic_org_type_changed = instance.generic_org_type != current_instance.generic_org_type From c5e6295a8a5c25f480c0465b2dd328224463cde1 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 2 Apr 2024 20:54:43 -0400 Subject: [PATCH 42/80] refactor wip --- src/registrar/tests/test_reports.py | 39 +++-- src/registrar/utility/csv_export.py | 247 +++++++++++++++------------- src/registrar/views/admin_views.py | 10 +- 3 files changed, 162 insertions(+), 134 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cd882c4f8..b4861560f 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -243,7 +243,12 @@ 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 + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=False, + should_write_header=True, ) # Reset the CSV file's position to the beginning @@ -305,7 +310,12 @@ 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 + 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) @@ -358,7 +368,12 @@ 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 + 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) @@ -438,7 +453,7 @@ class ExportDataTest(MockDb, MockEppLib): columns, sort_fields, filter_condition, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=True, ) write_domains_csv( @@ -446,7 +461,7 @@ class ExportDataTest(MockDb, MockEppLib): 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 @@ -514,7 +529,12 @@ 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 + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=True, + should_write_header=True, ) # Reset the CSV file's position to the beginning @@ -697,13 +717,8 @@ 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] - 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, 2] self.assertEqual(managed_domains_sliced_at_end_date, expected_content) def test_get_sliced_requests(self): diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 913fe95db..faad25b28 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -7,7 +7,7 @@ from registrar.models.domain_request import DomainRequest from registrar.models.domain_information import DomainInformation from django.utils import timezone from django.core.paginator import Paginator -from django.db.models import F, Value, CharField, Q, Count +from django.db.models import F, Value, CharField from django.db.models.functions import Concat, Coalesce from registrar.models.public_contact import PublicContact @@ -54,7 +54,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, domain_invitation_emails=None, domain_permissions_emails=None): +def parse_domain_row( + columns, + domain_info: DomainInformation, + dict_security_emails_dict=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 @@ -66,8 +73,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_dict is not None and domain.name in dict_security_emails_dict: + _email = dict_security_emails_dict.get(domain.name) security_email = _email if _email is not None else " " else: # If the dictionary doesn't contain that data, lets filter for it manually. @@ -104,20 +111,20 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di "Deleted": domain.deleted, } - if get_domain_managers: + if should_get_domain_managers: # Get lists of emails for active and invited domain managers - dm_active_emails = domain_permissions_emails.get(domain_info.domain.name, []) - dm_invited_emails = domain_invitation_emails.get(domain_info.domain.name, []) + 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(dm_active_emails, start=1): + 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(dm_invited_emails, start=i + 1): + 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" @@ -129,7 +136,7 @@ def _get_security_emails(sec_contact_ids): """ Retrieve security contact emails for the given security contact IDs. """ - security_emails_dict = {} + dict_security_emails_dict = {} public_contacts = ( PublicContact.objects.only("email", "domain__name") .select_related("domain") @@ -139,144 +146,152 @@ 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: + dict_security_emails_dict[domain.name] = contact.email else: logger.warning("csv_export -> Domain was none for PublicContact") - return security_emails_dict + return dict_security_emails_dict + + +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( - domain_info,domain_invitation_emails, domain_permissions_emails, update_columns, columns, max_dm_total + columns, + domain_info, + should_update_columns, + dms_total, + dict_domain_invitations_with_invited_status, + dict_user_domain_roles, ): - """Helper function that works with 'global' variables set in write_domains_csv - Accepts: - domain_info -> Domains to parse - update_columns -> A control to make sure we only run the columns test and update when needed - columns -> The header cells in the csv that's under construction - max_dm_total -> Starts at 0 and gets updated and passed again through this method - Returns: - Updated update_columns, columns, max_dm_active, max_dm_invited, max_dm_total""" + """Helper function to update columns with domain manager information""" - dm_active = 0 - dm_invited = 0 - try: - # logger.info(f'domain_invitation_emails {domain_invitation_emails[domain_info.domain.name]}') - - # Get the list of invitation emails for the domain name if it exists, otherwise, return an empty list - invitation_emails = domain_invitation_emails.get(domain_info.domain.name, []) - # Count the number of invitation emails - dm_invited = len(invitation_emails) - except KeyError: - pass + domain_name = domain_info.domain.name try: - active_emails = domain_permissions_emails.get(domain_info.domain.name, []) - # Count the number of invitation emails - dm_active = len(active_emails) - except KeyError: - pass - + dms_active, dms_invited = count_domain_managers( + domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles + ) - if dm_active + dm_invited > max_dm_total: - max_dm_total = dm_active + dm_invited - update_columns = True + if dms_active + dms_invited > dms_total: + dms_total = dms_active + dms_invited + should_update_columns = True - if update_columns: - for i in range(1, max_dm_total + 1): - column_name = f"Domain manager {i}" - column2_name = f"DM{i} status" - if column_name not in columns: - columns.append(column_name) - columns.append(column2_name) - update_columns = False + except Exception as err: + logger.error(f"Exception while parsing domain managers for reports: {err}") - return update_columns, columns, max_dm_total + 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 = None + 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_domains_csv( 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 """ with Timer(): + # 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_dict = _get_security_emails(sec_contact_ids) paginator = Paginator(all_domain_infos, 1000) - # We get the number of domain managers (DMs) an the domain - # that has the most DMs so we can set the header row appropriately - - max_dm_total = 0 - update_columns = False - - invites_with_invited_status=None - domain_invitation_emails = {} - domain_permissions_emails = {} - - if get_domain_managers: - invites_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 invites_with_invited_status: - domain_name = invite.domain.name - email = invite.email - if domain_name not in domain_invitation_emails: - domain_invitation_emails[domain_name] = [] - domain_invitation_emails[domain_name].append(email) - - domain_permissions = UserDomainRole.objects.all() - - # Iterate through each domain invitation and populate the dictionary - for permission in domain_permissions: - domain_name = permission.domain.name - email = permission.user.email - if domain_name not in domain_permissions_emails: - domain_permissions_emails[domain_name] = [] - domain_permissions_emails[domain_name].append(email) - - # logger.info(f'domain_invitation_emails {domain_invitation_emails}') - - # This var will live outside of the nested for loops to aggregate - # the data from those loops + # 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 max number of domain managers - if get_domain_managers: - update_columns, columns, max_dm_total = ( - update_columns_with_domain_managers( - domain_info, domain_invitation_emails,domain_permissions_emails, update_columns, columns, max_dm_total - ) + 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, domain_invitation_emails,domain_permissions_emails) + row = parse_domain_row( + columns, + domain_info, + dict_security_emails_dict, + 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) @@ -399,7 +414,7 @@ def export_data_type_to_csv(csv_file): ], } write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True + writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True ) @@ -432,7 +447,7 @@ def export_data_full_to_csv(csv_file): ], } write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -466,7 +481,7 @@ def export_data_federal_to_csv(csv_file): ], } write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -536,19 +551,19 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date): } write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) write_domains_csv( 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. @@ -639,7 +654,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( @@ -663,7 +678,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( @@ -688,7 +703,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date): columns, sort_fields, filter_managed_domains_end_date, - get_domain_managers=True, + should_get_domain_managers=True, should_write_header=True, ) @@ -712,7 +727,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( @@ -736,7 +751,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( @@ -761,7 +776,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date): columns, sort_fields, filter_unmanaged_domains_end_date, - get_domain_managers=False, + should_get_domain_managers=False, 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], From 77d1158c1b34b8c40bd139009bb5c7784fad4149 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 2 Apr 2024 21:49:42 -0400 Subject: [PATCH 43/80] Clean up --- src/registrar/tests/test_reports.py | 20 ++--- src/registrar/utility/csv_export.py | 123 ++++++++++++++-------------- 2 files changed, 71 insertions(+), 72 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index b4861560f..b34f3d920 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,7 +242,7 @@ class ExportDataTest(MockDb, MockEppLib): } self.maxDiff = None # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, @@ -273,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""" @@ -309,7 +309,7 @@ class ExportDataTest(MockDb, MockEppLib): ], } # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, @@ -367,7 +367,7 @@ class ExportDataTest(MockDb, MockEppLib): ], } # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, @@ -448,7 +448,7 @@ class ExportDataTest(MockDb, MockEppLib): } # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, @@ -456,7 +456,7 @@ class ExportDataTest(MockDb, MockEppLib): should_get_domain_managers=False, should_write_header=True, ) - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields_for_deleted_domains, @@ -528,7 +528,7 @@ class ExportDataTest(MockDb, MockEppLib): } self.maxDiff = None # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, @@ -673,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 diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index faad25b28..7605d5bfe 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -12,7 +12,6 @@ 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.models.utility.generic_helper import Timer from registrar.utility.enums import DefaultEmail logger = logging.getLogger(__name__) @@ -54,7 +53,7 @@ def get_domain_infos(filter_condition, sort_fields): return domain_infos_cleaned -def parse_domain_row( +def parse_row_for_domain( columns, domain_info: DomainInformation, dict_security_emails_dict=None, @@ -231,7 +230,8 @@ def build_dictionaries_for_domain_managers(dict_user_domain_roles, dict_domain_i return dict_user_domain_roles, dict_domain_invitations_with_invited_status -def write_domains_csv( + +def write_csv_for_domains( writer, columns, sort_fields, @@ -246,59 +246,58 @@ def write_domains_csv( should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice """ - with Timer(): - # Retrieve domain information and all sec emails - all_domain_infos = get_domain_infos(filter_condition, sort_fields) - sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True) - dict_security_emails_dict = _get_security_emails(sec_contact_ids) - paginator = Paginator(all_domain_infos, 1000) + # Retrieve domain information and all sec emails + all_domain_infos = get_domain_infos(filter_condition, sort_fields) + sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True) + dict_security_emails_dict = _get_security_emails(sec_contact_ids) + paginator = Paginator(all_domain_infos, 1000) - # Initialize variables - dms_total = 0 - should_update_columns = False - total_body_rows = [] - dict_user_domain_roles = {} - dict_domain_invitations_with_invited_status = {} + # 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 - ) + # 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: - 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, - ) + # Process domain information + for page_num in paginator.page_range: + rows = [] + page = paginator.page(page_num) + for domain_info in page.object_list: + 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, - dict_security_emails_dict, - should_get_domain_managers, - dict_domain_invitations_with_invited_status, - dict_user_domain_roles, - ) - rows.append(row) - except ValueError: - logger.error("csv_export -> Error when parsing row, domain was None") - continue - total_body_rows.extend(rows) + try: + row = parse_row_for_domain( + columns, + domain_info, + dict_security_emails_dict, + should_get_domain_managers, + dict_domain_invitations_with_invited_status, + dict_user_domain_roles, + ) + rows.append(row) + except ValueError: + logger.error("csv_export -> Error when parsing row, domain was None") + continue + total_body_rows.extend(rows) - if should_write_header: - write_header(writer, columns) - writer.writerows(total_body_rows) + if should_write_header: + write_header(writer, columns) + writer.writerows(total_body_rows) def get_requests(filter_condition, sort_fields): @@ -312,7 +311,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" @@ -344,7 +343,7 @@ def parse_request_row(columns, request: DomainRequest): return row -def write_requests_csv( +def write_csv_for_requests( writer, columns, sort_fields, @@ -365,7 +364,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. @@ -413,7 +412,7 @@ def export_data_type_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True ) @@ -446,7 +445,7 @@ def export_data_full_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -480,7 +479,7 @@ def export_data_federal_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -550,10 +549,10 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date): "domain__deleted__gte": start_date_formatted, } - write_domains_csv( + 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, @@ -698,7 +697,7 @@ 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, @@ -771,7 +770,7 @@ 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, @@ -807,4 +806,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) From 9d8d90e45a1f6bf418c9c35cac268601f5b130cf Mon Sep 17 00:00:00 2001 From: Rachid Mrad <107004823+rachidatecs@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:26:23 -0400 Subject: [PATCH 44/80] Update src/registrar/utility/csv_export.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/utility/csv_export.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 7605d5bfe..01eef295a 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -204,7 +204,6 @@ def build_dictionaries_for_domain_managers(dict_user_domain_roles, dict_domain_i """Helper function that builds dicts for invited users and active domain managers. We do so to avoid filtering within loops.""" - user_domain_roles = None user_domain_roles = UserDomainRole.objects.all() # Iterate through each user domain role and populate the dictionary From 602f07f2194629a3fa2130d1d2c5b7224587eb26 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 3 Apr 2024 11:31:46 -0400 Subject: [PATCH 45/80] var name correction --- src/registrar/utility/csv_export.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 01eef295a..949b0adcd 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -56,7 +56,7 @@ def get_domain_infos(filter_condition, sort_fields): def parse_row_for_domain( columns, domain_info: DomainInformation, - dict_security_emails_dict=None, + dict_security_emails=None, should_get_domain_managers=False, dict_domain_invitations_with_invited_status=None, dict_user_domain_roles=None, @@ -72,8 +72,8 @@ def parse_row_for_domain( # Grab the security email from a preset dictionary. # If nothing exists in the dictionary, grab from .contacts. - if dict_security_emails_dict is not None and domain.name in dict_security_emails_dict: - _email = dict_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. @@ -135,7 +135,7 @@ def _get_security_emails(sec_contact_ids): """ Retrieve security contact emails for the given security contact IDs. """ - dict_security_emails_dict = {} + dict_security_emails = {} public_contacts = ( PublicContact.objects.only("email", "domain__name") .select_related("domain") @@ -145,12 +145,12 @@ 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 dict_security_emails_dict: - 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 dict_security_emails_dict + return dict_security_emails def count_domain_managers(domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles): @@ -248,7 +248,7 @@ def write_csv_for_domains( # Retrieve domain information and all sec emails all_domain_infos = get_domain_infos(filter_condition, sort_fields) sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True) - dict_security_emails_dict = _get_security_emails(sec_contact_ids) + dict_security_emails = _get_security_emails(sec_contact_ids) paginator = Paginator(all_domain_infos, 1000) # Initialize variables @@ -283,7 +283,7 @@ def write_csv_for_domains( row = parse_row_for_domain( columns, domain_info, - dict_security_emails_dict, + dict_security_emails, should_get_domain_managers, dict_domain_invitations_with_invited_status, dict_user_domain_roles, From 220560dcedbcf60d4954eae433c1da6152220806 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 3 Apr 2024 16:27:16 -0400 Subject: [PATCH 46/80] Change sort settings and unit test --- src/registrar/admin.py | 4 +- src/registrar/tests/test_admin.py | 74 ++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e0c98b7c2..9673f7df4 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1056,6 +1056,7 @@ class DomainRequestAdmin(ListHeaderAdmin): # Columns list_display = [ "requested_domain", + "submission_date", "status", "generic_org_type", "federal_type", @@ -1064,7 +1065,6 @@ class DomainRequestAdmin(ListHeaderAdmin): "custom_election_board", "city", "state_territory", - "submission_date", "submitter", "investigator", ] @@ -1192,7 +1192,7 @@ class DomainRequestAdmin(ListHeaderAdmin): filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") # Table ordering - ordering = ["requested_domain__name"] + ordering = ["-submission_date", "requested_domain__name"] change_form_template = "django/admin/domain_request_change_form.html" diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 368f30721..8bdd45701 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,75 @@ 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", + "ccc.gov", + "zzz.gov", + "zzz.gov", + "bbb.gov", + "bbb.gov", + "aaa.gov", + "aaa.gov", + "ddd.gov", + "ddd.gov", + "eee.gov", + "eee.gov", + ] + self.assertEqual(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 From d0d312598da04b347ef45af6d240b7d304078be6 Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:49:38 -0700 Subject: [PATCH 47/80] Edit typo on create_groups migration --- src/registrar/migrations/0037_create_groups_v01.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/migrations/0037_create_groups_v01.py b/src/registrar/migrations/0037_create_groups_v01.py index 3540ea2f3..0c04a8b61 100644 --- a/src/registrar/migrations/0037_create_groups_v01.py +++ b/src/registrar/migrations/0037_create_groups_v01.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0036 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] From c25a082aa091caa325cbec2ba326daaa8e59ca77 Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:18:50 -0700 Subject: [PATCH 48/80] Add instructions for user group migrations --- src/registrar/models/user_group.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 2aa2f642e..6211094ec 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -5,6 +5,16 @@ logger = logging.getLogger(__name__) class UserGroup(Group): + """ + UserGroup sets read and write permissions for superusers (who have full access) + and analysts. To update analyst permissions do the following: + 1. Make desired changes to analyst group permissions in user_group.py. + 2. Follow the steps in 0037_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. + """ + class Meta: verbose_name = "User group" verbose_name_plural = "User groups" @@ -49,7 +59,7 @@ class UserGroup(Group): { "app_label": "registrar", "model": "user", - "permissions": ["analyst_access_permission", "change_user"], + "permissions": ["analyst_access_permission", "change_user", "delete_user"], }, { "app_label": "registrar", From c4cf7d5669e156fb755d2034c8e42c3af49f727b Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:27:49 -0700 Subject: [PATCH 49/80] Update dependency typos in user group migrations --- src/registrar/migrations/0038_create_groups_v02.py | 2 +- src/registrar/migrations/0042_create_groups_v03.py | 2 +- src/registrar/migrations/0044_create_groups_v04.py | 2 +- src/registrar/migrations/0053_create_groups_v05.py | 2 +- src/registrar/migrations/0065_create_groups_v06.py | 2 +- src/registrar/migrations/0067_create_groups_v07.py | 2 +- src/registrar/migrations/0075_create_groups_v08.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/registrar/migrations/0038_create_groups_v02.py b/src/registrar/migrations/0038_create_groups_v02.py index fc61db3c0..70d13b61a 100644 --- a/src/registrar/migrations/0038_create_groups_v02.py +++ b/src/registrar/migrations/0038_create_groups_v02.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0037 (which also updates user role permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0042_create_groups_v03.py b/src/registrar/migrations/0042_create_groups_v03.py index 01b7985bf..e30841599 100644 --- a/src/registrar/migrations/0042_create_groups_v03.py +++ b/src/registrar/migrations/0042_create_groups_v03.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0041 (which changes fields in domain request and domain information) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0044_create_groups_v04.py b/src/registrar/migrations/0044_create_groups_v04.py index ecb48e335..63cad49bb 100644 --- a/src/registrar/migrations/0044_create_groups_v04.py +++ b/src/registrar/migrations/0044_create_groups_v04.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0043 (which adds an expiry date field to a domain.) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0053_create_groups_v05.py b/src/registrar/migrations/0053_create_groups_v05.py index aaf74a9db..91e8389df 100644 --- a/src/registrar/migrations/0053_create_groups_v05.py +++ b/src/registrar/migrations/0053_create_groups_v05.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0052 (which alters fields in a domain request) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0065_create_groups_v06.py b/src/registrar/migrations/0065_create_groups_v06.py index d2cb32cee..965dc06a8 100644 --- a/src/registrar/migrations/0065_create_groups_v06.py +++ b/src/registrar/migrations/0065_create_groups_v06.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0065 (which renames fields in domain application) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0067_create_groups_v07.py b/src/registrar/migrations/0067_create_groups_v07.py index 85138d4af..809738ba3 100644 --- a/src/registrar/migrations/0067_create_groups_v07.py +++ b/src/registrar/migrations/0067_create_groups_v07.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0066 (which updates users with permission as Verified by Staff) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0075_create_groups_v08.py b/src/registrar/migrations/0075_create_groups_v08.py index b0b2ed740..a4df52d21 100644 --- a/src/registrar/migrations/0075_create_groups_v08.py +++ b/src/registrar/migrations/0075_create_groups_v08.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0035 (which populates ContentType and Permissions) +# It is dependent on 0074 (which renames Domain Application and its fields) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] From 8e5c1aadbf841559ea6ae17f11399139a07fea72 Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:29:16 -0700 Subject: [PATCH 50/80] Revert user group permission changes from testing --- src/registrar/models/user_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 6211094ec..3071fba11 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -59,7 +59,7 @@ class UserGroup(Group): { "app_label": "registrar", "model": "user", - "permissions": ["analyst_access_permission", "change_user", "delete_user"], + "permissions": ["analyst_access_permission", "change_user"], }, { "app_label": "registrar", From 34232f9b6c8199e5d5136a19972ac1794ddad988 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 4 Apr 2024 10:48:33 -0600 Subject: [PATCH 51/80] Change stlying --- src/registrar/assets/js/get-gov-admin.js | 1 + src/registrar/assets/sass/_theme/_admin.scss | 31 ++++++++++++++ .../assets/sass/_theme/_tooltips.scss | 6 +-- .../templates/admin/input_with_clipboard.html | 6 ++- .../admin/includes/contact_detail_list.html | 41 ++++++++++++++----- .../admin/includes/detail_table_fieldset.html | 27 +++++++----- src/registrar/templates/domain_users.html | 2 +- src/registrar/templates/home.html | 2 +- 8 files changed, 89 insertions(+), 27 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 4ae0af4a5..581c2b899 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -155,6 +155,7 @@ function openInNewTab(el, removeAttribute = false){ navigator.clipboard.writeText(input.value).then(function() { // Change the icon to a checkmark on successful copy let buttonIcon = button .querySelector('.usa-button__clipboard use'); + console.log(`what is the button icon ${buttonIcon}`) if (buttonIcon) { let currentHref = buttonIcon.getAttribute('xlink:href'); let baseHref = currentHref.split('#')[0]; diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index c636aab5c..8b5870b86 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -556,8 +556,39 @@ address.dja-address-contact-list { min-height: 2.25rem !important; } + button { + line-height: 15px; + } + +} + +.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; + } } .no-outline-on-click:focus { outline: none !important; +} + +svg.no-pointer-events { + use { + // USWDS has weird interactions with SVGs regarding tooltips, + // and other components. In this event, we need to disable pointer interactions. + pointer-events: none; + } } \ No newline at end of file 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/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index c2de55bf1..76f76b63f 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -10,10 +10,14 @@ Template for an input field with a clipboard class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard" type="button" > +
+
\ No newline at end of file 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 cded7526b..48a980fbc 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -25,18 +25,37 @@ {# Email #} {% if user.email or user.contact.email %} {% if user.contact.email %} - + {{ user.contact.email }} | + {% else %} - + {{ user.email }} | + {% endif %}
{% else %} 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 53bdbe821..459ffe438 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -84,7 +84,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - + @@ -93,17 +93,24 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) + {% endfor %} 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 %} Date: Thu, 4 Apr 2024 12:58:57 -0400 Subject: [PATCH 52/80] Fix unit test --- src/registrar/tests/test_admin.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 8bdd45701..928b68a62 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -826,19 +826,21 @@ class TestDomainRequestAdmin(MockEppLib): # Verify that the domain names are displayed in the expected order expected_order = [ "ccc.gov", - "ccc.gov", - "zzz.gov", "zzz.gov", "bbb.gov", - "bbb.gov", - "aaa.gov", "aaa.gov", "ddd.gov", - "ddd.gov", - "eee.gov", "eee.gov", ] - self.assertEqual(domain_names, expected_order) + + # 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): """ From 52a4a2d45aaef7471328c0ba1391232f400b7ff8 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 4 Apr 2024 12:41:33 -0600 Subject: [PATCH 53/80] Add copy logic --- src/registrar/assets/js/get-gov-admin.js | 32 +++++++++++++++++-- src/registrar/assets/sass/_theme/_admin.scss | 27 ++++++++++++++++ .../admin/includes/contact_detail_list.html | 21 ++++++++++-- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 581c2b899..a5f71ee0b 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -149,23 +149,49 @@ function openInNewTab(el, removeAttribute = false){ 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'); - console.log(`what is the button icon ${buttonIcon}`) + 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'); + + // Find the nearest .admin-icon-group__success-dialog and update its classes + let brElement = null + let successDialog = document.querySelector(`#email-clipboard__success-dialog-${userId}`); + if (successDialog) { + successDialog.classList.remove('display-none'); + // Find the associated BR if it exists + brElement = successDialog.nextElementSibling + } + + // If the element directly below the success dialog is a br, hide it. + // This is for dynamic styling reasons + if (brElement && brElement.tagName === 'BR' && brElement.classList.contains('admin-icon-group__br')) { + brElement.classList.add('display-none'); + } + setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); + + // Hide the success dialog + if (successDialog){ + successDialog.classList.add("display-none"); + } + + // Show the regular br + if (brElement) { + brElement.classList.remove("display-none"); + } }, 1500); + } }).catch(function(error) { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 8b5870b86..a3f969d9c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -578,6 +578,33 @@ address.dja-address-contact-list { display: inline-flex; padding-top: 4px; line-height: 14px; + color: var(--link-fg); + } +} + +.usa-alert--slim.usa-alert--slim--dja-admin { + .usa-alert__body:before { + top: 0.4rem; + left: 10px + } + .usa-alert__body { + padding-left: 2.5rem; + } +} +@media (prefers-color-scheme: dark) { + .usa-alert--success.usa-alert--slim--dja-admin { + border-left-color: var(--accent); + .usa-alert__body { + background-color: var(--darkened-bg); + } + + .usa-alert__body:before { + background-color: var(--body-quiet-color); + } + + p { + color: var(--body-quiet-color); + } } } 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 48a980fbc..4949d4194 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -43,7 +43,7 @@ {% else %} {{ user.email }} | + {% endif %} -
+
{% else %} None
{% endif %} From 95f9860bd9b0b770a775a2a66700f8dd56335da1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 4 Apr 2024 13:27:40 -0600 Subject: [PATCH 54/80] Add dialog --- src/registrar/assets/js/get-gov-admin.js | 18 ++++++++++++- src/registrar/assets/sass/_theme/_admin.scss | 10 ++++++++ .../admin/includes/contact_detail_list.html | 19 +++++++++++++- .../includes/email_clipboard_fieldset.html | 25 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index a5f71ee0b..1755a2a1c 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -163,9 +163,21 @@ function openInNewTab(el, removeAttribute = false){ buttonIcon.setAttribute('xlink:href', baseHref + '#check'); // Find the nearest .admin-icon-group__success-dialog and update its classes + let parentFlexContainer = null let brElement = null - let successDialog = document.querySelector(`#email-clipboard__success-dialog-${userId}`); + let successDialog = null + if (userId) { + successDialog = document.querySelector(`#email-clipboard__success-dialog-${userId}`); + }else { + successDialog = document.querySelector("#email-clipboard__success-dialog"); + } + if (successDialog) { + if (!userId) { + parentFlexContainer = successDialog.closest('.flex-container'); + // Flex container overrides display-none + parentFlexContainer.classList.remove('dja-important__display-none'); + } successDialog.classList.remove('display-none'); // Find the associated BR if it exists brElement = successDialog.nextElementSibling @@ -190,6 +202,10 @@ function openInNewTab(el, removeAttribute = false){ if (brElement) { brElement.classList.remove("display-none"); } + + if (parentFlexContainer) { + parentFlexContainer.classList.add("dja-important__display-none"); + } }, 1500); } diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index a3f969d9c..8e8294976 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -590,6 +590,12 @@ address.dja-address-contact-list { .usa-alert__body { padding-left: 2.5rem; } + + .admin-icon-group__success-dialog--input { + .usa-alert__body { + padding-right: 53px !important; + } + } } @media (prefers-color-scheme: dark) { .usa-alert--success.usa-alert--slim--dja-admin { @@ -618,4 +624,8 @@ svg.no-pointer-events { // and other components. In this event, we need to disable pointer interactions. pointer-events: none; } +} + +.dja-important__display-none { + display: none !important; } \ No newline at end of file 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 4949d4194..b00e51547 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -27,7 +27,7 @@ {% if user.contact.email %} {{ user.contact.email }} | + {% else %} {{ user.email }} | +{% endif %} \ No newline at end of file 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 b00e51547..8ad6fb96d 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -25,71 +25,11 @@ {# Email #} {% if user.email or user.contact.email %} {% if user.contact.email %} - {{ user.contact.email }} | - - + {{ user.contact.email }} + {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} {% else %} - {{ user.email }} | - - + {{ user.email }} + {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} {% endif %}
{% else %} diff --git a/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html index ecd2a7d83..f959f8edf 100644 --- a/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html +++ b/src/registrar/templates/django/admin/includes/email_clipboard_fieldset.html @@ -11,28 +11,3 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {{ block.super }} {% endif %} {% endblock field_other %} - -{% block after_help_text %} - {% if field.field.name == "email" %} -
- - -
- {% endif %} -{% endblock %} \ No newline at end of file From 6dc6299a0ff3f14dfa4dc479fa9bc28dd7157eab Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 5 Apr 2024 14:09:56 -0600 Subject: [PATCH 57/80] Fix styling --- src/registrar/assets/js/get-gov-admin.js | 2 +- src/registrar/assets/sass/_theme/_admin.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index abe0877c1..844872144 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -170,7 +170,7 @@ function openInNewTab(el, removeAttribute = false){ // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); nearestSpan.innerText = "Copy" - }, 1500); + }, 2000); } diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 94b497e8f..54160b4ff 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -399,13 +399,13 @@ address.margin-top-neg-1__detail-list { } td button.usa-button__clipboard-link, address.dja-address-contact-list { - font-size: 0.8125rem !important; + font-size: unset; } address.dja-address-contact-list { color: var(--body-quiet-color); button.usa-button__clipboard-link { - font-size: 0.8125rem !important; + font-size: unset; } } From 5535b9178e37962e19eb589e1f156f2e3a0feb13 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:09:36 -0600 Subject: [PATCH 58/80] Update detail_table_fieldset.html --- .../django/admin/includes/detail_table_fieldset.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 459ffe438..3ed8c4333 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -97,7 +97,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - From e9332edc8b4a24ebdc7334465fcde84da872f39d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:21:45 -0600 Subject: [PATCH 59/80] Fix button styling --- src/registrar/assets/js/get-gov-admin.js | 7 +++++++ src/registrar/assets/sass/_theme/_admin.scss | 4 ++++ .../django/admin/includes/detail_table_fieldset.html | 8 ++++---- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 844872144..2439d1346 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -164,11 +164,18 @@ function openInNewTab(el, removeAttribute = false){ // Change the button text nearestSpan = button.querySelector("span") + if (button.classList.contains('usa-button')) { + nearestSpan.innerText = "Copy email"; + } else { + nearestSpan.innerText = "Copy"; + } nearestSpan.innerText = "Copied to clipboard" setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); + usa-button__small + if (button.classList.) nearestSpan.innerText = "Copy" }, 2000); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 54160b4ff..fd66754bc 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -597,3 +597,7 @@ address.dja-address-contact-list { .no-outline-on-click:focus { outline: none !important; } + +.usa-button__small-text { + font-size: small; +} 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 3ed8c4333..a0a679290 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -84,7 +84,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
Other contact informationOther contact information
{{ contact.get_formatted_name }} {{ contact.title }} - + {{ contact.email }} + {{ contact.phone }} + + +
{{ contact.phone }} +
- + @@ -97,10 +97,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - From 113b9b62bd2af783bf514c42823a2d75ad77df66 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:25:09 -0600 Subject: [PATCH 60/80] Update get-gov-admin.js --- src/registrar/assets/js/get-gov-admin.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 2439d1346..954035f95 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -164,19 +164,16 @@ function openInNewTab(el, removeAttribute = false){ // Change the button text nearestSpan = button.querySelector("span") - if (button.classList.contains('usa-button')) { - nearestSpan.innerText = "Copy email"; - } else { - nearestSpan.innerText = "Copy"; - } nearestSpan.innerText = "Copied to clipboard" setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); - usa-button__small - if (button.classList.) - nearestSpan.innerText = "Copy" + if (button.classList.contains('usa-button')) { + nearestSpan.innerText = "Copy email"; + } else { + nearestSpan.innerText = "Copy"; + } }, 2000); } From e4976e9da5a5d1dbad3693578f42aaaf111b6b5b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:37:57 -0600 Subject: [PATCH 61/80] Fix err --- src/registrar/assets/js/get-gov-admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 954035f95..2909a48be 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -169,7 +169,7 @@ function openInNewTab(el, removeAttribute = false){ setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); - if (button.classList.contains('usa-button')) { + if (button.classList.contains('usa-button__small-text')) { nearestSpan.innerText = "Copy email"; } else { nearestSpan.innerText = "Copy"; From 3837c3ca21b91e1aba0d2680a86c1f510956a498 Mon Sep 17 00:00:00 2001 From: Alysia Broddrick Date: Fri, 5 Apr 2024 14:50:33 -0700 Subject: [PATCH 62/80] updated files --- ops/manifests/manifest-ab.yaml | 2 +- ops/manifests/manifest-stable.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml index 3ca800392..c7228a7c4 100644 --- a/ops/manifests/manifest-ab.yaml +++ b/ops/manifests/manifest-ab.yaml @@ -5,7 +5,7 @@ applications: - python_buildpack path: ../../src instances: 1 - memory: 512M + memory: 1G stack: cflinuxfs4 timeout: 180 command: ./run.sh 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 From 839b325e66b38d5281e92d941d0300d64fdd1373 Mon Sep 17 00:00:00 2001 From: Alysia Broddrick Date: Fri, 5 Apr 2024 15:59:25 -0700 Subject: [PATCH 63/80] undo my manifest change --- ops/manifests/manifest-ab.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml index c7228a7c4..3ca800392 100644 --- a/ops/manifests/manifest-ab.yaml +++ b/ops/manifests/manifest-ab.yaml @@ -5,7 +5,7 @@ applications: - python_buildpack path: ../../src instances: 1 - memory: 1G + memory: 512M stack: cflinuxfs4 timeout: 180 command: ./run.sh From 9a2e827634d63fbe702c2beb3a06abaac92bff0a Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 08:34:39 -0600 Subject: [PATCH 64/80] Fix unit tests --- src/registrar/tests/test_admin.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index af10dd2eb..bf92b3178 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1455,41 +1455,37 @@ 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-link", count=4) + self.assertContains(response, "usa-button__clipboard", count=4) def test_save_model_sets_restricted_status_on_user(self): with less_console_noise(): @@ -2219,41 +2215,37 @@ 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-link", count=4) + self.assertContains(response, "usa-button__clipboard", count=4) def test_readonly_fields_for_analyst(self): """Ensures that analysts have their permissions setup correctly""" From 1df928dad68b789b3a62f631d635a2910674219e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 08:45:38 -0600 Subject: [PATCH 65/80] Fix test --- src/registrar/tests/test_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 6299349c5..be66cb876 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -723,7 +723,7 @@ class HelperFunctions(MockDb): # 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, 0] + 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): From 88fe4858bc07c697825b9ef5ea307e704c43ad26 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 09:44:40 -0600 Subject: [PATCH 66/80] Add logs --- src/registrar/signals.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index d106f974c..aea20048a 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -103,7 +103,16 @@ def _update_org_type_from_generic_org_and_election(instance, org_map): if generic_org_type not in org_map: # Election board should always be reset to None if the record # can't have one. For example, federal. - instance.is_election_board = None + if 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." + ) + instance.is_election_board = None instance.organization_type = generic_org_type else: # This can only happen with manual data tinkering, which causes these to be out of sync. @@ -137,6 +146,14 @@ def _update_generic_org_and_election_from_org_type(instance, election_org_map, g if current_org_type in generic_org_map: 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." + ) instance.is_election_board = None From 8ce51f5bd9d0dc79b6108064bc83021086ca68fb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 10:06:52 -0600 Subject: [PATCH 67/80] Update admin.py --- src/registrar/admin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 8a2bc2141..31a0247af 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -884,6 +884,8 @@ class DomainInformationAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ + "generic_org_type", + "is_election_board", "organization_type", ] }, @@ -927,7 +929,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 = [ @@ -1121,6 +1123,8 @@ class DomainRequestAdmin(ListHeaderAdmin): "Type of organization", { "fields": [ + "generic_org_type", + "is_election_board", "organization_type", ] }, @@ -1164,7 +1168,7 @@ 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 = [ From 0bf7e1f1a8dad7e1d96bfdb44c052257ec1fcd21 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 11:03:23 -0600 Subject: [PATCH 68/80] Fix tests --- src/registrar/admin.py | 8 +++++++- src/registrar/models/domain_request.py | 2 +- src/registrar/tests/test_admin.py | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 31a0247af..89efc5951 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1168,7 +1168,13 @@ class DomainRequestAdmin(ListHeaderAdmin): ] # Readonly fields for analysts and superusers - readonly_fields = ("other_contacts", "current_websites", "alternative_domains", "generic_org_type", "is_election_board") + 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 = [ diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index fc2864fe4..673d765d1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -100,7 +100,7 @@ class DomainRequest(TimeStampedModel): class OrganizationChoices(models.TextChoices): """ Primary organization choices: - For use in the request experience + For use in the domain request experience Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose """ diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 7b810a2c5..e276da5d8 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1588,6 +1588,8 @@ class TestDomainRequestAdmin(MockEppLib): "other_contacts", "current_websites", "alternative_domains", + "generic_org_type", + "is_election_board", "id", "created_at", "updated_at", @@ -1637,6 +1639,8 @@ class TestDomainRequestAdmin(MockEppLib): "other_contacts", "current_websites", "alternative_domains", + "generic_org_type", + "is_election_board", "creator", "about_your_organization", "requested_domain", @@ -1662,6 +1666,8 @@ class TestDomainRequestAdmin(MockEppLib): "other_contacts", "current_websites", "alternative_domains", + "generic_org_type", + "is_election_board", ] self.assertEqual(readonly_fields, expected_fields) @@ -2259,6 +2265,8 @@ class TestDomainInformationAdmin(TestCase): expected_fields = [ "other_contacts", + "generic_org_type", + "is_election_board", "creator", "type_of_work", "more_organization_information", From df9d0c7ac36f33706285d3f3d29daae9270e15d6 Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Mon, 8 Apr 2024 10:14:51 -0700 Subject: [PATCH 69/80] Add user permission migration docs ot user-permissions.md --- docs/developer/user-permissions.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/developer/user-permissions.md b/docs/developer/user-permissions.md index f7c41492d..4e627b0a5 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 0037_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. From 51d14457929d67b426509bb07996aa9219b958ba Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Mon, 8 Apr 2024 10:15:49 -0700 Subject: [PATCH 70/80] Revert documentation changes to migration files --- src/registrar/migrations/0037_create_groups_v01.py | 2 +- src/registrar/migrations/0038_create_groups_v02.py | 2 +- src/registrar/migrations/0042_create_groups_v03.py | 2 +- src/registrar/migrations/0044_create_groups_v04.py | 2 +- src/registrar/migrations/0053_create_groups_v05.py | 2 +- src/registrar/migrations/0065_create_groups_v06.py | 2 +- src/registrar/migrations/0067_create_groups_v07.py | 2 +- src/registrar/migrations/0075_create_groups_v08.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/registrar/migrations/0037_create_groups_v01.py b/src/registrar/migrations/0037_create_groups_v01.py index 0c04a8b61..3540ea2f3 100644 --- a/src/registrar/migrations/0037_create_groups_v01.py +++ b/src/registrar/migrations/0037_create_groups_v01.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0036 (which populates ContentType and Permissions) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0038_create_groups_v02.py b/src/registrar/migrations/0038_create_groups_v02.py index 70d13b61a..fc61db3c0 100644 --- a/src/registrar/migrations/0038_create_groups_v02.py +++ b/src/registrar/migrations/0038_create_groups_v02.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0037 (which also updates user role permissions) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0042_create_groups_v03.py b/src/registrar/migrations/0042_create_groups_v03.py index e30841599..01b7985bf 100644 --- a/src/registrar/migrations/0042_create_groups_v03.py +++ b/src/registrar/migrations/0042_create_groups_v03.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0041 (which changes fields in domain request and domain information) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0044_create_groups_v04.py b/src/registrar/migrations/0044_create_groups_v04.py index 63cad49bb..ecb48e335 100644 --- a/src/registrar/migrations/0044_create_groups_v04.py +++ b/src/registrar/migrations/0044_create_groups_v04.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0043 (which adds an expiry date field to a domain.) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0053_create_groups_v05.py b/src/registrar/migrations/0053_create_groups_v05.py index 91e8389df..aaf74a9db 100644 --- a/src/registrar/migrations/0053_create_groups_v05.py +++ b/src/registrar/migrations/0053_create_groups_v05.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0052 (which alters fields in a domain request) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0065_create_groups_v06.py b/src/registrar/migrations/0065_create_groups_v06.py index 965dc06a8..d2cb32cee 100644 --- a/src/registrar/migrations/0065_create_groups_v06.py +++ b/src/registrar/migrations/0065_create_groups_v06.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0065 (which renames fields in domain application) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0067_create_groups_v07.py b/src/registrar/migrations/0067_create_groups_v07.py index 809738ba3..85138d4af 100644 --- a/src/registrar/migrations/0067_create_groups_v07.py +++ b/src/registrar/migrations/0067_create_groups_v07.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0066 (which updates users with permission as Verified by Staff) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] diff --git a/src/registrar/migrations/0075_create_groups_v08.py b/src/registrar/migrations/0075_create_groups_v08.py index a4df52d21..b0b2ed740 100644 --- a/src/registrar/migrations/0075_create_groups_v08.py +++ b/src/registrar/migrations/0075_create_groups_v08.py @@ -1,5 +1,5 @@ # This migration creates the create_full_access_group and create_cisa_analyst_group groups -# It is dependent on 0074 (which renames Domain Application and its fields) +# It is dependent on 0035 (which populates ContentType and Permissions) # If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS # in the user_group model then: # [NOT RECOMMENDED] From 12f536a9107e40a0021169a8b6116b7120dd6bfe Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Mon, 8 Apr 2024 11:24:45 -0700 Subject: [PATCH 71/80] Clean up redundant docs in user_group --- src/registrar/models/user_group.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 3071fba11..8565ea288 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -7,12 +7,7 @@ logger = logging.getLogger(__name__) class UserGroup(Group): """ UserGroup sets read and write permissions for superusers (who have full access) - and analysts. To update analyst permissions do the following: - 1. Make desired changes to analyst group permissions in user_group.py. - 2. Follow the steps in 0037_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. + and analysts. For more details, see the dev docs for user-permissions. """ class Meta: From 673a858bc3744df5f23351b550bd4b2e854353ab Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:15:05 -0600 Subject: [PATCH 72/80] Refactor to move logic into save + helper --- src/registrar/models/domain_information.py | 29 +++ src/registrar/models/domain_request.py | 29 +++ .../models/utility/generic_helper.py | 208 ++++++++++++++++++ src/registrar/signals.py | 187 ---------------- 4 files changed, 266 insertions(+), 187 deletions(-) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index f41e7d5c6..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 @@ -235,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 673d765d1..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 @@ -665,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/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 01d4e6b33..fadca2b14 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -35,3 +35,211 @@ 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 + else: + 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 + + 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/signals.py b/src/registrar/signals.py index aea20048a..67d007670 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -9,193 +9,6 @@ from .models import User, Contact, DomainRequest, DomainInformation logger = logging.getLogger(__name__) -@receiver(pre_save, sender=DomainRequest) -@receiver(pre_save, sender=DomainInformation) -def create_or_update_organization_type(sender: DomainRequest | DomainInformation, instance, **kwargs): - """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. - """ - - # == Init variables == # - election_org_choices = DomainRequest.OrgChoicesElectionOffice - - # For any given organization type, return the "_election" variant. - # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION - generic_org_to_org_map = election_org_choices.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_to_generic_org_map = election_org_choices.get_org_election_to_org_generic() - - # A new record is added with organization_type not defined. - # This happens from the regular domain request flow. - is_new_instance = instance.id is None - - if is_new_instance: - - # == Check for invalid conditions before proceeding == # - should_proceed = _validate_new_instance(instance, election_org_to_generic_org_map, generic_org_to_org_map) - 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 = instance.organization_type is None - generic_org_type_needs_update = 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: - _update_org_type_from_generic_org_and_election(instance, generic_org_to_org_map) - elif generic_org_type_needs_update: - _update_generic_org_and_election_from_org_type( - instance, election_org_to_generic_org_map, generic_org_to_org_map - ) - else: - - # == Init variables == # - # Instance is already in the database, fetch its current state - current_instance = sender.objects.get(id=instance.id) - - # Check the new and old values - generic_org_type_changed = instance.generic_org_type != current_instance.generic_org_type - is_election_board_changed = instance.is_election_board != current_instance.is_election_board - organization_type_changed = 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 - if organization_type_needs_update: - _update_org_type_from_generic_org_and_election(instance, generic_org_to_org_map) - elif generic_org_type_needs_update: - _update_generic_org_and_election_from_org_type( - instance, election_org_to_generic_org_map, generic_org_to_org_map - ) - - -def _update_org_type_from_generic_org_and_election(instance, org_map): - """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(instance.generic_org_type) - if generic_org_type not in org_map: - # Election board should always be reset to None if the record - # can't have one. For example, federal. - if 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." - ) - instance.is_election_board = None - instance.organization_type = generic_org_type - else: - # This can only happen with manual data tinkering, which causes these to be out of sync. - if instance.is_election_board is None: - logger.warning("create_or_update_organization_type() -> is_election_board is out of sync. Updating value.") - instance.is_election_board = False - - instance.organization_type = org_map[generic_org_type] if instance.is_election_board else generic_org_type - - -def _update_generic_org_and_election_from_org_type(instance, election_org_map, generic_org_map): - """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(instance.organization_type) - - # This essentially means: "_election" in current_org_type. - if current_org_type in election_org_map: - new_org = election_org_map[current_org_type] - instance.generic_org_type = new_org - instance.is_election_board = True - else: - 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: - 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." - ) - instance.is_election_board = None - - -def _validate_new_instance(instance, election_org_to_generic_org_map, generic_org_to_org_map): - """ - 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 instance.organization_type and instance.generic_org_type: - generic_org_type = str(instance.generic_org_type) - organization_type = str(instance.organization_type) - - # Strip "_election" if it exists - mapped_org_type = 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 generic_org_to_org_map - - election_board_mismatch = (is_election_type != 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 instance.organization_type and not instance.generic_org_type: - return False - else: - return True - - @receiver(post_save, sender=User) def handle_profile(sender, instance, **kwargs): """Method for when a User is saved. From c26618bff155bca977b4cfbda5714509cb14baec Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:20:05 -0600 Subject: [PATCH 73/80] Linting --- src/registrar/models/utility/generic_helper.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index fadca2b14..e332ce038 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -41,6 +41,7 @@ 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 @@ -67,7 +68,7 @@ class CreateOrUpdateOrganizationTypeHelper: self._handle_new_instance() else: self._handle_existing_instance() - + return self.instance def _handle_new_instance(self): @@ -87,7 +88,7 @@ class CreateOrUpdateOrganizationTypeHelper: 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) @@ -123,7 +124,7 @@ class CreateOrUpdateOrganizationTypeHelper: 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. @@ -133,7 +134,7 @@ class CreateOrUpdateOrganizationTypeHelper: # 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: @@ -162,12 +163,14 @@ class CreateOrUpdateOrganizationTypeHelper: 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.") + 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 = 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): @@ -242,4 +245,3 @@ class CreateOrUpdateOrganizationTypeHelper: return False else: return True - From 60e370dfb7013ff9042db60d966ba04b96c06043 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:23:48 -0600 Subject: [PATCH 74/80] Update signals.py --- src/registrar/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 67d007670..4e7768ef4 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -1,9 +1,9 @@ import logging -from django.db.models.signals import pre_save, post_save +from django.db.models.signals import post_save from django.dispatch import receiver -from .models import User, Contact, DomainRequest, DomainInformation +from .models import User, Contact logger = logging.getLogger(__name__) From bf6a70e9fb7ee808d6edb80c9eba538f6731c769 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 8 Apr 2024 15:31:42 -0400 Subject: [PATCH 75/80] comment --- src/registrar/admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 9673f7df4..18bc33db6 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1192,6 +1192,8 @@ class DomainRequestAdmin(ListHeaderAdmin): filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") # Table ordering + # 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" From f73fe9f0421393a014117c6809b42b19e50f292d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:41:46 -0600 Subject: [PATCH 76/80] Move test cases --- src/registrar/tests/test_models.py | 306 ++++++++++++++++++++++++++++ src/registrar/tests/test_signals.py | 306 ---------------------------- 2 files changed, 306 insertions(+), 306 deletions(-) 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_signals.py b/src/registrar/tests/test_signals.py index e950f39fb..7af6012a9 100644 --- a/src/registrar/tests/test_signals.py +++ b/src/registrar/tests/test_signals.py @@ -99,309 +99,3 @@ class TestUserPostSave(TestCase): self.assertEqual(actual.last_name, self.last_name) self.assertEqual(actual.email, self.email) self.assertEqual(actual.phone, self.phone) - - -class TestDomainRequestSignals(TestCase): - """Tests hooked signals 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 TestDomainInformationSignals(TestCase): - """Tests hooked signals on the DomainRequest 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) From 94e62e7520776199bd22cb3dc3c89a35df862124 Mon Sep 17 00:00:00 2001 From: Erin <121973038+erinysong@users.noreply.github.com> Date: Mon, 8 Apr 2024 14:48:04 -0700 Subject: [PATCH 77/80] Add suggested content detail to user permissions docs --- docs/developer/user-permissions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer/user-permissions.md b/docs/developer/user-permissions.md index 4e627b0a5..4919c02ff 100644 --- a/docs/developer/user-permissions.md +++ b/docs/developer/user-permissions.md @@ -26,8 +26,8 @@ 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 0037_create_groups_v01.py to create a duplicate -migration for the updated user group permissions. +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. From 9a87d24d6e955905f18271d1036adee9b93e1c2e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 20:34:53 -0600 Subject: [PATCH 78/80] Update generic_helper.py --- src/registrar/models/utility/generic_helper.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index e332ce038..77a9fd45e 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -189,7 +189,7 @@ class CreateOrUpdateOrganizationTypeHelper: new_org = election_org_map[current_org_type] self.instance.generic_org_type = new_org self.instance.is_election_board = True - else: + elif self.instance.generic_org_type is not None: self.instance.generic_org_type = current_org_type # This basically checks if the given org type @@ -207,6 +207,12 @@ class CreateOrUpdateOrganizationTypeHelper: 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): """ From 06b34f8ed865d6723557b3887b31cd61968f118c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 8 Apr 2024 20:38:08 -0600 Subject: [PATCH 79/80] Update generic_helper.py --- src/registrar/models/utility/generic_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 77a9fd45e..32f767ede 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -189,7 +189,7 @@ class CreateOrUpdateOrganizationTypeHelper: new_org = election_org_map[current_org_type] self.instance.generic_org_type = new_org self.instance.is_election_board = True - elif self.instance.generic_org_type is not None: + elif self.instance.organization_type is not None: self.instance.generic_org_type = current_org_type # This basically checks if the given org type From d27ab1634623ebea36bad37e35ced26ab69d9448 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 9 Apr 2024 08:08:46 -0600 Subject: [PATCH 80/80] Linting --- src/registrar/tests/test_signals.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/tests/test_signals.py b/src/registrar/tests/test_signals.py index 7af6012a9..e796bd12a 100644 --- a/src/registrar/tests/test_signals.py +++ b/src/registrar/tests/test_signals.py @@ -1,7 +1,6 @@ from django.test import TestCase from django.contrib.auth import get_user_model -from registrar.models import Contact, DomainRequest, Domain, DomainInformation -from registrar.tests.common import completed_domain_request +from registrar.models import Contact class TestUserPostSave(TestCase):
Other contact informationOther contact information
{{ contact.phone }} +