From f918720383561bcad51f6ad6a66a3fe8245fa885 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:57:48 -0800 Subject: [PATCH 001/135] Add screenreader text to no other contacts form --- src/registrar/forms/domain_request_wizard.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c7f5571af..a619aec26 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -733,7 +733,14 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm): required=True, # label has to end in a space to get the label_suffix to show label=("No other employees rationale"), - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "You don’t need to provide names of other employees now, \ + but it may slow down our assessment of your eligibility. Describe \ + why there are no other employees who can help verify your request. \ + You can enter up to 1000 characters." + } + ), validators=[ MaxLengthValidator( 1000, From e76571eb50d4eead1429aa936ff96b48259172d2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:05:08 -0800 Subject: [PATCH 002/135] Remove unused legends from radio groups --- src/registrar/forms/domain_request_wizard.py | 1 + .../forms/utility/wizard_form_helper.py | 10 +++++- .../templates/django/forms/label.html | 36 +++++++++++-------- .../templates/includes/input_with_errors.html | 6 +++- src/registrar/templatetags/field_helpers.py | 1 + 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index a619aec26..5d8f23057 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -544,6 +544,7 @@ class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" form_choices = ((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")) + title_label = "Are there other employees who can help verify your request?" field_name = "has_other_contacts" @property diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index eedf5839b..22c70b010 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -239,6 +239,10 @@ class BaseYesNoForm(RegistrarForm): # Default form choice mapping. Default is suitable for most cases. form_choices = ((True, "Yes"), (False, "No")) + # Option to append question to aria label for screenreader accessibility. + # Not added by default. + aria_label = "" + def __init__(self, *args, **kwargs): """Extend the initialization of the form from RegistrarForm __init__""" super().__init__(*args, **kwargs) @@ -256,7 +260,11 @@ class BaseYesNoForm(RegistrarForm): coerce=lambda x: x.lower() == "true" if x is not None else None, choices=self.form_choices, initial=self.get_initial_value(), - widget=forms.RadioSelect, + widget=forms.RadioSelect( + attrs={ + "aria-label": self.aria_label + } + ), error_messages={ "required": self.required_error_message, }, diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 545ccf781..e4d7842fa 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,19 +1,25 @@ -<{{ label_tag }} - class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" - {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} -> - {% if span_for_text %} - {{ field.label }} - {% else %} - {{ field.label }} - {% endif %} + +{% if not type == "radio" and not label_tag == "legend" %} + <{{ label_tag }} + class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" + {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} + > +{% endif %} - {% if widget.attrs.required %} - - {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + {% if span_for_text %} + {{ field.label }} {% else %} - * + {{ field.label }} {% endif %} - {% endif %} - + {% if widget.attrs.required %} + + {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + {% else %} + * + {% endif %} + {% endif %} + +{% if not type == "radio" and not label_tag == "legend" %} + +{% endif %} diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index d1e53968e..d239954a0 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -68,7 +68,11 @@ error messages, if necessary. {% endif %} {# this is the input field, itself #} - {% include widget.template_name %} + {% with aria_label=aria_label %} + {% include widget.template_name %} + {% endwith %} + + {% if append_gov %} .gov diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 8a80a75b9..d2bca13fb 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -169,5 +169,6 @@ def input_with_errors(context, field=None): # noqa: C901 ) # -> {"widget": {"name": ...}} context["widget"] = widget["widget"] + print("context: ", context) return context From 7cf8b8a82ea0a0d3df4885eaae45b1c4d0082dfc Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 22 Nov 2024 10:51:13 -0600 Subject: [PATCH 003/135] Delete contacts and subdomains on delete domain --- src/registrar/models/domain.py | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7fdc56971..03a969471 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1026,6 +1026,26 @@ class Domain(TimeStampedModel, DomainHelper): # if registry error occurs, log the error, and raise it as well logger.error(f"registry error removing client hold: {err}") raise (err) + + def _delete_contacts(self): + """Contacts associated with this domain will be deleted. + RegistryErrors will be logged and raised. Additional + error handling should be provided by the caller. + """ + contacts = self._cache.get("contacts") + for contact in contacts: + self._delete_contact(contact) + + def _delete_subdomains(self): + """Subdomains of this domain should be deleted from the registry. + Subdomains which are used by other domains (eg as a hostname) will + not be deleted. + + Supresses registry error, as registry can disallow delete for various reasons + """ + nameservers = [n[0] for n in self.nameservers] + hostsToDelete = self.createDeleteHostList(nameservers) + self._delete_hosts_if_not_used(hostsToDelete) def _delete_domain(self): """This domain should be deleted from the registry @@ -1431,6 +1451,8 @@ class Domain(TimeStampedModel, DomainHelper): @transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED) def deletedInEpp(self): """Domain is deleted in epp but is saved in our database. + Subdomains will be deleted first if not in use by another domain. + Contacts for this domain will also be deleted. Error handling should be provided by the caller.""" # While we want to log errors, we want to preserve # that information when this function is called. @@ -1438,6 +1460,8 @@ class Domain(TimeStampedModel, DomainHelper): # as doing everything here would reduce reliablity. try: logger.info("deletedInEpp()-> inside _delete_domain") + self._delete_subdomains() + self._delete_contacts() self._delete_domain() self.deleted = timezone.now() except RegistryError as err: @@ -1639,6 +1663,26 @@ class Domain(TimeStampedModel, DomainHelper): ) raise e + + def _delete_contact(self, contact: PublicContact): + """Try to delete a contact. RegistryErrors will be logged. + + raises: + RegistryError: if the registry is unable to delete the contact + """ + logger.info("_delete_contact() -> Attempting to delete contact for %s from domain %s", contact.name, contact.domain) + try: + req = commands.DeletContact(id=contact.registry_id) + return registry.send(req, cleaned=True).res_data[0] + except RegistryError as error: + logger.error( + "Registry threw error when trying to delete contact id %s contact type is %s, error code is\n %s full error is %s", # noqa + contact.registry_id, + contact.contact_type, + error.code, + error, + ) + raise error def is_ipv6(self, ip: str): ip_addr = ipaddress.ip_address(ip) From 36b78e9cea8207cf14b64c11271a3bbe6033464a Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:43:31 -0800 Subject: [PATCH 004/135] Refactor legends from two separate legends to one combined legend --- .../forms/utility/wizard_form_helper.py | 5 +++-- .../templates/django/forms/label.html | 21 +++++++++---------- .../domain_request_other_contacts.html | 11 +++------- src/registrar/templatetags/field_helpers.py | 6 ++++++ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 22c70b010..d48f7af64 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -241,7 +241,8 @@ class BaseYesNoForm(RegistrarForm): # Option to append question to aria label for screenreader accessibility. # Not added by default. - aria_label = "" + title_label = "" + aria_label = title_label.join("") def __init__(self, *args, **kwargs): """Extend the initialization of the form from RegistrarForm __init__""" @@ -262,7 +263,7 @@ class BaseYesNoForm(RegistrarForm): initial=self.get_initial_value(), widget=forms.RadioSelect( attrs={ - "aria-label": self.aria_label + # "aria-label": self.title_label } ), error_messages={ diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index e4d7842fa..eb9604dec 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,25 +1,24 @@ -{% if not type == "radio" and not label_tag == "legend" %} - <{{ label_tag }} - class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" - {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} - > -{% endif %} - +<{{ label_tag }} + class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" + {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} +> + {% if legend_label %} +

{{ legend_label }}

+ {% else %} {% if span_for_text %} {{ field.label }} {% else %} {{ field.label }} {% endif %} + {% endif %} {% if widget.attrs.required %} - {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %} {% else %} * {% endif %} {% endif %} -{% if not type == "radio" and not label_tag == "legend" %} - -{% endif %} + diff --git a/src/registrar/templates/domain_request_other_contacts.html b/src/registrar/templates/domain_request_other_contacts.html index 72e4abd8b..93fafe872 100644 --- a/src/registrar/templates/domain_request_other_contacts.html +++ b/src/registrar/templates/domain_request_other_contacts.html @@ -17,17 +17,12 @@ {% endblock %} {% block form_fields %} -
- -

Are there other employees who can help verify your request?

-
- - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} +
+ {% with add_class="usa-radio__input--tile" add_legend_label="Are there other employees who can help verify your request?" %} {% input_with_errors forms.0.has_other_contacts %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #} - -
+
{% include "includes/required_fields.html" %} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index d2bca13fb..31ceb1072 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -57,6 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901 legend_classes = [] group_classes = [] aria_labels = [] + legend_labels = [] # this will be converted to an attribute string described_by = [] @@ -90,6 +91,8 @@ def input_with_errors(context, field=None): # noqa: C901 label_classes.append(value) elif key == "add_legend_class": legend_classes.append(value) + elif key == "add_legend_label": + legend_labels.append(value) elif key == "add_group_class": group_classes.append(value) @@ -149,6 +152,9 @@ def input_with_errors(context, field=None): # noqa: C901 if legend_classes: context["legend_classes"] = " ".join(legend_classes) + if legend_labels: + context["legend_label"] = " ".join(legend_labels) + if group_classes: context["group_classes"] = " ".join(group_classes) From 3f66face606cf13c916490b1d5bc1e6fdbdded12 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:43:54 -0800 Subject: [PATCH 005/135] Remove outdated comment --- src/registrar/templates/django/forms/label.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index eb9604dec..27407247b 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,4 +1,3 @@ - <{{ label_tag }} class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} From 6891f5c8df34785be4452f81d0cf17a0f37bc754 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 26 Nov 2024 13:56:45 -0600 Subject: [PATCH 006/135] Rework delete from epp --- src/registrar/models/domain.py | 29 +++++++++---- src/registrar/tests/common.py | 30 +++++++++---- src/registrar/tests/test_models_domain.py | 51 ++++++++++++----------- 3 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 03a969471..37ce6c501 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -744,7 +744,12 @@ class Domain(TimeStampedModel, DomainHelper): successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount - self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + try: + self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + except: + # in this case we don't care if there's an error, and it will be logged in the function. + pass + if successTotalNameservers < 2: try: self.dns_needed() @@ -1032,19 +1037,28 @@ class Domain(TimeStampedModel, DomainHelper): RegistryErrors will be logged and raised. Additional error handling should be provided by the caller. """ + logger.info("Deleting contacts for %s", self.name) contacts = self._cache.get("contacts") - for contact in contacts: - self._delete_contact(contact) + logger.debug("Contacts to delete for %s inside _delete_contacts -> %s", self.name, contacts) + if contacts: + for contact in contacts: + self._delete_contact(contact) + def _delete_subdomains(self): """Subdomains of this domain should be deleted from the registry. Subdomains which are used by other domains (eg as a hostname) will not be deleted. - Supresses registry error, as registry can disallow delete for various reasons + raises: + RegistryError: if any subdomain cannot be deleted """ + logger.info("Deleting nameservers for %s", self.name) nameservers = [n[0] for n in self.nameservers] - hostsToDelete = self.createDeleteHostList(nameservers) + logger.info("Nameservers found: %s", nameservers) + hostsToDelete, _ = self.createDeleteHostList(nameservers) + logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) + self._delete_hosts_if_not_used(hostsToDelete) def _delete_domain(self): @@ -1665,7 +1679,7 @@ class Domain(TimeStampedModel, DomainHelper): raise e def _delete_contact(self, contact: PublicContact): - """Try to delete a contact. RegistryErrors will be logged. + """Try to delete a contact from the registry. raises: RegistryError: if the registry is unable to delete the contact @@ -1790,7 +1804,6 @@ class Domain(TimeStampedModel, DomainHelper): """delete the host object in registry, will only delete the host object, if it's not being used by another domain Performs just the DeleteHost epp call - Supresses regstry error, as registry can disallow delete for various reasons Args: hostsToDelete (list[str])- list of nameserver/host names to remove Returns: @@ -1808,6 +1821,8 @@ class Domain(TimeStampedModel, DomainHelper): logger.info("Did not remove host %s because it is in use on another domain." % nameserver) else: logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e)) + + raise e def _fix_unknown_state(self, cleaned): """ diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 4edfbe680..3807534b2 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1279,6 +1279,15 @@ class MockEppLib(TestCase): hosts=["fake.host.com"], ) + infoDomainSharedHost = fakedEppObject( + "sharedHost.gov", + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), + contacts=[], + hosts=[ + "ns1.sharedhost.com", + ], + ) + infoDomainThreeHosts = fakedEppObject( "my-nameserver.gov", cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), @@ -1496,10 +1505,7 @@ class MockEppLib(TestCase): case commands.UpdateHost: return self.mockUpdateHostCommands(_request, cleaned) case commands.DeleteHost: - return MagicMock( - res_data=[self.mockDataHostChange], - code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, - ) + return self.mockDeletHostCommands(_request, cleaned) case commands.CheckDomain: return self.mockCheckDomainCommand(_request, cleaned) case commands.DeleteDomain: @@ -1551,6 +1557,16 @@ class MockEppLib(TestCase): res_data=[self.mockDataHostChange], code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, ) + + def mockDeletHostCommands(self, _request, cleaned): + hosts = getattr(_request, "name", None).hosts + for host in hosts: + if "sharedhost.com" in host: + raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + return MagicMock( + res_data=[self.mockDataHostChange], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) def mockUpdateDomainCommands(self, _request, cleaned): if getattr(_request, "name", None) == "dnssec-invalid.gov": @@ -1563,10 +1579,7 @@ class MockEppLib(TestCase): def mockDeleteDomainCommands(self, _request, cleaned): if getattr(_request, "name", None) == "failDelete.gov": - name = getattr(_request, "name", None) - fake_nameserver = "ns1.failDelete.gov" - if name in fake_nameserver: - raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) return None def mockRenewDomainCommand(self, _request, cleaned): @@ -1636,6 +1649,7 @@ class MockEppLib(TestCase): "subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None), "ddomain3.gov": (self.InfoDomainWithContacts, None), "igorville.gov": (self.InfoDomainWithContacts, None), + "sharingiscaring.gov": (self.infoDomainSharedHost, None), } # Retrieve the corresponding values from the dictionary diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index bbd1e3f54..f39c485c7 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2585,6 +2585,7 @@ class TestAnalystDelete(MockEppLib): self.domain_on_hold, _ = Domain.objects.get_or_create(name="fake-on-hold.gov", state=Domain.State.ON_HOLD) def tearDown(self): + Host.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -2597,39 +2598,39 @@ class TestAnalystDelete(MockEppLib): The deleted date is set. """ - with less_console_noise(): - # Put the domain in client hold - self.domain.place_client_hold() - # Delete it... - self.domain.deletedInEpp() - self.domain.save() - self.mockedSendFunction.assert_has_calls( - [ - call( - commands.DeleteDomain(name="fake.gov"), - cleaned=True, - ) - ] - ) - # Domain itself should not be deleted - self.assertNotEqual(self.domain, None) - # Domain should have the right state - self.assertEqual(self.domain.state, Domain.State.DELETED) - # Domain should have a deleted - self.assertNotEqual(self.domain.deleted, None) - # Cache should be invalidated - self.assertEqual(self.domain._cache, {}) + # with less_console_noise(): + # Put the domain in client hold + self.domain.place_client_hold() + # Delete it... + self.domain.deletedInEpp() + self.domain.save() + self.mockedSendFunction.assert_has_calls( + [ + call( + commands.DeleteDomain(name="fake.gov"), + cleaned=True, + ) + ] + ) + # Domain itself should not be deleted + self.assertNotEqual(self.domain, None) + # Domain should have the right state + self.assertEqual(self.domain.state, Domain.State.DELETED) + # Domain should have a deleted + self.assertNotEqual(self.domain.deleted, None) + # Cache should be invalidated + self.assertEqual(self.domain._cache, {}) def test_deletion_is_unsuccessful(self): """ Scenario: Domain deletion is unsuccessful - When a subdomain exists + When a subdomain exists that is in use by another domain Then a client error is returned of code 2305 And `state` is not set to `DELETED` """ with less_console_noise(): # Desired domain - domain, _ = Domain.objects.get_or_create(name="failDelete.gov", state=Domain.State.ON_HOLD) + domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD) # Put the domain in client hold domain.place_client_hold() # Delete it @@ -2640,7 +2641,7 @@ class TestAnalystDelete(MockEppLib): self.mockedSendFunction.assert_has_calls( [ call( - commands.DeleteDomain(name="failDelete.gov"), + commands.DeleteHost(name=common.HostObjSet(hosts=['ns1.sharedhost.com'])), cleaned=True, ) ] From 394a04d47d173978b03a3ab2ce569dc55681f118 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:28:39 -0800 Subject: [PATCH 007/135] Save changes --- src/registrar/templates/django/forms/label.html | 3 +++ .../templates/domain_request_additional_details.html | 9 ++++++--- src/registrar/templatetags/field_helpers.py | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 27407247b..3fbb6b7af 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -4,6 +4,9 @@ > {% if legend_label %}

{{ legend_label }}

+ {% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %} +

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

+ {% endif %} {% else %} {% if span_for_text %} {{ field.label }} diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 2a581bbd2..454fbc8e4 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -10,14 +10,17 @@ {% block form_fields %}
- + + +

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

+ Select one. * - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 31ceb1072..6cf671931 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -175,6 +175,5 @@ def input_with_errors(context, field=None): # noqa: C901 ) # -> {"widget": {"name": ...}} context["widget"] = widget["widget"] - print("context: ", context) return context From 7b1e2662dd9776cad7f236295223eb15fcfa5768 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:43:30 -0800 Subject: [PATCH 008/135] Add select instructions to radio fields --- src/registrar/templates/django/forms/label.html | 13 ++++++++----- .../domain_request_additional_details.html | 3 --- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 3fbb6b7af..422186522 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -15,12 +15,15 @@ {% endif %} {% endif %} - {% if widget.attrs.required %} + {% if widget.attrs.required %} + + {% if field.widget_type == 'radioselect' %} + Select one. * - {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %} - {% else %} - * - {% endif %} + {% elif field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %} + {% else %} + * {% endif %} + {% endif %} diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 454fbc8e4..d09ec6966 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -15,9 +15,6 @@

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

--> -

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

- - Select one. * {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} From ab05cb7e4674e261de8154ae63a958b26fb91638 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:16:20 -0800 Subject: [PATCH 009/135] Add select text to radios --- src/registrar/assets/sass/_theme/_typography.scss | 3 +++ src/registrar/templates/domain_request_additional_details.html | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index d815ef6dd..3b3958b9b 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -27,6 +27,9 @@ h2 { .usa-form, .usa-form fieldset { font-size: 1rem; + legend em { + font-size: 1rem; + } } .p--blockquote { diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index d09ec6966..74a024c9b 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -16,7 +16,6 @@ --> - Select one. * {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} From b5e4f8b40c0bf062f203d7e6dc05e305d3b05e8b Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 3 Dec 2024 15:04:25 -0600 Subject: [PATCH 010/135] update deletion process and tests --- src/registrar/models/domain.py | 39 ++++++++++++------- src/registrar/tests/common.py | 13 +++++++ src/registrar/tests/test_models_domain.py | 46 ++++++++++++++++++++++- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 37ce6c501..2f5524ab4 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -254,7 +254,7 @@ class Domain(TimeStampedModel, DomainHelper): return not cls.available(domain) @Cache - def contacts(self) -> dict[str, str]: + def registry_contacts(self) -> dict[str, str]: """ Get a dictionary of registry IDs for the contacts for this domain. @@ -263,7 +263,10 @@ class Domain(TimeStampedModel, DomainHelper): { PublicContact.ContactTypeChoices.REGISTRANT: "jd1234", PublicContact.ContactTypeChoices.ADMINISTRATIVE: "sh8013",...} """ - raise NotImplementedError() + if self._cache.get("contacts"): + return self._cache.get("contacts") + else: + return self._get_property("contacts") @Cache def creation_date(self) -> date: @@ -1032,17 +1035,19 @@ class Domain(TimeStampedModel, DomainHelper): logger.error(f"registry error removing client hold: {err}") raise (err) - def _delete_contacts(self): - """Contacts associated with this domain will be deleted. - RegistryErrors will be logged and raised. Additional - error handling should be provided by the caller. + def _delete_nonregistrant_contacts(self): + """Non-registrant contacts associated with this domain will be deleted. + RegistryErrors will be logged and raised. Error + handling should be provided by the caller. """ logger.info("Deleting contacts for %s", self.name) - contacts = self._cache.get("contacts") + contacts = self.registry_contacts logger.debug("Contacts to delete for %s inside _delete_contacts -> %s", self.name, contacts) if contacts: - for contact in contacts: - self._delete_contact(contact) + for contact, id in contacts.items(): + # registrants have to be deleted after the domain + if contact != PublicContact.ContactTypeChoices.REGISTRANT: + self._delete_contact(contact, id) def _delete_subdomains(self): @@ -1067,6 +1072,13 @@ class Domain(TimeStampedModel, DomainHelper): request = commands.DeleteDomain(name=self.name) registry.send(request, cleaned=True) + def _delete_domain_registrant(self): + """This domain's registrant should be deleted from the registry + may raises RegistryError, should be caught or handled correctly by caller""" + registrantID = self.registrant_contact.registry_id + request = commands.DeleteContact(id=registrantID) + registry.send(request, cleaned=True) + def __str__(self) -> str: return self.name @@ -1475,8 +1487,9 @@ class Domain(TimeStampedModel, DomainHelper): try: logger.info("deletedInEpp()-> inside _delete_domain") self._delete_subdomains() - self._delete_contacts() + self._delete_nonregistrant_contacts() self._delete_domain() + self._delete_domain_registrant() self.deleted = timezone.now() except RegistryError as err: logger.error(f"Could not delete domain. Registry returned error: {err}") @@ -1678,15 +1691,15 @@ class Domain(TimeStampedModel, DomainHelper): raise e - def _delete_contact(self, contact: PublicContact): + def _delete_contact(self, contact_name: str, registry_id: str): """Try to delete a contact from the registry. raises: RegistryError: if the registry is unable to delete the contact """ - logger.info("_delete_contact() -> Attempting to delete contact for %s from domain %s", contact.name, contact.domain) + logger.info("_delete_contact() -> Attempting to delete contact for %s from domain %s", contact_name, self.name) try: - req = commands.DeletContact(id=contact.registry_id) + req = commands.DeleteContact(id=registry_id) return registry.send(req, cleaned=True).res_data[0] except RegistryError as error: logger.error( diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 5bfa63462..ac444c8aa 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1229,6 +1229,7 @@ class MockEppLib(TestCase): common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], + registrant="regContact", ex_date=date(2023, 5, 25), ) @@ -1610,6 +1611,8 @@ class MockEppLib(TestCase): return self.mockInfoContactCommands(_request, cleaned) case commands.CreateContact: return self.mockCreateContactCommands(_request, cleaned) + case commands.DeleteContact: + return self.mockDeleteContactCommands(_request, cleaned) case commands.UpdateDomain: return self.mockUpdateDomainCommands(_request, cleaned) case commands.CreateHost: @@ -1731,6 +1734,7 @@ class MockEppLib(TestCase): # Define a dictionary to map request names to data and extension values request_mappings = { + "fake.gov": (self.mockDataInfoDomain, None), "security.gov": (self.infoDomainNoContact, None), "dnssec-dsdata.gov": ( self.mockDataInfoDomain, @@ -1811,6 +1815,15 @@ class MockEppLib(TestCase): # mocks a contact error on creation raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE) return MagicMock(res_data=[self.mockDataInfoHosts]) + + def mockDeleteContactCommands(self, _request, cleaned): + if getattr(_request, "id", None) == "fail": + raise RegistryError(code=ErrorCode.OBJECT_EXISTS) + else: + return MagicMock( + res_data=[self.mockDataInfoContact], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) def setUp(self): """mock epp send function as this will fail locally""" diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index f39c485c7..73691bb69 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2586,6 +2586,7 @@ class TestAnalystDelete(MockEppLib): def tearDown(self): Host.objects.all().delete() + PublicContact.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -2643,7 +2644,7 @@ class TestAnalystDelete(MockEppLib): call( commands.DeleteHost(name=common.HostObjSet(hosts=['ns1.sharedhost.com'])), cleaned=True, - ) + ), ] ) # Domain itself should not be deleted @@ -2651,6 +2652,49 @@ class TestAnalystDelete(MockEppLib): # State should not have changed self.assertEqual(domain.state, Domain.State.ON_HOLD) + def test_deletion_with_host_and_contacts(self): + """ + Scenario: Domain with related Host and Contacts is Deleted + When a contact and host exists that is tied to this domain + Then `commands.DeleteHost` is sent to the registry + Then `commands.DeleteContact` is sent to the registry + Then `commands.DeleteDomain` is sent to the registry + Then `commands.DeleteContact` is sent to the registry for the registrant contact + And `state` is set to `DELETED` + """ + # with less_console_noise(): + # Desired domain + domain, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.ON_HOLD) + # Put the domain in client hold + domain.place_client_hold() + # Delete it + domain.deletedInEpp() + domain.save() + + # Check that the host and contacts are deleted, order doesn't matter + self.mockedSendFunction.assert_has_calls( + [ + call(commands.DeleteHost(name=common.HostObjSet(hosts=['fake.host.com'])), cleaned=True), + call(commands.DeleteContact(id="securityContact"), cleaned=True), + call(commands.DeleteContact(id="technicalContact"), cleaned=True), + call(commands.DeleteContact(id="adminContact"),cleaned=True,) + ], + any_order=True + ) + + # These calls need to be in order + self.mockedSendFunction.assert_has_calls( + [ + call(commands.DeleteDomain(name="freeman.gov"), cleaned=True), + call(commands.InfoContact(id="regContact"), cleaned=True), + call(commands.DeleteContact(id="regContact"), cleaned=True), + ], + ) + # Domain itself should not be deleted + self.assertNotEqual(domain, None) + # State should have changed + self.assertEqual(domain.state, Domain.State.DELETED) + def test_deletion_ready_fsm_failure(self): """ Scenario: Domain deletion is unsuccessful due to FSM rules From 1beb0947f7273ce200adc62b9851c0fef60feefb Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:26:29 -0800 Subject: [PATCH 011/135] Refactor additional details radio --- src/registrar/assets/sass/_theme/_typography.scss | 1 + .../templates/domain_request_additional_details.html | 12 +----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index 3b3958b9b..c60b7d802 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -29,6 +29,7 @@ h2 { font-size: 1rem; legend em { font-size: 1rem; + margin-bottom: 0.5rem; } } diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 74a024c9b..386a7a4af 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -10,11 +10,6 @@ {% block form_fields %}
- - {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} @@ -30,13 +25,8 @@
- -

Is there anything else you’d like us to know about your domain request?

-
- - Select one. * - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% with add_class="usa-radio__input--tile" add_legend_label="Is there anything else you’d like us to know about your domain request?" %} {% input_with_errors forms.2.has_anything_else_text %} {% endwith %} {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} From 780b53b3fca83544cfa3ef38eaa9e40982a6fe62 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:31:57 -0800 Subject: [PATCH 012/135] Moved required fields text in other contacts form --- src/registrar/templates/domain_request_other_contacts.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_request_other_contacts.html b/src/registrar/templates/domain_request_other_contacts.html index 93fafe872..035fe442b 100644 --- a/src/registrar/templates/domain_request_other_contacts.html +++ b/src/registrar/templates/domain_request_other_contacts.html @@ -9,7 +9,7 @@
  • We typically don’t reach out to these employees, but if contact is necessary, our practice is to coordinate with you first.
  • - + {% include "includes/required_fields.html" %} {% endblock %} {% block form_required_fields_help_text %} @@ -25,7 +25,6 @@
    - {% include "includes/required_fields.html" %} {{ forms.1.management_form }} {# forms.1 is a formset and this iterates over its forms #} {% for form in forms.1.forms %} From 8154d25873208a08234c34490253ecdbb8d867a3 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:55:25 -0800 Subject: [PATCH 013/135] Add aria label for additional details form --- src/registrar/forms/domain_request_wizard.py | 7 ++++++- src/registrar/templates/django/forms/label.html | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5d8f23057..5ce50dc0c 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -789,7 +789,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "Is there anything else you’d like us to know about your domain request? Provide details below. \ + You can enter up to 2000 characters" + } + ), validators=[ MaxLengthValidator( 2000, diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 422186522..2852ce2ba 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -3,7 +3,7 @@ {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} > {% if legend_label %} -

    {{ legend_label }}

    +

    {{ legend_label }}

    {% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %}

    .gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

    {% endif %} From 27868a0aed8f1fe6fad6566788b281ca761dc163 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 11:24:49 -0600 Subject: [PATCH 014/135] minor fixes to tests --- src/registrar/models/domain.py | 10 ++-- src/registrar/tests/common.py | 11 ++-- src/registrar/tests/test_models_domain.py | 71 ++++++++++++----------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 2f5524ab4..9c954b073 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -161,12 +161,12 @@ class Domain(TimeStampedModel, DomainHelper): """Returns a help message for a desired state. If none is found, an empty string is returned""" help_texts = { # For now, unknown has the same message as DNS_NEEDED - cls.UNKNOWN: ("Before this domain can be used, " "you’ll need to add name server addresses."), - cls.DNS_NEEDED: ("Before this domain can be used, " "you’ll need to add name server addresses."), + cls.UNKNOWN: ("Before this domain can be used, " "you'll need to add name server addresses."), + cls.DNS_NEEDED: ("Before this domain can be used, " "you'll need to add name server addresses."), cls.READY: "This domain has name servers and is ready for use.", cls.ON_HOLD: ( "This domain is administratively paused, " - "so it can’t be edited and won’t resolve in DNS. " + "so it can't be edited and won't resolve in DNS. " "Contact help@get.gov for details." ), cls.DELETED: ("This domain has been removed and " "is no longer registered to your organization."), @@ -1060,11 +1060,11 @@ class Domain(TimeStampedModel, DomainHelper): """ logger.info("Deleting nameservers for %s", self.name) nameservers = [n[0] for n in self.nameservers] - logger.info("Nameservers found: %s", nameservers) hostsToDelete, _ = self.createDeleteHostList(nameservers) logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) - self._delete_hosts_if_not_used(hostsToDelete) + for objSet in hostsToDelete: + self._delete_hosts_if_not_used(objSet.hosts) def _delete_domain(self): """This domain should be deleted from the registry diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index ac444c8aa..72a315e9b 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1620,7 +1620,7 @@ class MockEppLib(TestCase): case commands.UpdateHost: return self.mockUpdateHostCommands(_request, cleaned) case commands.DeleteHost: - return self.mockDeletHostCommands(_request, cleaned) + return self.mockDeleteHostCommands(_request, cleaned) case commands.CheckDomain: return self.mockCheckDomainCommand(_request, cleaned) case commands.DeleteDomain: @@ -1673,11 +1673,10 @@ class MockEppLib(TestCase): code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, ) - def mockDeletHostCommands(self, _request, cleaned): - hosts = getattr(_request, "name", None).hosts - for host in hosts: - if "sharedhost.com" in host: - raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + def mockDeleteHostCommands(self, _request, cleaned): + host = getattr(_request, "name", None) + if "sharedhost.com" in host: + raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) return MagicMock( res_data=[self.mockDataHostChange], code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 73691bb69..b013c7811 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -1422,40 +1422,41 @@ class TestRegistrantNameservers(MockEppLib): And `domain.is_active` returns False """ - with less_console_noise(): - self.domainWithThreeNS.nameservers = [(self.nameserver1,)] - expectedCalls = [ - call( - commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None), - cleaned=True, + # with less_console_noise(): + self.domainWithThreeNS.nameservers = [(self.nameserver1,)] + expectedCalls = [ + call( + commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None), + cleaned=True, + ), + call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True), + call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True), + call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True), + call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True), + call( + commands.UpdateDomain( + name=self.domainWithThreeNS.name, + add=[], + rem=[ + common.HostObjSet( + hosts=[ + "ns1.my-nameserver-2.com", + "ns1.cats-are-superior3.com", + ] + ), + ], + nsset=None, + keyset=None, + registrant=None, + auth_info=None, ), - call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True), - call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True), - call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True), - call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True), - call( - commands.UpdateDomain( - name=self.domainWithThreeNS.name, - add=[], - rem=[ - common.HostObjSet( - hosts=[ - "ns1.my-nameserver-2.com", - "ns1.cats-are-superior3.com", - ] - ), - ], - nsset=None, - keyset=None, - registrant=None, - auth_info=None, - ), - cleaned=True, - ), - call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True), - ] - self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True) - self.assertFalse(self.domainWithThreeNS.is_active()) + cleaned=True, + ), + call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True), + ] + + self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True) + self.assertFalse(self.domainWithThreeNS.is_active()) def test_user_replaces_nameservers(self): """ @@ -2642,7 +2643,7 @@ class TestAnalystDelete(MockEppLib): self.mockedSendFunction.assert_has_calls( [ call( - commands.DeleteHost(name=common.HostObjSet(hosts=['ns1.sharedhost.com'])), + commands.DeleteHost(name='ns1.sharedhost.com'), cleaned=True, ), ] @@ -2674,7 +2675,7 @@ class TestAnalystDelete(MockEppLib): # Check that the host and contacts are deleted, order doesn't matter self.mockedSendFunction.assert_has_calls( [ - call(commands.DeleteHost(name=common.HostObjSet(hosts=['fake.host.com'])), cleaned=True), + call(commands.DeleteHost(name='fake.host.com'), cleaned=True), call(commands.DeleteContact(id="securityContact"), cleaned=True), call(commands.DeleteContact(id="technicalContact"), cleaned=True), call(commands.DeleteContact(id="adminContact"),cleaned=True,) From 9437b732c8a475d3b5216ca9c805942e3507b586 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 12:50:28 -0600 Subject: [PATCH 015/135] minor test fix --- src/registrar/tests/test_admin_domain.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index f02b59a91..ee275741c 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -16,6 +16,7 @@ from registrar.models import ( Host, Portfolio, ) +from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole from .common import ( MockSESClient, @@ -59,6 +60,7 @@ class TestDomainAdminAsStaff(MockEppLib): def tearDown(self): super().tearDown() Host.objects.all().delete() + PublicContact.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() From 89253a1626f9e4cf6a24f0d8ed4ef203822a8e30 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 13:37:23 -0600 Subject: [PATCH 016/135] linter fixes --- src/registrar/models/domain.py | 25 ++++++++--------------- src/registrar/tests/common.py | 10 ++++----- src/registrar/tests/test_models_domain.py | 13 +++++++----- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 9c954b073..64d29a21a 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -747,11 +747,7 @@ class Domain(TimeStampedModel, DomainHelper): successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount - try: - self._delete_hosts_if_not_used(hostsToDelete=deleted_values) - except: - # in this case we don't care if there's an error, and it will be logged in the function. - pass + self._delete_hosts_if_not_used(hostsToDelete=deleted_values) if successTotalNameservers < 2: try: @@ -1034,10 +1030,10 @@ class Domain(TimeStampedModel, DomainHelper): # if registry error occurs, log the error, and raise it as well logger.error(f"registry error removing client hold: {err}") raise (err) - + def _delete_nonregistrant_contacts(self): """Non-registrant contacts associated with this domain will be deleted. - RegistryErrors will be logged and raised. Error + RegistryErrors will be logged and raised. Error handling should be provided by the caller. """ logger.info("Deleting contacts for %s", self.name) @@ -1048,8 +1044,7 @@ class Domain(TimeStampedModel, DomainHelper): # registrants have to be deleted after the domain if contact != PublicContact.ContactTypeChoices.REGISTRANT: self._delete_contact(contact, id) - - + def _delete_subdomains(self): """Subdomains of this domain should be deleted from the registry. Subdomains which are used by other domains (eg as a hostname) will @@ -1690,10 +1685,10 @@ class Domain(TimeStampedModel, DomainHelper): ) raise e - + def _delete_contact(self, contact_name: str, registry_id: str): """Try to delete a contact from the registry. - + raises: RegistryError: if the registry is unable to delete the contact """ @@ -1703,10 +1698,8 @@ class Domain(TimeStampedModel, DomainHelper): return registry.send(req, cleaned=True).res_data[0] except RegistryError as error: logger.error( - "Registry threw error when trying to delete contact id %s contact type is %s, error code is\n %s full error is %s", # noqa - contact.registry_id, - contact.contact_type, - error.code, + "Registry threw error when trying to delete contact %s, error: %s", # noqa + contact_name, error, ) raise error @@ -1834,7 +1827,7 @@ class Domain(TimeStampedModel, DomainHelper): logger.info("Did not remove host %s because it is in use on another domain." % nameserver) else: logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e)) - + raise e def _fix_unknown_state(self, cleaned): diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 72a315e9b..79c262cb9 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1672,7 +1672,7 @@ class MockEppLib(TestCase): res_data=[self.mockDataHostChange], code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, ) - + def mockDeleteHostCommands(self, _request, cleaned): host = getattr(_request, "name", None) if "sharedhost.com" in host: @@ -1814,15 +1814,15 @@ class MockEppLib(TestCase): # mocks a contact error on creation raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE) return MagicMock(res_data=[self.mockDataInfoHosts]) - + def mockDeleteContactCommands(self, _request, cleaned): if getattr(_request, "id", None) == "fail": raise RegistryError(code=ErrorCode.OBJECT_EXISTS) else: return MagicMock( - res_data=[self.mockDataInfoContact], - code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, - ) + res_data=[self.mockDataInfoContact], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) def setUp(self): """mock epp send function as this will fail locally""" diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index b013c7811..e381a06fe 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2643,7 +2643,7 @@ class TestAnalystDelete(MockEppLib): self.mockedSendFunction.assert_has_calls( [ call( - commands.DeleteHost(name='ns1.sharedhost.com'), + commands.DeleteHost(name="ns1.sharedhost.com"), cleaned=True, ), ] @@ -2664,7 +2664,7 @@ class TestAnalystDelete(MockEppLib): And `state` is set to `DELETED` """ # with less_console_noise(): - # Desired domain + # Desired domain domain, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.ON_HOLD) # Put the domain in client hold domain.place_client_hold() @@ -2675,12 +2675,15 @@ class TestAnalystDelete(MockEppLib): # Check that the host and contacts are deleted, order doesn't matter self.mockedSendFunction.assert_has_calls( [ - call(commands.DeleteHost(name='fake.host.com'), cleaned=True), + call(commands.DeleteHost(name="fake.host.com"), cleaned=True), call(commands.DeleteContact(id="securityContact"), cleaned=True), call(commands.DeleteContact(id="technicalContact"), cleaned=True), - call(commands.DeleteContact(id="adminContact"),cleaned=True,) + call( + commands.DeleteContact(id="adminContact"), + cleaned=True, + ), ], - any_order=True + any_order=True, ) # These calls need to be in order From f25bb9be055835866c004a827e7241ef0485c1cd Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 16:28:33 -0600 Subject: [PATCH 017/135] include hostname in error messages for shared hosts --- src/registrar/models/domain.py | 23 ++++++- src/registrar/tests/common.py | 1 + src/registrar/tests/test_models_domain.py | 79 +++++++++++------------ 3 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 64d29a21a..61cc539b0 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -231,6 +231,14 @@ class Domain(TimeStampedModel, DomainHelper): """Called during delete. Example: `del domain.registrant`.""" super().__delete__(obj) + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + # If the domain is deleted we don't want the expiration date to be set + if self.state == self.State.DELETED and self.expiration_date: + self.expiration_date = None + super().save(force_insert, force_update, using, update_fields) + @classmethod def available(cls, domain: str) -> bool: """Check if a domain is available. @@ -1054,6 +1062,13 @@ class Domain(TimeStampedModel, DomainHelper): RegistryError: if any subdomain cannot be deleted """ logger.info("Deleting nameservers for %s", self.name) + # check if any nameservers are in use by another domain + hosts = Host.objects.filter(name__regex=r'.+{}'.format(self.name)) + for host in hosts: + if host.domain != self: + logger.error("Host %s in use by another domain: %s", host.name, host.domain) + raise RegistryError("Host in use by another domain: {}".format(host.domain), code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + nameservers = [n[0] for n in self.nameservers] hostsToDelete, _ = self.createDeleteHostList(nameservers) logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) @@ -1070,9 +1085,10 @@ class Domain(TimeStampedModel, DomainHelper): def _delete_domain_registrant(self): """This domain's registrant should be deleted from the registry may raises RegistryError, should be caught or handled correctly by caller""" - registrantID = self.registrant_contact.registry_id - request = commands.DeleteContact(id=registrantID) - registry.send(request, cleaned=True) + if self.registrant_contact: + registrantID = self.registrant_contact.registry_id + request = commands.DeleteContact(id=registrantID) + registry.send(request, cleaned=True) def __str__(self) -> str: return self.name @@ -1486,6 +1502,7 @@ class Domain(TimeStampedModel, DomainHelper): self._delete_domain() self._delete_domain_registrant() self.deleted = timezone.now() + self.expiration_date = None except RegistryError as err: logger.error(f"Could not delete domain. Registry returned error: {err}") raise err diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 79c262cb9..16fa58104 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1676,6 +1676,7 @@ class MockEppLib(TestCase): def mockDeleteHostCommands(self, _request, cleaned): host = getattr(_request, "name", None) if "sharedhost.com" in host: + print("raising registry error") raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) return MagicMock( res_data=[self.mockDataHostChange], diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index e381a06fe..8dfb764e3 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2584,6 +2584,7 @@ class TestAnalystDelete(MockEppLib): super().setUp() self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) self.domain_on_hold, _ = Domain.objects.get_or_create(name="fake-on-hold.gov", state=Domain.State.ON_HOLD) + Host.objects.create(name="ns1.sharingiscaring.gov", domain=self.domain_on_hold) def tearDown(self): Host.objects.all().delete() @@ -2639,15 +2640,9 @@ class TestAnalystDelete(MockEppLib): with self.assertRaises(RegistryError) as err: domain.deletedInEpp() domain.save() + self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) - self.mockedSendFunction.assert_has_calls( - [ - call( - commands.DeleteHost(name="ns1.sharedhost.com"), - cleaned=True, - ), - ] - ) + self.assertEqual(err.msg, "Host in use by another domain: fake-on-hold.gov") # Domain itself should not be deleted self.assertNotEqual(domain, None) # State should not have changed @@ -2663,41 +2658,43 @@ class TestAnalystDelete(MockEppLib): Then `commands.DeleteContact` is sent to the registry for the registrant contact And `state` is set to `DELETED` """ - # with less_console_noise(): - # Desired domain - domain, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.ON_HOLD) - # Put the domain in client hold - domain.place_client_hold() - # Delete it - domain.deletedInEpp() - domain.save() + with less_console_noise(): + # Desired domain + domain, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.ON_HOLD) + # Put the domain in client hold + domain.place_client_hold() + # Delete it + domain.deletedInEpp() + domain.save() - # Check that the host and contacts are deleted, order doesn't matter - self.mockedSendFunction.assert_has_calls( - [ - call(commands.DeleteHost(name="fake.host.com"), cleaned=True), - call(commands.DeleteContact(id="securityContact"), cleaned=True), - call(commands.DeleteContact(id="technicalContact"), cleaned=True), - call( - commands.DeleteContact(id="adminContact"), - cleaned=True, - ), - ], - any_order=True, - ) + # Check that the host and contacts are deleted, order doesn't matter + self.mockedSendFunction.assert_has_calls( + [ + call(commands.DeleteHost(name="fake.host.com"), cleaned=True), + call(commands.DeleteContact(id="securityContact"), cleaned=True), + call(commands.DeleteContact(id="technicalContact"), cleaned=True), + call( + commands.DeleteContact(id="adminContact"), + cleaned=True, + ), + ], + any_order=True, + ) + actual_calls = self.mockedSendFunction.call_args_list + print("actual_calls", actual_calls) - # These calls need to be in order - self.mockedSendFunction.assert_has_calls( - [ - call(commands.DeleteDomain(name="freeman.gov"), cleaned=True), - call(commands.InfoContact(id="regContact"), cleaned=True), - call(commands.DeleteContact(id="regContact"), cleaned=True), - ], - ) - # Domain itself should not be deleted - self.assertNotEqual(domain, None) - # State should have changed - self.assertEqual(domain.state, Domain.State.DELETED) + # These calls need to be in order + self.mockedSendFunction.assert_has_calls( + [ + call(commands.DeleteDomain(name="freeman.gov"), cleaned=True), + call(commands.InfoContact(id="regContact"), cleaned=True), + call(commands.DeleteContact(id="regContact"), cleaned=True), + ], + ) + # Domain itself should not be deleted + self.assertNotEqual(domain, None) + # State should have changed + self.assertEqual(domain.state, Domain.State.DELETED) def test_deletion_ready_fsm_failure(self): """ From dad42264bf6293765c107dac17861bec078b7357 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 16:32:17 -0600 Subject: [PATCH 018/135] add back in less console noise decorator --- src/registrar/tests/test_models_domain.py | 110 +++++++++++----------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 8dfb764e3..8fd2b5411 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -1422,41 +1422,41 @@ class TestRegistrantNameservers(MockEppLib): And `domain.is_active` returns False """ - # with less_console_noise(): - self.domainWithThreeNS.nameservers = [(self.nameserver1,)] - expectedCalls = [ - call( - commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None), - cleaned=True, - ), - call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True), - call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True), - call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True), - call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True), - call( - commands.UpdateDomain( - name=self.domainWithThreeNS.name, - add=[], - rem=[ - common.HostObjSet( - hosts=[ - "ns1.my-nameserver-2.com", - "ns1.cats-are-superior3.com", - ] - ), - ], - nsset=None, - keyset=None, - registrant=None, - auth_info=None, + with less_console_noise(): + self.domainWithThreeNS.nameservers = [(self.nameserver1,)] + expectedCalls = [ + call( + commands.InfoDomain(name=self.domainWithThreeNS.name, auth_info=None), + cleaned=True, ), - cleaned=True, - ), - call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True), - ] + call(commands.InfoHost(name="ns1.my-nameserver-1.com"), cleaned=True), + call(commands.InfoHost(name="ns1.my-nameserver-2.com"), cleaned=True), + call(commands.InfoHost(name="ns1.cats-are-superior3.com"), cleaned=True), + call(commands.DeleteHost(name="ns1.my-nameserver-2.com"), cleaned=True), + call( + commands.UpdateDomain( + name=self.domainWithThreeNS.name, + add=[], + rem=[ + common.HostObjSet( + hosts=[ + "ns1.my-nameserver-2.com", + "ns1.cats-are-superior3.com", + ] + ), + ], + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True), + ] - self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True) - self.assertFalse(self.domainWithThreeNS.is_active()) + self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True) + self.assertFalse(self.domainWithThreeNS.is_active()) def test_user_replaces_nameservers(self): """ @@ -2601,28 +2601,28 @@ class TestAnalystDelete(MockEppLib): The deleted date is set. """ - # with less_console_noise(): - # Put the domain in client hold - self.domain.place_client_hold() - # Delete it... - self.domain.deletedInEpp() - self.domain.save() - self.mockedSendFunction.assert_has_calls( - [ - call( - commands.DeleteDomain(name="fake.gov"), - cleaned=True, - ) - ] - ) - # Domain itself should not be deleted - self.assertNotEqual(self.domain, None) - # Domain should have the right state - self.assertEqual(self.domain.state, Domain.State.DELETED) - # Domain should have a deleted - self.assertNotEqual(self.domain.deleted, None) - # Cache should be invalidated - self.assertEqual(self.domain._cache, {}) + with less_console_noise(): + # Put the domain in client hold + self.domain.place_client_hold() + # Delete it... + self.domain.deletedInEpp() + self.domain.save() + self.mockedSendFunction.assert_has_calls( + [ + call( + commands.DeleteDomain(name="fake.gov"), + cleaned=True, + ) + ] + ) + # Domain itself should not be deleted + self.assertNotEqual(self.domain, None) + # Domain should have the right state + self.assertEqual(self.domain.state, Domain.State.DELETED) + # Domain should have a deleted + self.assertNotEqual(self.domain.deleted, None) + # Cache should be invalidated + self.assertEqual(self.domain._cache, {}) def test_deletion_is_unsuccessful(self): """ From 6fdb763c0249bdc46684a0d8d2e3928a442fae43 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 17:10:45 -0600 Subject: [PATCH 019/135] admin fix --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 40d4befb5..042666619 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1570,7 +1570,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "is_policy_acknowledged", ] - # For each filter_horizontal, init in admin js initFilterHorizontalWidget + # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) From e8fdf0c5d376b2b94647ff782d59adfdf6d957f5 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 10:16:40 -0600 Subject: [PATCH 020/135] revert accidental admin change --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 042666619..40d4befb5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1570,7 +1570,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "is_policy_acknowledged", ] - # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets + # For each filter_horizontal, init in admin js initFilterHorizontalWidget # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) From 3f79b562bd9db55af9eb5aac5bf08c3aca61a962 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 10:58:12 -0600 Subject: [PATCH 021/135] temp test changes --- src/registrar/models/domain.py | 6 +++--- src/registrar/tests/test_views.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 61cc539b0..6ca3676f7 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -161,12 +161,12 @@ class Domain(TimeStampedModel, DomainHelper): """Returns a help message for a desired state. If none is found, an empty string is returned""" help_texts = { # For now, unknown has the same message as DNS_NEEDED - cls.UNKNOWN: ("Before this domain can be used, " "you'll need to add name server addresses."), - cls.DNS_NEEDED: ("Before this domain can be used, " "you'll need to add name server addresses."), + cls.UNKNOWN: ("Before this domain can be used, " "you’ll need to add name server addresses."), + cls.DNS_NEEDED: ("Before this domain can be used, " "you’ll need to add name server addresses."), cls.READY: "This domain has name servers and is ready for use.", cls.ON_HOLD: ( "This domain is administratively paused, " - "so it can't be edited and won't resolve in DNS. " + "so it can’t be edited and won’t resolve in DNS. " "Contact help@get.gov for details." ), cls.DELETED: ("This domain has been removed and " "is no longer registered to your organization."), diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index f46e417be..3c1f1959e 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -169,7 +169,7 @@ class HomeTests(TestWithUser): self.assertContains(response, "You don't have any registered domains.") self.assertContains(response, "Why don't I see my domain when I sign in to the registrar?") - @less_console_noise_decorator + # @less_console_noise_decorator def test_state_help_text(self): """Tests if each domain state has help text""" From 2e841711e112cf0d1482dd42e19d839d86cfbbac Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 11:30:16 -0600 Subject: [PATCH 022/135] fix a test --- src/registrar/models/domain.py | 2 +- src/registrar/tests/test_views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 6ca3676f7..661e958e6 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1154,7 +1154,7 @@ class Domain(TimeStampedModel, DomainHelper): Returns True if expired, False otherwise. """ if self.expiration_date is None: - return True + return self.state != self.State.DELETED now = timezone.now().date() return self.expiration_date < now diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 3c1f1959e..f46e417be 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -169,7 +169,7 @@ class HomeTests(TestWithUser): self.assertContains(response, "You don't have any registered domains.") self.assertContains(response, "Why don't I see my domain when I sign in to the registrar?") - # @less_console_noise_decorator + @less_console_noise_decorator def test_state_help_text(self): """Tests if each domain state has help text""" From aaaa4f21d238e1e46b0010741cf7be55a7a41822 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 12:50:25 -0600 Subject: [PATCH 023/135] fix broken test --- src/registrar/models/domain.py | 13 +++++++------ src/registrar/tests/test_models_domain.py | 2 +- src/registrar/tests/test_reports.py | 8 ++++---- src/registrar/utility/csv_export.py | 3 ++- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 661e958e6..348ccf3ad 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -231,9 +231,7 @@ class Domain(TimeStampedModel, DomainHelper): """Called during delete. Example: `del domain.registrant`.""" super().__delete__(obj) - def save( - self, force_insert=False, force_update=False, using=None, update_fields=None - ): + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): # If the domain is deleted we don't want the expiration date to be set if self.state == self.State.DELETED and self.expiration_date: self.expiration_date = None @@ -1063,12 +1061,15 @@ class Domain(TimeStampedModel, DomainHelper): """ logger.info("Deleting nameservers for %s", self.name) # check if any nameservers are in use by another domain - hosts = Host.objects.filter(name__regex=r'.+{}'.format(self.name)) + hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) for host in hosts: if host.domain != self: logger.error("Host %s in use by another domain: %s", host.name, host.domain) - raise RegistryError("Host in use by another domain: {}".format(host.domain), code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) - + raise RegistryError( + "Host in use by another domain: {}".format(host.domain), + code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, + ) + nameservers = [n[0] for n in self.nameservers] hostsToDelete, _ = self.createDeleteHostList(nameservers) logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 8fd2b5411..e5df19d82 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2640,7 +2640,7 @@ class TestAnalystDelete(MockEppLib): with self.assertRaises(RegistryError) as err: domain.deletedInEpp() domain.save() - + self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) self.assertEqual(err.msg, "Host in use by another domain: fake-on-hold.gov") # Domain itself should not be deleted diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 377216aa4..0c3fad51a 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -880,18 +880,18 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): "Email,Organization admin,Invited by,Joined date,Last active,Domain requests," "Member management,Domain management,Number of domains,Domains\n" # Content + "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,Viewer,True,1,cdomain1.gov\n" + "cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer,Viewer,False,0,\n" + "icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer Requester,Manager,False,0,\n" "meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None," 'Manager,True,2,"adomain2.gov,cdomain1.gov"\n' - "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,Viewer,True,1,cdomain1.gov\n" - "tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,None,False,0,\n" - "icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer Requester,Manager,False,0,\n" - "cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer,Viewer,False,0,\n" "nonexistentmember_1@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Manager,False,0,\n" "nonexistentmember_2@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Viewer,False,0,\n" "nonexistentmember_3@igorville.gov,False,help@get.gov,Unretrieved,Invited,Viewer,None,False,0,\n" "nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved," "Invited,Viewer Requester,Manager,False,0,\n" "nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited,Viewer,Viewer,False,0,\n" + "tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,None,False,0,\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index a03e51de5..48a5f9e2d 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -415,7 +415,8 @@ class MemberExport(BaseExport): .values(*shared_columns) ) - return convert_queryset_to_dict(permissions.union(invitations), is_model=False) + members = permissions.union(invitations).order_by("email_display") + return convert_queryset_to_dict(members, is_model=False) @classmethod def get_invited_by_query(cls, object_id_query): From b9ea3d884665f863f18cc3852af6f1de6b97a469 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:31:58 -0800 Subject: [PATCH 024/135] Save changes --- src/registrar/forms/domain_request_wizard.py | 7 +------ .../templates/domain_request_additional_details.html | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5ce50dc0c..5d8f23057 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -789,12 +789,7 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea( - attrs={ - "aria-label": "Is there anything else you’d like us to know about your domain request? Provide details below. \ - You can enter up to 2000 characters" - } - ), + widget=forms.Textarea(), validators=[ MaxLengthValidator( 2000, diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 386a7a4af..6e28e5869 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -34,7 +34,7 @@

    Provide details below. *

    - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_label="Is there anything else you’d like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} From 8b473d5e1846d80d4495ff5b375a2136c8b14f53 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 13:55:32 -0600 Subject: [PATCH 025/135] add error message to registry errors --- src/epplibwrapper/errors.py | 3 ++- src/registrar/admin.py | 5 ++--- src/registrar/models/domain.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index 2b7bdd255..4ded1e5a7 100644 --- a/src/epplibwrapper/errors.py +++ b/src/epplibwrapper/errors.py @@ -62,9 +62,10 @@ class RegistryError(Exception): - 2501 - 2502 Something malicious or abusive may have occurred """ - def __init__(self, *args, code=None, **kwargs): + def __init__(self, *args, code=None, msg=None,**kwargs): super().__init__(*args, **kwargs) self.code = code + self.msg = msg def should_retry(self): return self.code == ErrorCode.COMMAND_FAILED diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 40d4befb5..6bafbab08 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2916,18 +2916,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): except RegistryError as err: # Using variables to get past the linter message1 = f"Cannot delete Domain when in state {obj.state}" - message2 = "This subdomain is being used as a hostname on another domain" # Human-readable mappings of ErrorCodes. Can be expanded. error_messages = { # noqa on these items as black wants to reformat to an invalid length ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1, - ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2, + ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: err.msg, } message = "Cannot connect to the registry" if not err.is_connection_error(): # If nothing is found, will default to returned err - message = error_messages.get(err.code, err) + message = error_messages[err.code] self.message_user(request, f"Error deleting this Domain: {message}", messages.ERROR) except TransitionNotAllowed: if obj.state == Domain.State.DELETED: diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 348ccf3ad..f4922bfdd 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1066,7 +1066,7 @@ class Domain(TimeStampedModel, DomainHelper): if host.domain != self: logger.error("Host %s in use by another domain: %s", host.name, host.domain) raise RegistryError( - "Host in use by another domain: {}".format(host.domain), + msg="Host in use by another domain: {}".format(host.domain), code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, ) From 3dbafb52207d2c64af201a736f53e510b123b5c8 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 14:21:02 -0600 Subject: [PATCH 026/135] up log level --- ops/manifests/manifest-ms.yaml | 2 +- src/epplibwrapper/errors.py | 4 ++-- src/registrar/admin.py | 1 + src/registrar/models/domain.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ops/manifests/manifest-ms.yaml b/ops/manifests/manifest-ms.yaml index 153ee5f08..ac46f5d92 100644 --- a/ops/manifests/manifest-ms.yaml +++ b/ops/manifests/manifest-ms.yaml @@ -20,7 +20,7 @@ applications: # Tell Django where it is being hosted DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov # Tell Django how much stuff to log - DJANGO_LOG_LEVEL: INFO + DJANGO_LOG_LEVEL: DEBUG # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index 4ded1e5a7..d30ae93ea 100644 --- a/src/epplibwrapper/errors.py +++ b/src/epplibwrapper/errors.py @@ -62,10 +62,10 @@ class RegistryError(Exception): - 2501 - 2502 Something malicious or abusive may have occurred """ - def __init__(self, *args, code=None, msg=None,**kwargs): + def __init__(self, *args, code=None, note=None,**kwargs): super().__init__(*args, **kwargs) self.code = code - self.msg = msg + self.note = note def should_retry(self): return self.code == ErrorCode.COMMAND_FAILED diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 6bafbab08..81e4772e5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2916,6 +2916,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): except RegistryError as err: # Using variables to get past the linter message1 = f"Cannot delete Domain when in state {obj.state}" + message2 = f"This subdomain is being used as a hostname on another domain: {err.note}" # Human-readable mappings of ErrorCodes. Can be expanded. error_messages = { # noqa on these items as black wants to reformat to an invalid length diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index f4922bfdd..e3a2c910a 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1066,8 +1066,8 @@ class Domain(TimeStampedModel, DomainHelper): if host.domain != self: logger.error("Host %s in use by another domain: %s", host.name, host.domain) raise RegistryError( - msg="Host in use by another domain: {}".format(host.domain), code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, + note=host.domain, ) nameservers = [n[0] for n in self.nameservers] From a9710dafde51eb48b09327f9ac6a786861c56b46 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 14:50:49 -0600 Subject: [PATCH 027/135] more debugging --- src/epplibwrapper/errors.py | 1 + src/registrar/admin.py | 2 +- src/registrar/models/domain.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index d30ae93ea..0f6ee2722 100644 --- a/src/epplibwrapper/errors.py +++ b/src/epplibwrapper/errors.py @@ -65,6 +65,7 @@ class RegistryError(Exception): def __init__(self, *args, code=None, note=None,**kwargs): super().__init__(*args, **kwargs) self.code = code + # note is a string that can be used to provide additional context self.note = note def should_retry(self): diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 81e4772e5..d26566c63 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2921,7 +2921,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): error_messages = { # noqa on these items as black wants to reformat to an invalid length ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1, - ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: err.msg, + ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2, } message = "Cannot connect to the registry" diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index e3a2c910a..19c4f6a8d 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1505,7 +1505,7 @@ class Domain(TimeStampedModel, DomainHelper): self.deleted = timezone.now() self.expiration_date = None except RegistryError as err: - logger.error(f"Could not delete domain. Registry returned error: {err}") + logger.error(f"Could not delete domain. Registry returned error: {err}. Additional context: {err.note}") raise err except TransitionNotAllowed as err: logger.error("Could not delete domain. FSM failure: {err}") From 5e7823a6ecd3390703691063a84134363720afd3 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 15:10:16 -0600 Subject: [PATCH 028/135] more debugging --- src/registrar/models/domain.py | 2 +- src/registrar/tests/common.py | 2 +- src/registrar/tests/test_admin_domain.py | 51 +++++++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 19c4f6a8d..6596232f6 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1486,7 +1486,7 @@ class Domain(TimeStampedModel, DomainHelper): self._remove_client_hold() # TODO -on the client hold ticket any additional error handling here - @transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED) + @transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED, State.UNKNOWN], target=State.DELETED) def deletedInEpp(self): """Domain is deleted in epp but is saved in our database. Subdomains will be deleted first if not in use by another domain. diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 16fa58104..0f7923083 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1677,7 +1677,7 @@ class MockEppLib(TestCase): host = getattr(_request, "name", None) if "sharedhost.com" in host: print("raising registry error") - raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note="otherdomain.gov") return MagicMock( res_data=[self.mockDataHostChange], code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index ee275741c..57961605d 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -172,7 +172,7 @@ class TestDomainAdminAsStaff(MockEppLib): @less_console_noise_decorator def test_deletion_is_successful(self): """ - Scenario: Domain deletion is unsuccessful + Scenario: Domain deletion is successful When the domain is deleted Then a user-friendly success message is returned for displaying on the web And `state` is set to `DELETED` @@ -223,6 +223,55 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertEqual(domain.state, Domain.State.DELETED) + # @less_console_noise_decorator + def test_deletion_is_unsuccessful(self): + """ + Scenario: Domain deletion is unsuccessful + When the domain is deleted and has shared subdomains + Then a user-friendly success message is returned for displaying on the web + And `state` is not set to `DELETED` + """ + domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD) + # Put in client hold + domain.place_client_hold() + # Ensure everything is displaying correctly + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove from registry") + + # The contents of the modal should exist before and after the post. + # Check for the header + self.assertContains(response, "Are you sure you want to remove this domain from the registry?") + + # Check for some of its body + self.assertContains(response, "When a domain is removed from the registry:") + + # Check for some of the button content + self.assertContains(response, "Yes, remove from registry") + + # Test the info dialog + request = self.factory.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_delete_domain": "Remove from registry", "name": domain.name}, + follow=True, + ) + request.user = self.client + with patch("django.contrib.messages.add_message") as mock_add_message: + self.admin.do_delete_domain(request, domain) + mock_add_message.assert_called_once_with( + request, + messages.ERROR, + "Error deleting this Domain: This subdomain is being used as a hostname on another domain: otherdomain.gov", + extra_tags="", + fail_silently=False, + ) + + self.assertEqual(domain.state, Domain.State.ON_HOLD) + @less_console_noise_decorator def test_deletion_ready_fsm_failure(self): """ From 6ff3901c91f021e123feba9ef04328c35988e3b1 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:43:41 -0800 Subject: [PATCH 029/135] Add aria label to anything else text field --- src/registrar/forms/domain_request_wizard.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5d8f23057..ca1313184 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -789,7 +789,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "Is there anything else you’d like us to know about your domain request? \ + Provide details below. You can enter up to 2000 characters" + } + ), validators=[ MaxLengthValidator( 2000, From 4671c8967fcbf1d3ae98c8b53e2425300abe9f95 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:10:57 -0800 Subject: [PATCH 030/135] Refactor legend heading --- src/registrar/assets/sass/_theme/_base.scss | 8 ++++++++ src/registrar/assets/sass/_theme/_typography.scss | 3 +-- src/registrar/templates/django/forms/label.html | 4 ++-- .../templates/domain_request_additional_details.html | 8 ++++---- .../templates/domain_request_other_contacts.html | 2 +- src/registrar/templatetags/field_helpers.py | 10 +++++----- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index db1599621..9a6818f09 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -149,6 +149,14 @@ footer { color: color('primary'); } +.usa-footer { + margin-bottom: 0.5rem; +} + +.usa-radio { + margin-top: 1rem; +} + abbr[title] { // workaround for underlining abbr element border-bottom: none; diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index c60b7d802..952fc6dad 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -27,9 +27,8 @@ h2 { .usa-form, .usa-form fieldset { font-size: 1rem; - legend em { + .usa-legend { font-size: 1rem; - margin-bottom: 0.5rem; } } diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 2852ce2ba..3783c0fef 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -2,8 +2,8 @@ class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} > - {% if legend_label %} -

    {{ legend_label }}

    + {% if legend_heading %} +

    {{ legend_heading }}

    {% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %}

    .gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

    {% endif %} diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 6e28e5869..ba7a7f441 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -9,9 +9,9 @@ {% block form_fields %} -
    +
    - {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} + {% with add_class="usa-radio__input--tile" add_legend_heading="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} @@ -26,7 +26,7 @@
    - {% with add_class="usa-radio__input--tile" add_legend_label="Is there anything else you’d like us to know about your domain request?" %} + {% with add_class="usa-radio__input--tile" add_legend_heading="Is there anything else you’d like us to know about your domain request?" %} {% input_with_errors forms.2.has_anything_else_text %} {% endwith %} {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} @@ -34,7 +34,7 @@

    Provide details below. *

    - {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_label="Is there anything else you’d like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_heading="Is there anything else you’d like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} diff --git a/src/registrar/templates/domain_request_other_contacts.html b/src/registrar/templates/domain_request_other_contacts.html index 035fe442b..b3c1be8b4 100644 --- a/src/registrar/templates/domain_request_other_contacts.html +++ b/src/registrar/templates/domain_request_other_contacts.html @@ -18,7 +18,7 @@ {% block form_fields %}
    - {% with add_class="usa-radio__input--tile" add_legend_label="Are there other employees who can help verify your request?" %} + {% with add_class="usa-radio__input--tile" add_legend_heading="Are there other employees who can help verify your request?" %} {% input_with_errors forms.0.has_other_contacts %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 6cf671931..a1f662fba 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -57,7 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901 legend_classes = [] group_classes = [] aria_labels = [] - legend_labels = [] + legend_headings = [] # this will be converted to an attribute string described_by = [] @@ -91,8 +91,8 @@ def input_with_errors(context, field=None): # noqa: C901 label_classes.append(value) elif key == "add_legend_class": legend_classes.append(value) - elif key == "add_legend_label": - legend_labels.append(value) + elif key == "add_legend_heading": + legend_headings.append(value) elif key == "add_group_class": group_classes.append(value) @@ -152,8 +152,8 @@ def input_with_errors(context, field=None): # noqa: C901 if legend_classes: context["legend_classes"] = " ".join(legend_classes) - if legend_labels: - context["legend_label"] = " ".join(legend_labels) + if legend_headings: + context["legend_heading"] = " ".join(legend_headings) if group_classes: context["group_classes"] = " ".join(group_classes) From 6854310449f81c28678e88022441cb44a3f8921c Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:55:16 -0800 Subject: [PATCH 031/135] Refactor how usa-legend is assigned --- src/registrar/assets/sass/_theme/_base.scss | 1 + src/registrar/templates/domain_request_additional_details.html | 2 +- src/registrar/templatetags/field_helpers.py | 3 --- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 9a6818f09..f6a9eef90 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -155,6 +155,7 @@ footer { .usa-radio { margin-top: 1rem; + font-size: 1.06rem; } abbr[title] { diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index ba7a7f441..86fa79fa3 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -11,7 +11,7 @@
    - {% with add_class="usa-radio__input--tile" add_legend_heading="Are you working with a CISA regional representative on your domain request?" %} + {% with add_class="usa-radio__input--tile" add_legend_class="margin-top-0" add_legend_heading="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index a1f662fba..426caf9bc 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -119,9 +119,6 @@ def input_with_errors(context, field=None): # noqa: C901 else: context["label_tag"] = "label" - if field.use_fieldset: - label_classes.append("usa-legend") - if field.widget_type == "checkbox": label_classes.append("usa-checkbox__label") elif not field.use_fieldset: From 5b2ecbcf8520c50927bd8d6c17881e7b1842f5cc Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:08:59 -0800 Subject: [PATCH 032/135] Remove unused param --- src/registrar/forms/domain_request_wizard.py | 1 - src/registrar/forms/utility/wizard_form_helper.py | 11 +---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 4a0403e08..5ec8deb24 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -547,7 +547,6 @@ class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" form_choices = ((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")) - title_label = "Are there other employees who can help verify your request?" field_name = "has_other_contacts" @property diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index d48f7af64..e87998372 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -239,11 +239,6 @@ class BaseYesNoForm(RegistrarForm): # Default form choice mapping. Default is suitable for most cases. form_choices = ((True, "Yes"), (False, "No")) - # Option to append question to aria label for screenreader accessibility. - # Not added by default. - title_label = "" - aria_label = title_label.join("") - def __init__(self, *args, **kwargs): """Extend the initialization of the form from RegistrarForm __init__""" super().__init__(*args, **kwargs) @@ -261,11 +256,7 @@ class BaseYesNoForm(RegistrarForm): coerce=lambda x: x.lower() == "true" if x is not None else None, choices=self.form_choices, initial=self.get_initial_value(), - widget=forms.RadioSelect( - attrs={ - # "aria-label": self.title_label - } - ), + widget=forms.RadioSelect(), error_messages={ "required": self.required_error_message, }, From 762bda225ad85ecb7154fc44c1d58045c2162c46 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:10:41 -0800 Subject: [PATCH 033/135] Remove unused change --- src/registrar/assets/src/sass/_theme/_base.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index dd1c375d9..62f9f436e 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -149,10 +149,6 @@ footer { color: color('primary'); } -.usa-footer { - margin-bottom: 0.5rem; -} - .usa-radio { margin-top: 1rem; font-size: 1.06rem; From 904629734c4fec4cce0dea0ec6b5b4ddb222b786 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:13:41 -0800 Subject: [PATCH 034/135] Remove unused parentheses --- src/registrar/forms/utility/wizard_form_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index e87998372..eedf5839b 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -256,7 +256,7 @@ class BaseYesNoForm(RegistrarForm): coerce=lambda x: x.lower() == "true" if x is not None else None, choices=self.form_choices, initial=self.get_initial_value(), - widget=forms.RadioSelect(), + widget=forms.RadioSelect, error_messages={ "required": self.required_error_message, }, From 8dfb183ce08e384e4dc35664b969ad0fab54a9c9 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:38:46 -0700 Subject: [PATCH 035/135] Copy template --- .../portfolio_member_permissions.html | 143 +++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 02d120360..ca816ee2d 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -1,4 +1,145 @@ {% extends 'portfolio_base.html' %} +{% load static url_helpers %} +{% load field_helpers %} + +{% block title %}Organization member{% endblock %} + +{% block wrapper_class %} + {{ block.super }} dashboard--grey-1 +{% endblock %} + +{% block portfolio_content %} + + +{% include "includes/form_errors.html" with form=form %} +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock messages%} + + + + + +{% block new_member_header %} +

    Member access and permissions

    +{% endblock new_member_header %} + +{% include "includes/required_fields.html" %} + +
    + {% csrf_token %} +
    + +

    Member email

    +
    + {% comment %} TODO should default to name {% endcomment %} +

    + {% if member %} + {{ member.email }} + {% elif invitation %} + {{ invitation.email }} + {% endif %} +

    + +
    + + +
    + +

    Member Access

    +
    + + Select the level of access for this member. * + + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} +
    + {% for radio in form.member_access_level %} + {{ radio.tag }} + + {% endfor %} +
    + {% endwith %} + +
    + + +
    +

    Admin access permissions

    +

    Member permissions available for admin-level acccess.

    + +

    Organization domain requests

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.admin_org_domain_request_permissions %} + {% endwith %} + +

    Organization members

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.admin_org_members_permissions %} + {% endwith %} +
    + + +
    +

    Basic member permissions

    +

    Member permissions available for basic-level acccess.

    + +

    Organization domain requests

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.basic_org_domain_request_permissions %} + {% endwith %} +
    + + +
    + Cancel + + + +
    +
    + +{% endblock portfolio_content%} + +{% comment %} {% extends 'portfolio_base.html' %} {% load static field_helpers%} {% block title %}Organization member {% endblock %} @@ -39,4 +180,4 @@
    -{% endblock %} +{% endblock %} {% endcomment %} From e87c4f78f111553ab0d185d9e242a2714703ee26 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 9 Dec 2024 13:34:34 -0600 Subject: [PATCH 036/135] use update function to delete hosts --- src/registrar/models/domain.py | 5 +++-- src/registrar/tests/test_admin_domain.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 6596232f6..c768838d5 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1074,8 +1074,9 @@ class Domain(TimeStampedModel, DomainHelper): hostsToDelete, _ = self.createDeleteHostList(nameservers) logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) - for objSet in hostsToDelete: - self._delete_hosts_if_not_used(objSet.hosts) + self.addAndRemoveHostsFromDomain(None, hostsToDelete=nameservers) + # for objSet in hostsToDelete: + # self._delete_hosts_if_not_used(objSet.hosts) def _delete_domain(self): """This domain should be deleted from the registry diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 57961605d..aed4795a6 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -228,7 +228,7 @@ class TestDomainAdminAsStaff(MockEppLib): """ Scenario: Domain deletion is unsuccessful When the domain is deleted and has shared subdomains - Then a user-friendly success message is returned for displaying on the web + Then a user-friendly error message is returned for displaying on the web And `state` is not set to `DELETED` """ domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD) From dffae9163e7ccaa88388bd0c3ffd082c28c994fe Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:20:58 -0800 Subject: [PATCH 037/135] Update portfolio screenreader additional details form --- .../portfolio_domain_request_additional_details.html | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index 5bc529243..1adc4e308 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,15 +6,9 @@ {% endblock %} {% block form_fields %} - -
    -

    Is there anything else you’d like us to know about your domain request?

    - -
    -

    This question is optional.

    - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% with attr_maxlength=2000 add_legend_heading="Is there anything else you’d like us to know about your domain request?" add_aria_label="This question is optional." %} {% input_with_errors forms.0.anything_else %} {% endwith %}
    From 1764155a8ba1579a2ba0500d9a23b9b55aff59d0 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:22:53 -0800 Subject: [PATCH 038/135] Revert portfolio form screenreader changes --- .../portfolio_domain_request_additional_details.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index 1adc4e308..5bc529243 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,9 +6,15 @@ {% endblock %} {% block form_fields %} + +
    +

    Is there anything else you’d like us to know about your domain request?

    + +
    +

    This question is optional.

    - {% with attr_maxlength=2000 add_legend_heading="Is there anything else you’d like us to know about your domain request?" add_aria_label="This question is optional." %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} {% input_with_errors forms.0.anything_else %} {% endwith %}
    From ed9d21557793f13f268577c719eaaf6f4dffd250 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:03:39 -0700 Subject: [PATCH 039/135] Form structure --- src/registrar/forms/portfolio.py | 179 +++++++++++++++--- .../models/utility/portfolio_helper.py | 17 ++ .../django/forms/widgets/multiple_input.html | 14 +- .../portfolio_member_permissions.html | 54 ++---- src/registrar/templatetags/custom_filters.py | 8 + 5 files changed, 201 insertions(+), 71 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5309f7263..65911200b 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -110,52 +110,169 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data -class PortfolioMemberForm(forms.ModelForm): +class BasePortfolioMemberForm(forms.ModelForm): + role = forms.ChoiceField( + label="Select permission", + choices=[ + (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin Access"), + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic Access") + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + # Permissions for admins + domain_request_permissions_admin = forms.ChoiceField( + label="Select permission", + choices=[ + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Create and edit requests") + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + member_permissions_admin = forms.ChoiceField( + label="Select permission", + choices=[ + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), + (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Admin member permission is required", + }, + ) + domain_request_permissions_member = forms.ChoiceField( + label="Select permission", + choices=[ + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), + (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Basic member permission is required", + }, + ) + + # this form dynamically shows/hides some fields, depending on what + # was selected prior. This toggles which field is required or not. + ROLE_REQUIRED_FIELDS = { + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ + "domain_request_permissions_admin", + "member_permissions_admin", + ], + UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ + "domain_request_permissions_member", + ], + } + + def _map_instance_to_form(self, instance): + """Maps model instance data to form fields""" + if not instance: + return {} + mapped_data = {} + # Map roles with priority for admin + if instance.roles: + if UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value in instance.roles: + mapped_data['role'] = UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value + else: + mapped_data['role'] = UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value + + perms = UserPortfolioPermission.get_portfolio_permissions(instance.roles, instance.additional_permissions) + # Map permissions with priority for edit permissions + if perms: + if UserPortfolioPermissionChoices.EDIT_REQUESTS.value in perms: + mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_REQUESTS.value + elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value in perms: + mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + + if UserPortfolioPermissionChoices.EDIT_MEMBERS.value in perms: + mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_MEMBERS.value + elif UserPortfolioPermissionChoices.VIEW_MEMBERS.value in perms: + mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_MEMBERS.value + + return mapped_data + + def _map_form_to_instance(self, instance): + """Maps form data to model instance""" + if not self.is_valid(): + return + + role = self.cleaned_data.get("role") + domain_request_permissions_member = self.cleaned_data.get("domain_request_permissions_member") + domain_request_permissions_admin = self.cleaned_data.get('domain_request_permissions_admin') + member_permissions_admin = self.cleaned_data.get('member_permissions_admin') + + instance.roles = [role] + additional_permissions = [] + if domain_request_permissions_member: + additional_permissions.append(domain_request_permissions_member) + elif domain_request_permissions_admin: + additional_permissions.append(domain_request_permissions_admin) + + if member_permissions_admin: + additional_permissions.append(member_permissions_admin) + + instance.additional_permissions = additional_permissions + return instance + + def clean(self): + cleaned_data = super().clean() + role = cleaned_data.get("role") + + # Get required fields for the selected role. + # Then validate all required fields for the role. + required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) + for field_name in required_fields: + if not cleaned_data.get(field_name): + self.add_error( + field_name, + self.fields.get(field_name).error_messages.get("required") + ) + + return cleaned_data + + +class PortfolioMemberForm(BasePortfolioMemberForm): """ Form for updating a portfolio member. """ - - roles = forms.MultipleChoiceField( - choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Roles", - ) - - additional_permissions = forms.MultipleChoiceField( - choices=UserPortfolioPermissionChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Additional Permissions", - ) - class Meta: model = UserPortfolioPermission fields = [ "roles", "additional_permissions", ] + def __init__(self, *args, instance=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields['role'].descriptions = { + "organization_admin": UserPortfolioRoleChoices.get_role_description(UserPortfolioRoleChoices.ORGANIZATION_ADMIN), + "organization_member": UserPortfolioRoleChoices.get_role_description(UserPortfolioRoleChoices.ORGANIZATION_MEMBER) + } + self.instance = instance + self.initial = self._map_instance_to_form(self.instance) + + def save(self): + """Save form data to instance""" + if not self.instance: + self.instance = self.Meta.model() + self._map_form_to_instance(self.instance) + self.instance.save() + return self.instance -class PortfolioInvitedMemberForm(forms.ModelForm): +class PortfolioInvitedMemberForm(BasePortfolioMemberForm): """ Form for updating a portfolio invited member. """ - roles = forms.MultipleChoiceField( - choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Roles", - ) - - additional_permissions = forms.MultipleChoiceField( - choices=UserPortfolioPermissionChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Additional Permissions", - ) - class Meta: model = PortfolioInvitation fields = [ diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 3768aa77a..60fa2170a 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -17,6 +17,23 @@ class UserPortfolioRoleChoices(models.TextChoices): @classmethod def get_user_portfolio_role_label(cls, user_portfolio_role): return cls(user_portfolio_role).label if user_portfolio_role else None + + @classmethod + def get_role_description(cls, user_portfolio_role): + """Returns a detailed description for a given role.""" + descriptions = { + cls.ORGANIZATION_ADMIN: ( + "Grants this member access to the organization-wide information " + "on domains, domain requests, and members. Domain management can be assigned separately." + ), + cls.ORGANIZATION_MEMBER: ( + "Grants this member access to the organization. They can be given extra permissions to view all " + "organization domain requests and submit domain requests on behalf of the organization. Basic access " + "members can’t view all members of an organization or manage them. " + "Domain management can be assigned separately." + ) + } + return descriptions.get(user_portfolio_role) class UserPortfolioPermissionChoices(models.TextChoices): diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index 90c241366..76e19b169 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -1,3 +1,5 @@ +{% load static custom_filters %} +
    {% for group, options, index in widget.optgroups %} {% if group %}
    {% endif %} @@ -13,7 +15,17 @@ + > + {{ option.label }} + {% comment %} Add a description on each, if available {% endcomment %} + {% if field and field.field and field.field.descriptions %} + {% with description=field.field.descriptions|get_dict_value:option.value %} + {% if description %} +

    {{ description }}

    + {% endif %} + {% endwith %} + {% endif %} + {% endfor %} {% if group %}
    {% endif %} {% endfor %} diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index ca816ee2d..a5f1731d0 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -10,12 +10,6 @@ {% block portfolio_content %} - -{% include "includes/form_errors.html" with form=form %} -{% block messages %} - {% include "includes/form_messages.html" %} -{% endblock messages%} - -{% block new_member_header %}

    Member access and permissions

    -{% endblock new_member_header %} {% include "includes/required_fields.html" %} @@ -45,7 +37,6 @@

    Member email

    - {% comment %} TODO should default to name {% endcomment %}

    {% if member %} {{ member.email }} @@ -64,24 +55,15 @@ Select the level of access for this member. * - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} -

    - {% for radio in form.member_access_level %} - {{ radio.tag }} - - {% endfor %} -
    + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors form.role %} {% endwith %} + {% comment %} {% if radio.value == "organization_admin" %} + Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately. + {% elif radio.value == "organization_member" %} + Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members can’t view all members of an organization or manage them. Domain management can be assigned separately. + {% endif %} {% endcomment %}
    @@ -93,7 +75,7 @@ text-primary-dark margin-bottom-0">Organization domain requests {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.admin_org_domain_request_permissions %} + {% input_with_errors form.domain_request_permissions_admin %} {% endwith %}

    Organization members

    {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.admin_org_members_permissions %} + {% input_with_errors form.member_permissions_admin %} {% endwith %}
    @@ -112,7 +94,7 @@

    Organization domain requests

    {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.basic_org_domain_request_permissions %} + {% input_with_errors form.domain_request_permissions_member %} {% endwith %}
    @@ -123,17 +105,11 @@ href="{% url 'members' %}" class="usa-button usa-button--outline" name="btn-cancel-click" - aria-label="Cancel adding new member" - >Cancel - - - + aria-label="Cancel editing member" + > + Cancel + + diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index e88830156..6140130c8 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -282,3 +282,11 @@ def display_requesting_entity(domain_request): ) return display + + +@register.filter +def get_dict_value(dictionary, key): + """Get a value from a dictionary. Returns a string on empty.""" + if isinstance(dictionary, dict): + return dictionary.get(key, "") + return "" From 278bc60099ba9291da1df8fcf37c0ef838bd5f6b Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 9 Dec 2024 23:33:48 -0700 Subject: [PATCH 040/135] Design request #1 - make portfolio org name editable for analysts --- src/registrar/admin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..17f3fd292 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3533,10 +3533,6 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - analyst_readonly_fields = [ - "organization_name", - ] - def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) From 2730047588e8f75a02ac825b470f9a3130474a0c Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 10 Dec 2024 13:41:48 -0500 Subject: [PATCH 041/135] domain information changes done --- src/registrar/admin.py | 151 +++++++++++++++++- .../getgov-admin/domain-information-form.js | 12 +- .../helpers-portfolio-dynamic-fields.js | 24 +-- 3 files changed, 161 insertions(+), 26 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..7adb7e3ed 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -220,6 +220,14 @@ class DomainInformationAdminForm(forms.ModelForm): fields = "__all__" widgets = { "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + "portfolio": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} + ), + "sub_organization": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("sub_organization"), + admin.site, + attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"}, + ), } @@ -1523,6 +1531,71 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): orderable_fk_fields = [("domain", "name")] + # Define methods to display fields from the related portfolio + def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: + return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None + + portfolio_senior_official.short_description = "Senior official" # type: ignore + + def portfolio_organization_type(self, obj): + return ( + DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) + if obj.portfolio and obj.portfolio.organization_type + else "-" + ) + + portfolio_organization_type.short_description = "Organization type" # type: ignore + + def portfolio_federal_type(self, obj): + return ( + BranchChoices.get_branch_label(obj.portfolio.federal_type) + if obj.portfolio and obj.portfolio.federal_type + else "-" + ) + + portfolio_federal_type.short_description = "Federal type" # type: ignore + + def portfolio_organization_name(self, obj): + return obj.portfolio.organization_name if obj.portfolio else "" + + portfolio_organization_name.short_description = "Organization name" # type: ignore + + def portfolio_federal_agency(self, obj): + return obj.portfolio.federal_agency if obj.portfolio else "" + + portfolio_federal_agency.short_description = "Federal agency" # type: ignore + + def portfolio_state_territory(self, obj): + return obj.portfolio.state_territory if obj.portfolio else "" + + portfolio_state_territory.short_description = "State, territory, or military post" # type: ignore + + def portfolio_address_line1(self, obj): + return obj.portfolio.address_line1 if obj.portfolio else "" + + portfolio_address_line1.short_description = "Address line 1" # type: ignore + + def portfolio_address_line2(self, obj): + return obj.portfolio.address_line2 if obj.portfolio else "" + + portfolio_address_line2.short_description = "Address line 2" # type: ignore + + def portfolio_city(self, obj): + return obj.portfolio.city if obj.portfolio else "" + + portfolio_city.short_description = "City" # type: ignore + + def portfolio_zipcode(self, obj): + return obj.portfolio.zipcode if obj.portfolio else "" + + portfolio_zipcode.short_description = "Zip code" # type: ignore + + def portfolio_urbanization(self, obj): + return obj.portfolio.urbanization if obj.portfolio else "" + + portfolio_urbanization.short_description = "Urbanization" # type: ignore + + # Filters list_filter = [GenericOrgFilter] @@ -1537,16 +1610,36 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): None, { "fields": [ - "portfolio", - "sub_organization", - "creator", "domain_request", "notes", ] }, ), + ( + "Requested by", + { + "fields": [ + "portfolio", + "sub_organization", + "creator", + ] + }, + ), (".gov domain", {"fields": ["domain"]}), - ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}), + ( + "Contacts", + { + "fields": [ + "senior_official", + "portfolio_senior_official", + "other_contacts", + "no_other_contacts_rationale", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + ] + }, + ), ("Background info", {"fields": ["anything_else"]}), ( "Type of organization", @@ -1595,10 +1688,58 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ], }, ), + # the below three sections are for portfolio fields + ( + "Type of organization", + { + "fields": [ + "portfolio_organization_type", + "portfolio_federal_type", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "portfolio_organization_name", + "portfolio_federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + ], + }, + ), ] # Readonly fields for analysts and superusers - readonly_fields = ("other_contacts", "is_election_board") + readonly_fields = ( + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "other_contacts", + "is_election_board" + ) # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ diff --git a/src/registrar/assets/src/js/getgov-admin/domain-information-form.js b/src/registrar/assets/src/js/getgov-admin/domain-information-form.js index 8139c752f..072b73720 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-information-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-information-form.js @@ -1,17 +1,11 @@ -import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.js'; +import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js'; /** - * A function for dynamic DomainInformation fields + * A function for dynamic DomainRequest fields */ export function initDynamicDomainInformationFields(){ const domainInformationPage = document.getElementById("domaininformation_form"); if (domainInformationPage) { - handleSuborganizationFields(); - } - - // DomainInformation is embedded inside domain so this should fire there too - const domainPage = document.getElementById("domain_form"); - if (domainPage) { - handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization"); + handlePortfolioSelection(); } } diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 39f30b87f..ca4c4b44c 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -24,13 +24,13 @@ export function handleSuborganizationFields( function toggleSuborganizationFields() { if (portfolioDropdown.val() && !suborganizationDropdown.val()) { - showElement(requestedSuborgField); - showElement(suborgCity); - showElement(suborgStateTerritory); + if (requestedSuborgField) showElement(requestedSuborgField); + if (suborgCity) showElement(suborgCity); + if (suborgStateTerritory) showElement(suborgStateTerritory); }else { - hideElement(requestedSuborgField); - hideElement(suborgCity); - hideElement(suborgStateTerritory); + if (requestedSuborgField) hideElement(requestedSuborgField); + if (suborgCity) hideElement(suborgCity); + if (suborgStateTerritory) hideElement(suborgStateTerritory); } } @@ -504,14 +504,14 @@ export function handlePortfolioSelection() { if (portfolio_id && !suborganization_id) { // Show suborganization request fields - showElement(requestedSuborganizationField); - showElement(suborganizationCity); - showElement(suborganizationStateTerritory); + if (requestedSuborganizationField) showElement(requestedSuborganizationField); + if (suborganizationCity) showElement(suborganizationCity); + if (suborganizationStateTerritory) showElement(suborganizationStateTerritory); } else { // Hide suborganization request fields if suborganization is selected - hideElement(requestedSuborganizationField); - hideElement(suborganizationCity); - hideElement(suborganizationStateTerritory); + if (requestedSuborganizationField) hideElement(requestedSuborganizationField); + if (suborganizationCity) hideElement(suborganizationCity); + if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory); } } From 787506989742024344ebbb526e8c61ccbae8b223 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 10 Dec 2024 15:37:18 -0500 Subject: [PATCH 042/135] fixed rest of domain information --- src/registrar/admin.py | 4 ++-- .../django/admin/domain_information_change_form.html | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 7adb7e3ed..30b49f17e 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -221,10 +221,10 @@ class DomainInformationAdminForm(forms.ModelForm): widgets = { "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), "portfolio": AutocompleteSelectWithPlaceholder( - DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} + DomainInformation._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} ), "sub_organization": AutocompleteSelectWithPlaceholder( - DomainRequest._meta.get_field("sub_organization"), + DomainInformation._meta.get_field("sub_organization"), admin.site, attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"}, ), diff --git a/src/registrar/templates/django/admin/domain_information_change_form.html b/src/registrar/templates/django/admin/domain_information_change_form.html index c5b0d54b8..487fd97e1 100644 --- a/src/registrar/templates/django/admin/domain_information_change_form.html +++ b/src/registrar/templates/django/admin/domain_information_change_form.html @@ -1,6 +1,13 @@ {% extends 'admin/change_form.html' %} {% load i18n static %} +{% block content %} + {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} + {% url 'get-portfolio-json' as url %} + + {{ block.super }} +{% endblock content %} + {% block field_sets %} {% for fieldset in adminform %} {% comment %} From bc3a96aa87d1d6216a1fcb0105d61b6fcb445353 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:50:30 -0700 Subject: [PATCH 043/135] fine comb --- src/registrar/admin.py | 1 + .../src/js/getgov/portfolio-member-page.js | 2 +- src/registrar/context_processors.py | 2 + src/registrar/forms/portfolio.py | 99 ++++++++++--------- .../django/forms/widgets/multiple_input.html | 2 +- .../portfolio_member_permissions.html | 15 +-- src/registrar/views/portfolios.py | 2 +- 7 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..144d1fcab 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3791,6 +3791,7 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + extra_context["organization_member"] = flag_is_active_for_user(request.user, "organization_member") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index ac0b7cffe..98bcf7d03 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() { * on the Add New Member page. */ export function initAddNewMemberPageListeners() { - add_member_form = document.getElementById("add_member_form") + let add_member_form = document.getElementById("add_member_form") if (!add_member_form){ return; } diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index c1547ad88..5a526f86f 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -107,6 +107,8 @@ def is_widescreen_mode(request): "/no-organization-requests/", "/no-organization-domains/", "/domain-request/", + # "/members/", + # "/member/" ] is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" is_portfolio_widescreen = bool( diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 65911200b..92fd23906 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,7 +4,7 @@ import logging from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator - +from django.utils.safestring import mark_safe from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, @@ -109,13 +109,13 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): cleaned_data.pop("full_name", None) return cleaned_data - -class BasePortfolioMemberForm(forms.ModelForm): +class BasePortfolioMemberForm(forms.Form): + required_star = '*' + role = forms.ChoiceField( - label="Select permission", choices=[ - (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin Access"), - (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic Access") + (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access") ], widget=forms.RadioSelect, required=True, @@ -123,12 +123,12 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Member access level is required", }, ) - # Permissions for admins + domain_request_permissions_admin = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Create and edit requests") + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), ], widget=forms.RadioSelect, required=False, @@ -136,11 +136,12 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Admin domain request permission is required", }, ) + member_permissions_admin = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), ], widget=forms.RadioSelect, required=False, @@ -148,11 +149,13 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Admin member permission is required", }, ) + domain_request_permissions_member = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ - (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + ("no_access", "No access"), ], widget=forms.RadioSelect, required=False, @@ -161,8 +164,6 @@ class BasePortfolioMemberForm(forms.ModelForm): }, ) - # this form dynamically shows/hides some fields, depending on what - # was selected prior. This toggles which field is required or not. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ "domain_request_permissions_admin", @@ -173,10 +174,19 @@ class BasePortfolioMemberForm(forms.ModelForm): ], } + def __init__(self, *args, instance=None, **kwargs): + self.instance = instance + # If we have an instance, set initial + if instance: + kwargs['initial'] = self._map_instance_to_form(instance) + + super().__init__(*args, **kwargs) + def _map_instance_to_form(self, instance): """Maps model instance data to form fields""" if not instance: return {} + mapped_data = {} # Map roles with priority for admin if instance.roles: @@ -192,37 +202,16 @@ class BasePortfolioMemberForm(forms.ModelForm): mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_REQUESTS.value elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value in perms: mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + else: + mapped_data["member_permissions_admin"] = "no_access" if UserPortfolioPermissionChoices.EDIT_MEMBERS.value in perms: mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_MEMBERS.value elif UserPortfolioPermissionChoices.VIEW_MEMBERS.value in perms: mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_MEMBERS.value - + return mapped_data - def _map_form_to_instance(self, instance): - """Maps form data to model instance""" - if not self.is_valid(): - return - - role = self.cleaned_data.get("role") - domain_request_permissions_member = self.cleaned_data.get("domain_request_permissions_member") - domain_request_permissions_admin = self.cleaned_data.get('domain_request_permissions_admin') - member_permissions_admin = self.cleaned_data.get('member_permissions_admin') - - instance.roles = [role] - additional_permissions = [] - if domain_request_permissions_member: - additional_permissions.append(domain_request_permissions_member) - elif domain_request_permissions_admin: - additional_permissions.append(domain_request_permissions_admin) - - if member_permissions_admin: - additional_permissions.append(member_permissions_admin) - - instance.additional_permissions = additional_permissions - return instance - def clean(self): cleaned_data = super().clean() role = cleaned_data.get("role") @@ -239,6 +228,27 @@ class BasePortfolioMemberForm(forms.ModelForm): return cleaned_data + def save(self): + """Save the form data to the instance""" + if not self.instance: + raise ValueError("Cannot save form without instance") + + role = self.cleaned_data.get("role") + self.instance.roles = [self.cleaned_data["role"]] + + additional_permissions = [] + if self.cleaned_data.get("domain_request_permissions_member") and self.cleaned_data["domain_request_permissions_member"] != "no_access": + additional_permissions.append(self.cleaned_data["domain_request_permissions_member"]) + elif self.cleaned_data.get("domain_request_permissions_admin"): + additional_permissions.append(self.cleaned_data["domain_request_permissions_admin"]) + + if self.cleaned_data.get("member_permissions_admin"): + additional_permissions.append(self.cleaned_data["member_permissions_admin"]) + self.instance.additional_permissions = additional_permissions + + self.instance.save() + return self.instance + class PortfolioMemberForm(BasePortfolioMemberForm): """ @@ -250,6 +260,7 @@ class PortfolioMemberForm(BasePortfolioMemberForm): "roles", "additional_permissions", ] + def __init__(self, *args, instance=None, **kwargs): super().__init__(*args, **kwargs) self.fields['role'].descriptions = { @@ -258,14 +269,6 @@ class PortfolioMemberForm(BasePortfolioMemberForm): } self.instance = instance self.initial = self._map_instance_to_form(self.instance) - - def save(self): - """Save form data to instance""" - if not self.instance: - self.instance = self.Meta.model() - self._map_form_to_instance(self.instance) - self.instance.save() - return self.instance class PortfolioInvitedMemberForm(BasePortfolioMemberForm): diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index 76e19b169..cc0e11989 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -21,7 +21,7 @@ {% if field and field.field and field.field.descriptions %} {% with description=field.field.descriptions|get_dict_value:option.value %} {% if description %} -

    {{ description }}

    +

    {{ description }}

    {% endif %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index a5f1731d0..f1db5941c 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -9,6 +9,7 @@ {% endblock %} {% block portfolio_content %} +{% include "includes/form_errors.html" with form=form %}
    -
    +

    Admin access permissions

    Member permissions available for admin-level acccess.

    diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index de27b7059..801dc8791 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2567,7 +2567,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): final_response = self.client.post( reverse("new-member"), { - "member_access_level": "basic", + "role": "organization_member", "basic_org_domain_request_permissions": "view_only", "email": self.new_member_email, }, @@ -2600,7 +2600,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "member_access_level": "basic", + "role": "organization_member", "basic_org_domain_request_permissions": "view_only", "email": self.invited_member_email, }, @@ -2630,7 +2630,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "member_access_level": "basic", + "role": "organization_member", "basic_org_domain_request_permissions": "view_only", "email": self.user.email, }, From d2149484c9c32b1e1e87a1d093882eb410a84e50 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:31:22 -0700 Subject: [PATCH 081/135] fix tests + cleanup --- .../src/js/getgov/portfolio-member-page.js | 9 +------ src/registrar/assets/src/js/getgov/radios.js | 5 +--- src/registrar/forms/portfolio.py | 3 +++ src/registrar/tests/test_views_portfolio.py | 24 +++++++++---------- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 16017959f..e7d797441 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -98,28 +98,21 @@ export function initAddNewMemberPageListeners() { // Get all permission sections (divs with h3 and radio inputs) const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - console.log(`what is the id? ${permission_details_div_id}`) - console.log(`what is the permissionSections? ${permissionSections}`) - permissionSections.forEach(section => { - console.log(`what is the section? ${section}`) // Find the

    element text const sectionTitle = section.textContent; // Find the associated radio buttons container (next fieldset) const fieldset = section.nextElementSibling; - console.log(`what is the fieldset? ${fieldset}`) if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { // Get the selected radio button within this fieldset const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - console.log(`what is the selectedRadio? ${selectedRadio.id}`) // If a radio button is selected, get its label text let selectedPermission = "No permission selected"; if (selectedRadio) { const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); selectedPermission = label ? label.textContent : "No permission selected"; } - console.log(`what is the selectedPermission? ${selectedPermission}`) // Create new elements for the modal content const titleElement = document.createElement("h4"); @@ -149,7 +142,7 @@ export function initAddNewMemberPageListeners() { // Get selected radio button for access level let selectedAccess = document.querySelector('input[name="role"]:checked'); - console.log(`selectedAccess" ${selectedAccess} vs value ${selectedAccess.value}`) + // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) // This value does not have the first letter capitalized so let's capitalize it let accessText = "No access level selected"; diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index c81d18fd2..055bdf621 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -39,7 +39,6 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { // Get the radio buttons let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`); - console.log(`what are the radio buttons? ${radioButtons} vs name: ${radioButtonName}`) // Extract the list of all element IDs from the valueToElementMap let allElementIds = Object.values(valueToElementMap); @@ -50,12 +49,10 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; // Hide all elements by default - console.log(`what are the elementids? ${allElementIds}`) allElementIds.forEach(function (elementId) { let element = document.getElementById(elementId); - console.log(`id? ${elementId} what is the element? ${element}`) if (element) { - hideElement(element); + hideElement(element); } }); diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 4a7417318..5549aa11e 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -124,6 +124,7 @@ class BasePortfolioMemberForm(forms.Form): ) domain_request_permission_admin = forms.ChoiceField( + # nosec B308 - required_star is a hardcoded HTML string label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), @@ -137,6 +138,7 @@ class BasePortfolioMemberForm(forms.Form): ) member_permission_admin = forms.ChoiceField( + # nosec B308 - required_star is a hardcoded HTML string label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), @@ -150,6 +152,7 @@ class BasePortfolioMemberForm(forms.Form): ) domain_request_permission_member = forms.ChoiceField( + # nosec B308 - required_star is a hardcoded HTML string label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 801dc8791..834e1d049 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2568,17 +2568,19 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): reverse("new-member"), { "role": "organization_member", - "basic_org_domain_request_permissions": "view_only", + "domain_request_permission_member": "view_all_requests", "email": self.new_member_email, }, ) # Ensure the final submission is successful self.assertEqual(final_response.status_code, 302) # redirects after success - # Validate Database Changes portfolio_invite = PortfolioInvitation.objects.filter( - email=self.new_member_email, portfolio=self.portfolio + email=self.new_member_email, + portfolio=self.portfolio, + roles__exact=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions__exact=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], ).first() self.assertIsNotNone(portfolio_invite) self.assertEqual(portfolio_invite.email, self.new_member_email) @@ -2601,14 +2603,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): reverse("new-member"), { "role": "organization_member", - "basic_org_domain_request_permissions": "view_only", + "domain_request_permission_member": "view_all_requests", "email": self.invited_member_email, }, ) - self.assertEqual(response.status_code, 302) # Redirects - - # TODO: verify messages - + # Unsucessful form submissions return the same page with a 200 + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["form"].errors["email"][0], "An invitation already exists for this user.") # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) @@ -2631,13 +2632,12 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): reverse("new-member"), { "role": "organization_member", - "basic_org_domain_request_permissions": "view_only", + "domain_request_permissions_member": "view_all_requests", "email": self.user.email, }, ) - self.assertEqual(response.status_code, 302) # Redirects - - # TODO: verify messages + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["form"].errors["email"][0], "User is already a member of this portfolio.") # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() From 24480ec434b77fc9c6d8591cbe7e61f4dcef8340 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:54:52 -0700 Subject: [PATCH 082/135] Polish of paint --- src/registrar/admin.py | 3 +++ src/registrar/forms/portfolio.py | 30 +++++++++++++++++---------- src/registrar/views/portfolios.py | 27 ++++++++---------------- src/registrar/views/utility/mixins.py | 1 + 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 44b8d7345..6afa78a55 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3791,7 +3791,10 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + # Normally you have to first enable the org feature then navigate to an org before you see these. + # Lets just auto-populate it on page load to make development easier. extra_context["organization_members"] = flag_is_active_for_user(request.user, "organization_members") + extra_context["organization_requests"] = flag_is_active_for_user(request.user, "organization_requests") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5549aa11e..9101bcbf8 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -124,8 +124,7 @@ class BasePortfolioMemberForm(forms.Form): ) domain_request_permission_admin = forms.ChoiceField( - # nosec B308 - required_star is a hardcoded HTML string - label=mark_safe(f"Select permission {required_star}"), + label=mark_safe(f"Select permission {required_star}"), # nosec choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), @@ -138,8 +137,7 @@ class BasePortfolioMemberForm(forms.Form): ) member_permission_admin = forms.ChoiceField( - # nosec B308 - required_star is a hardcoded HTML string - label=mark_safe(f"Select permission {required_star}"), + label=mark_safe(f"Select permission {required_star}"), # nosec choices=[ (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), @@ -153,7 +151,7 @@ class BasePortfolioMemberForm(forms.Form): domain_request_permission_member = forms.ChoiceField( # nosec B308 - required_star is a hardcoded HTML string - label=mark_safe(f"Select permission {required_star}"), + label=mark_safe(f"Select permission {required_star}"), # nosec choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), @@ -195,7 +193,7 @@ class BasePortfolioMemberForm(forms.Form): def clean(self): """ Validates form data based on selected role and its required fields. - + Since form fields are dynamically shown/hidden via JavaScript based on role selection, we only validate fields that are relevant to the selected role: - organization_admin: ["member_permission_admin", "domain_request_permission_admin"] @@ -290,17 +288,17 @@ class BasePortfolioMemberForm(forms.Form): def map_cleaned_data_to_instance(self, cleaned_data, instance): """ Maps cleaned data to a member instance, setting roles and permissions. - + Additional permissions logic: - For org admins: Adds domain request and member admin permissions if selected - For other roles: Adds domain request member permissions if not 'no_access' - Automatically adds VIEW permissions when EDIT permissions are granted - Filters out permissions already granted by base role - + Args: cleaned_data (dict): Cleaned data containing role and permission choices instance: Instance to update - + Returns: instance: Updated instance """ @@ -355,7 +353,7 @@ class NewMemberForm(BasePortfolioMemberForm): ) def __init__(self, *args, **kwargs): - self.portfolio = kwargs.pop('portfolio', None) + self.portfolio = kwargs.pop("portfolio", None) super().__init__(*args, **kwargs) def clean(self): @@ -369,7 +367,7 @@ class NewMemberForm(BasePortfolioMemberForm): # Check if user is already a member if UserPortfolioPermission.objects.filter(user__email=email_value, portfolio=self.portfolio).exists(): self.add_error("email", "User is already a member of this portfolio.") - + if PortfolioInvitation.objects.filter(email=email_value, portfolio=self.portfolio).exists(): self.add_error("email", "An invitation already exists for this user.") ########################################## @@ -383,3 +381,13 @@ class NewMemberForm(BasePortfolioMemberForm): # except User.DoesNotExist: # raise forms.ValidationError("User with this email does not exist.") return cleaned_data + + def map_cleaned_data_to_instance(self, cleaned_data, instance): + """Override of the base class to add portfolio and email.""" + instance = super().map_cleaned_data_to_instance(cleaned_data, instance) + email = cleaned_data.get("email") + if email and isinstance(email, str): + email = email.lower() + instance.email = email + instance.portfolio = self.portfolio + return instance diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 7b80221d9..95b7238ff 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -163,21 +163,17 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): def post(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) user = portfolio_permission.user - is_editing_self = request.user == user - form = self.form_class(request.POST, instance=portfolio_permission) if form.is_valid(): # Check if user is removing their own admin or edit role - old_roles = set(portfolio_permission.roles) - new_roles = set(form.cleaned_data.get("role", [])) - removing_admin_role = ( - is_editing_self - and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in old_roles - and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles + removing_admin_role_on_self = ( + request.user == user + and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles + and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", []) ) form.save() messages.success(self.request, "The member access and permission changes have been saved.") - return redirect("member", pk=pk) if not removing_admin_role else redirect("home") + return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home") return render( request, @@ -518,7 +514,7 @@ class NewMemberView(PortfolioInvitationCreatePermissionView): def get_form_kwargs(self): """Pass request and portfolio to form.""" kwargs = super().get_form_kwargs() - kwargs['portfolio'] = self.request.session.get("portfolio") + kwargs["portfolio"] = self.request.session.get("portfolio") return kwargs def get_success_url(self): @@ -535,14 +531,9 @@ class NewMemberView(PortfolioInvitationCreatePermissionView): # if not send_success: # return - # Create instance using form's mapping method - self.object = form.map_cleaned_data_to_instance( - form.cleaned_data, - PortfolioInvitation( - email=form.cleaned_data.get("email"), - portfolio=self.request.session.get("portfolio") - ) - ) + # Create instance using form's mapping method. + # Pass in a new object since we are adding a new record. + self.object = form.map_cleaned_data_to_instance(form.cleaned_data, PortfolioInvitation()) self.object.save() messages.success(self.request, f"{self.object.email} has been invited.") return redirect(self.get_success_url()) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 155fa9f11..e62944c40 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -483,6 +483,7 @@ class PortfolioInvitationCreatePermission(PortfolioBasePermission): portfolio = self.request.session.get("portfolio") return self.request.user.has_edit_members_portfolio_permission(portfolio) + class PortfolioDomainsPermission(PortfolioBasePermission): """Permission mixin that allows access to portfolio domain pages if user has access, otherwise 403""" From 8fcf4a57067869337532a0d735ba682d9c7bc036 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:00:42 -0800 Subject: [PATCH 083/135] Remove duplicate aria text --- src/registrar/forms/domain_request_wizard.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5ec8deb24..c533c653c 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -740,8 +740,7 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm): attrs={ "aria-label": "You don’t need to provide names of other employees now, \ but it may slow down our assessment of your eligibility. Describe \ - why there are no other employees who can help verify your request. \ - You can enter up to 1000 characters." + why there are no other employees who can help verify your request." } ), validators=[ From c551a60e3acaafd15edb9684607ba9ce2323b81a Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 12:29:08 -0700 Subject: [PATCH 084/135] Add Unit tests --- src/registrar/forms/portfolio.py | 6 +- .../models/utility/portfolio_helper.py | 5 +- src/registrar/tests/test_views_portfolio.py | 167 ++++++++++++++++++ src/registrar/views/portfolios.py | 1 - src/zap.conf | 1 + 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 9101bcbf8..85c0faf62 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -274,9 +274,9 @@ class BasePortfolioMemberForm(forms.Form): # Build form data based on role. form_data = { "role": role, - "member_permission_admin": member_permission.value if is_admin else None, - "domain_request_permission_admin": domain_request_permission.value if is_admin else None, - "domain_request_permission_member": domain_request_permission.value if not is_admin else None, + "member_permission_admin": getattr(member_permission, "value", None) if is_admin else None, + "domain_request_permission_admin": getattr(domain_request_permission, "value", None) if is_admin else None, + "domain_request_permission_member": getattr(domain_request_permission, "value", None) if not is_admin else None, } # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 25073639b..cde28e4bd 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -8,6 +8,7 @@ import logging logger = logging.getLogger(__name__) + class UserPortfolioRoleChoices(models.TextChoices): """ Roles make it easier for admins to look at @@ -23,7 +24,7 @@ class UserPortfolioRoleChoices(models.TextChoices): except ValueError: logger.warning(f"Invalid portfolio role: {user_portfolio_role}") return f"Unknown ({user_portfolio_role})" - + @classmethod def get_role_description(cls, user_portfolio_role): """Returns a detailed description for a given role.""" @@ -37,7 +38,7 @@ class UserPortfolioRoleChoices(models.TextChoices): "organization domain requests and submit domain requests on behalf of the organization. Basic access " "members can’t view all members of an organization or manage them. " "Domain management can be assigned separately." - ) + ), } return descriptions.get(user_portfolio_role) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 834e1d049..664d50e87 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2642,3 +2642,170 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) + + +class TestEditPortfolioMemberView(WebTest): + + def setUp(self): + self.user = create_user() + # Create Portfolio + self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio") + + # Add an invited member who has been invited to manage domains + self.invited_member_email = "invited@example.com" + self.invitation = PortfolioInvitation.objects.create( + email=self.invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Assign permissions to the user making requests + UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + def tearDown(self): + PortfolioInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_member_permissions_basic_to_admin(self): + """Tests converting a basic member to admin with full permissions.""" + self.client.force_login(self.user) + + # Create a basic member to edit + basic_member = create_test_user() + basic_permission = UserPortfolioPermission.objects.create( + user=basic_member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS] + ) + + response = self.client.post( + reverse("member-permissions", kwargs={"pk": basic_permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS, + } + ) + + # Verify redirect and success message + self.assertEqual(response.status_code, 302) + + # Verify database changes + basic_permission.refresh_from_db() + self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + # We expect view permissions to be added automagically + self.assertEqual( + set(basic_permission.additional_permissions), + { + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + } + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_member_permissions_validation(self): + """Tests form validation for required fields based on role.""" + self.client.force_login(self.user) + + member = create_test_user() + permission = UserPortfolioPermission.objects.create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + + # Test missing required admin permissions + response = self.client.post( + reverse("member-permissions", kwargs={"pk": permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + # Missing required admin fields + } + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.context["form"].errors["domain_request_permission_admin"][0], + "Admin domain request permission is required" + ) + self.assertEqual( + response.context["form"].errors["member_permission_admin"][0], + "Admin member permission is required" + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_invited_member_permissions(self): + """Tests editing permissions for an invited (but not yet joined) member.""" + self.client.force_login(self.user) + + # Test updating invitation permissions + response = self.client.post( + reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS, + } + ) + + self.assertEqual(response.status_code, 302) + + # Verify invitation was updated + self.invitation.refresh_from_db() + self.assertEqual(self.invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + self.assertEqual( + set(self.invitation.additional_permissions), + { + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + } + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_admin_removing_own_admin_role(self): + """Tests an admin removing their own admin role redirects to home.""" + self.client.force_login(self.user) + + # Get the user's admin permission + admin_permission = UserPortfolioPermission.objects.get( + user=self.user, + portfolio=self.portfolio + ) + + response = self.client.post( + reverse("member-permissions", kwargs={"pk": admin_permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + } + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], reverse("home")) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 95b7238ff..53c500f51 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -10,7 +10,6 @@ from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from registrar.utility.email import EmailSendingError from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, diff --git a/src/zap.conf b/src/zap.conf index 65468773a..a0a60bdc7 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -70,6 +70,7 @@ 10038 OUTOFSCOPE http://app:8080/org-name-address 10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domains/ +10038 OUTOFSCOPE http://app:8080/domains/edit 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/suborganization/ From 3770494781092d9135bd4db162eca320bed20b4e Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:36:47 -0800 Subject: [PATCH 085/135] Remove duplicate aria text --- src/registrar/forms/domain_request_wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c533c653c..289b3da0b 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -530,7 +530,7 @@ class PurposeForm(RegistrarForm): widget=forms.Textarea( attrs={ "aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \ - Will it be used for a website, email, or something else? You can enter up to 2000 characters." + Will it be used for a website, email, or something else?" } ), validators=[ From 093fc1523f7366e234447fa94c7155c26427101e Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 13 Dec 2024 12:54:09 -0700 Subject: [PATCH 086/135] corrected sentence case for status and federal type filters. Updated portfolio filter --- src/registrar/admin.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 986704da8..3c1b51112 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1687,7 +1687,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): class StatusListFilter(MultipleChoiceListFilter): """Custom status filter which is a multiple choice filter""" - title = "Status" + title = "status" parameter_name = "status__in" template = "django/admin/multiple_choice_list_filter.html" @@ -1731,7 +1731,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): If we have a portfolio, use the portfolio's federal type. If not, use the organization in the Domain Request object.""" - title = "federal Type" + title = "federal type" parameter_name = "converted_federal_types" def lookups(self, request, model_admin): @@ -1818,6 +1818,24 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return queryset.filter(is_election_board=True) if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) + + class PortfolioFilter(admin.SimpleListFilter): + """Define a custom filter for portfolio""" + + title = _("portfolio") + parameter_name = "portfolio" + + def lookups(self, request, model_admin): + return ( + ("1", _("Yes")), + ("0", _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == "1": + return queryset.filter(Q(portfolio__isnull=False)) + if self.value() == "0": + return queryset.filter(Q(portfolio__isnull=True)) # ------ Custom fields ------ def custom_election_board(self, obj): @@ -1984,9 +2002,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): GenericOrgFilter, FederalTypeFilter, ElectionOfficeFilter, + PortfolioFilter, "rejection_reason", InvestigatorFilter, - "portfolio" ) # Search From eb72c3f66d65c2067e9815b65a531fa9ff46634a Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 13 Dec 2024 12:54:21 -0700 Subject: [PATCH 087/135] (missed this commit) --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 3c1b51112..b2fc80e17 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1823,7 +1823,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Define a custom filter for portfolio""" title = _("portfolio") - parameter_name = "portfolio" + parameter_name = "portfolio__isnull" def lookups(self, request, model_admin): return ( From b5464a9da8c865fc085e3575a8874f58e25f91a1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:12:00 -0700 Subject: [PATCH 088/135] lintomatic --- src/registrar/forms/portfolio.py | 5 +-- src/registrar/tests/test_views_portfolio.py | 40 +++++++++------------ 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 85c0faf62..d0dbb0b8f 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -150,7 +150,6 @@ class BasePortfolioMemberForm(forms.Form): ) domain_request_permission_member = forms.ChoiceField( - # nosec B308 - required_star is a hardcoded HTML string label=mark_safe(f"Select permission {required_star}"), # nosec choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), @@ -276,7 +275,9 @@ class BasePortfolioMemberForm(forms.Form): "role": role, "member_permission_admin": getattr(member_permission, "value", None) if is_admin else None, "domain_request_permission_admin": getattr(domain_request_permission, "value", None) if is_admin else None, - "domain_request_permission_member": getattr(domain_request_permission, "value", None) if not is_admin else None, + "domain_request_permission_member": ( + getattr(domain_request_permission, "value", None) if not is_admin else None + ), } # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 664d50e87..30091c593 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2685,14 +2685,14 @@ class TestEditPortfolioMemberView(WebTest): def test_edit_member_permissions_basic_to_admin(self): """Tests converting a basic member to admin with full permissions.""" self.client.force_login(self.user) - + # Create a basic member to edit basic_member = create_test_user() basic_permission = UserPortfolioPermission.objects.create( user=basic_member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], - additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS] + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], ) response = self.client.post( @@ -2701,12 +2701,12 @@ class TestEditPortfolioMemberView(WebTest): "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS, "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS, - } + }, ) # Verify redirect and success message self.assertEqual(response.status_code, 302) - + # Verify database changes basic_permission.refresh_from_db() self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) @@ -2718,7 +2718,7 @@ class TestEditPortfolioMemberView(WebTest): UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, - } + }, ) @less_console_noise_decorator @@ -2727,12 +2727,10 @@ class TestEditPortfolioMemberView(WebTest): def test_edit_member_permissions_validation(self): """Tests form validation for required fields based on role.""" self.client.force_login(self.user) - + member = create_test_user() permission = UserPortfolioPermission.objects.create( - user=member, - portfolio=self.portfolio, - roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + user=member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] ) # Test missing required admin permissions @@ -2741,17 +2739,16 @@ class TestEditPortfolioMemberView(WebTest): { "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, # Missing required admin fields - } + }, ) self.assertEqual(response.status_code, 200) self.assertEqual( response.context["form"].errors["domain_request_permission_admin"][0], - "Admin domain request permission is required" + "Admin domain request permission is required", ) self.assertEqual( - response.context["form"].errors["member_permission_admin"][0], - "Admin member permission is required" + response.context["form"].errors["member_permission_admin"][0], "Admin member permission is required" ) @less_console_noise_decorator @@ -2760,7 +2757,7 @@ class TestEditPortfolioMemberView(WebTest): def test_edit_invited_member_permissions(self): """Tests editing permissions for an invited (but not yet joined) member.""" self.client.force_login(self.user) - + # Test updating invitation permissions response = self.client.post( reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}), @@ -2768,11 +2765,11 @@ class TestEditPortfolioMemberView(WebTest): "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS, "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS, - } + }, ) self.assertEqual(response.status_code, 302) - + # Verify invitation was updated self.invitation.refresh_from_db() self.assertEqual(self.invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) @@ -2783,7 +2780,7 @@ class TestEditPortfolioMemberView(WebTest): UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, - } + }, ) @less_console_noise_decorator @@ -2792,19 +2789,16 @@ class TestEditPortfolioMemberView(WebTest): def test_admin_removing_own_admin_role(self): """Tests an admin removing their own admin role redirects to home.""" self.client.force_login(self.user) - + # Get the user's admin permission - admin_permission = UserPortfolioPermission.objects.get( - user=self.user, - portfolio=self.portfolio - ) + admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) response = self.client.post( reverse("member-permissions", kwargs={"pk": admin_permission.id}), { "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, - } + }, ) self.assertEqual(response.status_code, 302) From 21007ce869bc6b737e8aa77d76ce3c7e42a9a0ef Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 13 Dec 2024 15:23:15 -0600 Subject: [PATCH 089/135] review changes --- src/registrar/admin.py | 2 +- src/registrar/models/domain.py | 9 ++++++++- src/registrar/tests/common.py | 1 - 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d26566c63..5a9118549 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2927,7 +2927,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): message = "Cannot connect to the registry" if not err.is_connection_error(): # If nothing is found, will default to returned err - message = error_messages[err.code] + message = error_messages.get(err.code, err) self.message_user(request, f"Error deleting this Domain: {message}", messages.ERROR) except TransitionNotAllowed: if obj.state == Domain.State.DELETED: diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 191b6f143..8c290a8a6 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -750,7 +750,12 @@ class Domain(TimeStampedModel, DomainHelper): successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount - self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + try: + self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + except: + # the error will be logged in the erring function and we don't + # need this part to succeed in order to continue.s + pass if successTotalNameservers < 2: try: @@ -1065,6 +1070,8 @@ class Domain(TimeStampedModel, DomainHelper): if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: raise NameserverError(code=nsErrorCodes.BAD_DATA) + # addAndRemoveHostsFromDomain removes the hosts from the domain object, + # but we still need to delete the object themselves self._delete_hosts_if_not_used(hostsToDelete=deleted_values) logger.debug("Deleting non-registrant contacts for %s", self.name) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 392d5b248..c379b1c26 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1676,7 +1676,6 @@ class MockEppLib(TestCase): def mockDeleteHostCommands(self, _request, cleaned): host = getattr(_request, "name", None) if "sharedhost.com" in host: - print("raising registry error") raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note="ns1.sharedhost.com") return MagicMock( res_data=[self.mockDataHostChange], From 32789faec6b93e471528fab147474e0836a084dc Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 13 Dec 2024 15:27:57 -0600 Subject: [PATCH 090/135] review changes and linting --- src/epplibwrapper/errors.py | 2 +- src/registrar/models/domain.py | 4 +- src/registrar/tests/test_models_domain.py | 72 +++++++++++++---------- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index 78272ff0a..95db40ab8 100644 --- a/src/epplibwrapper/errors.py +++ b/src/epplibwrapper/errors.py @@ -62,7 +62,7 @@ class RegistryError(Exception): - 2501 - 2502 Something malicious or abusive may have occurred """ - def __init__(self, *args, code=None, note="",**kwargs): + def __init__(self, *args, code=None, note="", **kwargs): super().__init__(*args, **kwargs) self.code = code # note is a string that can be used to provide additional context diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 191b6f143..80dd79f70 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1033,7 +1033,7 @@ class Domain(TimeStampedModel, DomainHelper): # if registry error occurs, log the error, and raise it as well logger.error(f"registry error removing client hold: {err}") raise (err) - + def _delete_domain(self): """This domain should be deleted from the registry may raises RegistryError, should be caught or handled correctly by caller""" @@ -1048,7 +1048,7 @@ class Domain(TimeStampedModel, DomainHelper): code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note=f"Host {host.name} is in use by {host.domain}", ) - + ( deleted_values, updated_values, diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 115351c20..1aa08ffe4 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2684,8 +2684,8 @@ class TestAnalystDelete(MockEppLib): [ call( commands.UpdateDomain( - name='freeman.gov', - add=[common.Status(state=Domain.Status.CLIENT_HOLD, description='', lang='en')], + name="freeman.gov", + add=[common.Status(state=Domain.Status.CLIENT_HOLD, description="", lang="en")], rem=[], nsset=None, keyset=None, @@ -2694,19 +2694,43 @@ class TestAnalystDelete(MockEppLib): ), cleaned=True, ), + ] + ) + self.mockedSendFunction.assert_has_calls( + [ call( - commands.InfoDomain(name='freeman.gov', auth_info=None), + commands.InfoDomain(name="freeman.gov", auth_info=None), cleaned=True, ), call( - commands.InfoHost(name='fake.host.com'), + commands.InfoHost(name="fake.host.com"), cleaned=True, ), call( commands.UpdateDomain( - name='freeman.gov', + name="freeman.gov", add=[], - rem=[common.HostObjSet(hosts=['fake.host.com'])], + rem=[common.HostObjSet(hosts=["fake.host.com"])], + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + ] + ) + self.mockedSendFunction.assert_has_calls( + [ + call( + commands.DeleteHost(name="fake.host.com"), + cleaned=True, + ), + call( + commands.UpdateDomain( + name="freeman.gov", + add=[], + rem=[common.DomainContact(contact="adminContact", type="admin")], nsset=None, keyset=None, registrant=None, @@ -2715,14 +2739,14 @@ class TestAnalystDelete(MockEppLib): cleaned=True, ), call( - commands.DeleteHost(name='fake.host.com'), + commands.DeleteContact(id="adminContact"), cleaned=True, ), call( commands.UpdateDomain( - name='freeman.gov', + name="freeman.gov", add=[], - rem=[common.DomainContact(contact='adminContact', type='admin')], + rem=[common.DomainContact(contact="techContact", type="tech")], nsset=None, keyset=None, registrant=None, @@ -2731,32 +2755,20 @@ class TestAnalystDelete(MockEppLib): cleaned=True, ), call( - commands.DeleteContact(id='adminContact'), - cleaned=True, - ), - call( - commands.UpdateDomain( - name='freeman.gov', - add=[], - rem=[common.DomainContact(contact='techContact', type='tech')], - nsset=None, - keyset=None, - registrant=None, - auth_info=None, - ), - cleaned=True, - ), - call( - commands.DeleteContact(id='techContact'), - cleaned=True, - ), - call( - commands.DeleteDomain(name='freeman.gov'), + commands.DeleteContact(id="techContact"), cleaned=True, ), ], any_order=True, ) + self.mockedSendFunction.assert_has_calls( + [ + call( + commands.DeleteDomain(name="freeman.gov"), + cleaned=True, + ), + ], + ) # Domain itself should not be deleted self.assertNotEqual(self.domain_with_contacts, None) From 053fc23173786abf0389b525b4c208eec2792073 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:29:32 -0700 Subject: [PATCH 091/135] fix test --- src/registrar/forms/portfolio.py | 3 --- src/registrar/tests/test_views_portfolio.py | 9 +++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index d0dbb0b8f..a3fa9ba18 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -361,9 +361,6 @@ class NewMemberForm(BasePortfolioMemberForm): cleaned_data = super().clean() # Lowercase the value of the 'email' field email_value = cleaned_data.get("email") - if email_value: - cleaned_data["email"] = email_value.lower() - if email_value: # Check if user is already a member if UserPortfolioPermission.objects.filter(user__email=email_value, portfolio=self.portfolio).exists(): diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 30091c593..edb43824c 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2710,14 +2710,11 @@ class TestEditPortfolioMemberView(WebTest): # Verify database changes basic_permission.refresh_from_db() self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) - # We expect view permissions to be added automagically self.assertEqual( set(basic_permission.additional_permissions), { UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, }, ) @@ -2771,10 +2768,10 @@ class TestEditPortfolioMemberView(WebTest): self.assertEqual(response.status_code, 302) # Verify invitation was updated - self.invitation.refresh_from_db() - self.assertEqual(self.invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id) + self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) self.assertEqual( - set(self.invitation.additional_permissions), + set(updated_invitation.additional_permissions), { UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, From 3839cbafd0a588acbe2367700ce35dcff13b2c73 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:43:14 -0700 Subject: [PATCH 092/135] fix bug on no-organization-domains --- src/registrar/templates/portfolio_no_domains.html | 6 ------ src/registrar/tests/test_views_portfolio.py | 2 -- 2 files changed, 8 deletions(-) diff --git a/src/registrar/templates/portfolio_no_domains.html b/src/registrar/templates/portfolio_no_domains.html index ac6a8c036..995f391a2 100644 --- a/src/registrar/templates/portfolio_no_domains.html +++ b/src/registrar/templates/portfolio_no_domains.html @@ -5,12 +5,6 @@ {% block title %} Domains | {% endblock %} {% block portfolio_content %} - -{% block messages %} - {% include "includes/form_messages.html" %} -{% endblock %} - -

    Domains

    diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index edb43824c..f5f1a4401 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2774,9 +2774,7 @@ class TestEditPortfolioMemberView(WebTest): set(updated_invitation.additional_permissions), { UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, }, ) From 88c717be9a0a94a86530937217c3b17359f15b9a Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:06:13 -0700 Subject: [PATCH 093/135] Update src/registrar/templates/portfolio_member_permissions.html --- src/registrar/templates/portfolio_member_permissions.html | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 454738460..66becaa9e 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -81,11 +81,6 @@ {% input_with_errors form.role %} {% endwith %} - {% comment %} {% if radio.value == "organization_admin" %} - Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately. - {% elif radio.value == "organization_member" %} - Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members can’t view all members of an organization or manage them. Domain management can be assigned separately. - {% endif %} {% endcomment %} From bb2072debce7a86cae74791d6139f3638b527c3a Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 13 Dec 2024 16:35:29 -0600 Subject: [PATCH 094/135] log exception in nameserver setter. --- src/registrar/models/domain.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index c3ed8cada..f67002e4f 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -752,10 +752,9 @@ class Domain(TimeStampedModel, DomainHelper): try: self._delete_hosts_if_not_used(hostsToDelete=deleted_values) - except: - # the error will be logged in the erring function and we don't - # need this part to succeed in order to continue.s - pass + except Exception as e: + # we don't need this part to succeed in order to continue. + logger.error("Failed to delete nameserver hosts: %s", e) if successTotalNameservers < 2: try: From 0178d5749952762841438d8c244392ab852870a4 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 16 Dec 2024 10:28:55 -0600 Subject: [PATCH 095/135] linter fixes --- src/registrar/models/domain.py | 6 +++--- src/registrar/tests/test_admin_domain.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 9aca9b5c3..f99de3d45 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -712,7 +712,7 @@ class Domain(TimeStampedModel, DomainHelper): raise e @nameservers.setter # type: ignore - def nameservers(self, hosts: list[tuple[str, list]]): + def nameservers(self, hosts: list[tuple[str, list]]): # noqa """Host should be a tuple of type str, str,... where the elements are Fully qualified host name, addresses associated with the host example: [(ns1.okay.gov, [127.0.0.1, others ips])]""" @@ -749,7 +749,7 @@ class Domain(TimeStampedModel, DomainHelper): successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount - try: + try: self._delete_hosts_if_not_used(hostsToDelete=deleted_values) except Exception as e: # we don't need this part to succeed in order to continue. @@ -1068,7 +1068,7 @@ class Domain(TimeStampedModel, DomainHelper): if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: raise NameserverError(code=nsErrorCodes.BAD_DATA) - # addAndRemoveHostsFromDomain removes the hosts from the domain object, + # addAndRemoveHostsFromDomain removes the hosts from the domain object, # but we still need to delete the object themselves self._delete_hosts_if_not_used(hostsToDelete=deleted_values) diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 6eb934091..8a487ea2b 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -265,7 +265,7 @@ class TestDomainAdminAsStaff(MockEppLib): mock_add_message.assert_called_once_with( request, messages.ERROR, - "Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", + "Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa extra_tags="", fail_silently=False, ) From f039f315e0b41840249f7c3678449c221e20b2e8 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 16 Dec 2024 10:35:34 -0600 Subject: [PATCH 096/135] linter fixes --- src/registrar/models/domain.py | 2 +- src/registrar/tests/test_admin_domain.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index f99de3d45..19e96719f 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -712,7 +712,7 @@ class Domain(TimeStampedModel, DomainHelper): raise e @nameservers.setter # type: ignore - def nameservers(self, hosts: list[tuple[str, list]]): # noqa + def nameservers(self, hosts: list[tuple[str, list]]): # noqa """Host should be a tuple of type str, str,... where the elements are Fully qualified host name, addresses associated with the host example: [(ns1.okay.gov, [127.0.0.1, others ips])]""" diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 8a487ea2b..072bc1f7f 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -265,7 +265,7 @@ class TestDomainAdminAsStaff(MockEppLib): mock_add_message.assert_called_once_with( request, messages.ERROR, - "Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa + "Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa extra_tags="", fail_silently=False, ) From 2ba9fd8be66dc80e3ccc8d27aba696bc0ca46b27 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 11:13:37 -0700 Subject: [PATCH 097/135] fix 500 error for Portfolio Admin --- src/registrar/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b2fc80e17..13729f003 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3607,6 +3607,11 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [ + ] + def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) From 91740c9026b2c514279bce812a6d12c8346faaab Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:20:08 -0800 Subject: [PATCH 098/135] Apply suggestions from code review Add suggested commas Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/domain_request_done.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_request_done.html b/src/registrar/templates/domain_request_done.html index 9b1b109a1..17eea9854 100644 --- a/src/registrar/templates/domain_request_done.html +++ b/src/registrar/templates/domain_request_done.html @@ -28,9 +28,9 @@

    We’ll review your request. This review period can take 30 business days. Due to the volume of requests, the wait time is longer than usual. We appreciate your patience.

    {% if has_organization_feature_flag %} -

    During our review we’ll verify that your requested domain meets our naming requirements.

    +

    During our review, we’ll verify that your requested domain meets our naming requirements.

    {% else %} -

    During our review we’ll verify that:

    +

    During our review, we’ll verify that:

    • Your organization is eligible for a .gov domain.
    • You work at the organization and/or can make requests on its behalf.
    • From 0554133286e1b35ade507b36a2bcc1fe1304409a Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 12:21:26 -0700 Subject: [PATCH 099/135] Fix ordering on custom_requested_domain. Move portfolio filter to top of filter list --- src/registrar/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 13729f003..c0024c4dc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1862,7 +1862,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): url, text ) - custom_requested_domain.admin_order_field = "requested_domain" # type: ignore + custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore # ------ Converted fields ------ # These fields map to @Property methods and @@ -1998,11 +1998,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Filters list_filter = ( + PortfolioFilter, StatusListFilter, GenericOrgFilter, FederalTypeFilter, ElectionOfficeFilter, - PortfolioFilter, "rejection_reason", InvestigatorFilter, ) From 1558358bce381ffc7dd219b7caa7abc6f811f3e2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 16 Dec 2024 11:24:20 -0800 Subject: [PATCH 100/135] Update domain request content --- src/registrar/templates/emails/submission_confirmation.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt index ef9736a9d..aa1c207ce 100644 --- a/src/registrar/templates/emails/submission_confirmation.txt +++ b/src/registrar/templates/emails/submission_confirmation.txt @@ -12,12 +12,12 @@ STATUS: Submitted NEXT STEPS We’ll review your request. This review period can take 30 business days. Due to the volume of requests, the wait time is longer than usual. We appreciate your patience. -During our review we’ll verify that: +During our review, we’ll verify that: - Your organization is eligible for a .gov domain - You work at the organization and/or can make requests on its behalf - Your requested domain meets our naming requirements -We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar homepage. +We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. NEED TO MAKE CHANGES? From 679fab221f9118af8d23b1ed3a548aafc92e063b Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 13:10:48 -0700 Subject: [PATCH 101/135] revert erroneous text --- src/registrar/templates/includes/header_basic.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/includes/header_basic.html b/src/registrar/templates/includes/header_basic.html index e42b2529b..3f8b4a2fb 100644 --- a/src/registrar/templates/includes/header_basic.html +++ b/src/registrar/templates/includes/header_basic.html @@ -1,7 +1,7 @@ {% load static %}
      -
      +
      {% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %} From 172cd36f74b8835c888abcc715013660760d30e1 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:06:44 -0800 Subject: [PATCH 102/135] Update django version in pipfile --- src/Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pipfile b/src/Pipfile index fdf127d7c..07b1db715 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -django = "4.2.10" +django = "4.2.17" cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" From b0d1bc26da85e0e6ac8bae4746d529089a6b61de Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:15:13 -0700 Subject: [PATCH 103/135] revert csv-export --- src/registrar/tests/test_reports.py | 138 ++++---- src/registrar/utility/csv_export.py | 519 ++++++---------------------- 2 files changed, 181 insertions(+), 476 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cafaff7b1..f91c5b299 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,35 +251,32 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency," - "Organization name,City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - "meoward@rocks.com,squeaker@rocks.com\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," - "World War I Centennial Commission,,,, ,,(blank)," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," + "SO email,Security contact email,Domain managers,Invited domain managers\n" + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," "meoward@rocks.com,\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," + ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal," - "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," + "security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "meoward@rocks.com,squeaker@rocks.com\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -315,17 +312,20 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + "City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," + '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" - "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" - "zdomain12.gov,Interstate,Ready,(blank)\n" + "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" + "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" + "zdomain12.govInterstateReady(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,6 +611,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" + self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -645,6 +646,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -681,6 +683,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -718,9 +721,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) - # @less_console_noise_decorator + @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -762,34 +766,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() - expected_content = ( # Header - "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," - "City,State/territory,Region,Creator first name,Creator last name,Creator email," - "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," - "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type," + "Federal agency,Organization name,Election office,City,State/territory," + "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," + "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," + "SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," - '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' - 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' - 'Testy Tester testy2@town.com",' - 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," - "Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," - "Testy Tester testy2@town.com," - "cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," + "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," + "testy@town.com," + "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' + 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' + "CISA-last-name " + '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' + 'testy2@town.com"' + ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com" + ",cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -857,6 +862,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user + self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 97feae20c..a03e51de5 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -414,11 +414,8 @@ class MemberExport(BaseExport): ) .values(*shared_columns) ) - # Adding a order_by increases output predictability. - # Doesn't matter as much for normal use, but makes tests easier. - # We should also just be ordering by default anyway. - members = permissions.union(invitations).order_by("email_display") - return convert_queryset_to_dict(members, is_model=False) + + return convert_queryset_to_dict(permissions.union(invitations), is_model=False) @classmethod def get_invited_by_query(cls, object_id_query): @@ -528,115 +525,6 @@ class DomainExport(BaseExport): # Return the model class that this export handles return DomainInformation - @classmethod - def get_computed_fields(cls, **kwargs): - """ - Get a dict of computed fields. - """ - # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. - # This is for performance purposes. Since we are working with dictionary values and not - # model objects as we export data, trying to reinstate model objects in order to grab @property - # values negatively impacts performance. Therefore, we will follow best practice and use annotations - return { - "converted_generic_org_type": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__organization_type")), - # Otherwise, return the natively assigned value - default=F("generic_org_type"), - output_field=CharField(), - ), - "converted_federal_agency": Case( - # When portfolio is present, use its value instead - When( - Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), - then=F("portfolio__federal_agency__agency"), - ), - # Otherwise, return the natively assigned value - default=F("federal_agency__agency"), - output_field=CharField(), - ), - "converted_federal_type": Case( - # When portfolio is present, use its value instead - # NOTE: this is an @Property funciton in portfolio. - When( - Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), - then=F("portfolio__federal_agency__federal_type"), - ), - # Otherwise, return the natively assigned value - default=F("federal_type"), - output_field=CharField(), - ), - "converted_organization_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__organization_name")), - # Otherwise, return the natively assigned value - default=F("organization_name"), - output_field=CharField(), - ), - "converted_city": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__city")), - # Otherwise, return the natively assigned value - default=F("city"), - output_field=CharField(), - ), - "converted_state_territory": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__state_territory")), - # Otherwise, return the natively assigned value - default=F("state_territory"), - output_field=CharField(), - ), - "converted_so_email": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__email"), - output_field=CharField(), - ), - "converted_senior_official_last_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__last_name"), - output_field=CharField(), - ), - "converted_senior_official_first_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__first_name"), - output_field=CharField(), - ), - "converted_senior_official_title": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__title"), - output_field=CharField(), - ), - "converted_so_name": Case( - # When portfolio is present, use that senior official instead - When( - Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), - then=Concat( - Coalesce(F("portfolio__senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("portfolio__senior_official__last_name"), Value("")), - output_field=CharField(), - ), - ), - # Otherwise, return the natively assigned senior official - default=Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - output_field=CharField(), - ), - } - @classmethod def update_queryset(cls, queryset, **kwargs): """ @@ -726,10 +614,10 @@ class DomainExport(BaseExport): if first_ready_on is None: first_ready_on = "(blank)" - # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") + # organization_type has generic_org_type AND is_election + domain_org_type = model.get("organization_type") human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("converted_federal_type") + domain_federal_type = model.get("federal_type") human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) domain_type = human_readable_domain_org_type if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: @@ -752,12 +640,12 @@ class DomainExport(BaseExport): "First ready on": first_ready_on, "Expiration date": expiration_date, "Domain type": domain_type, - "Agency": model.get("converted_federal_agency"), - "Organization name": model.get("converted_organization_name"), - "City": model.get("converted_city"), - "State": model.get("converted_state_territory"), - "SO": model.get("converted_so_name"), - "SO email": model.get("converted_so_email"), + "Agency": model.get("federal_agency__agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("senior_official__email"), "Security contact email": security_contact_email, "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), @@ -766,23 +654,8 @@ class DomainExport(BaseExport): } row = [FIELDS.get(column, "") for column in columns] - return row - def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): - """Returns a list of Domain Requests that has been filtered by the given organization value.""" - - annotated_queryset = domain_infos_to_filter.annotate( - converted_generic_org_type=Case( - # Recreate the logic of the converted_generic_org_type property - # here in annotations - When(portfolio__isnull=False, then=F("portfolio__organization_type")), - default=F("generic_org_type"), - output_field=CharField(), - ) - ) - return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) - @classmethod def get_sliced_domains(cls, filter_condition): """Get filtered domains counts sliced by org type and election office. @@ -790,51 +663,23 @@ class DomainExport(BaseExport): when a domain has more that one manager. """ - domain_informations = DomainInformation.objects.all().filter(**filter_condition).distinct() - domains_count = domain_informations.count() - federal = ( - cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.FEDERAL) - .distinct() - .count() - ) - interstate = cls.get_filtered_domain_infos_by_org( - domain_informations, DomainRequest.OrganizationChoices.INTERSTATE - ).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 = ( - cls.get_filtered_domain_infos_by_org( - domain_informations, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY - ) - .distinct() - .count() - ) - tribal = ( - cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.TRIBAL) - .distinct() - .count() - ) - county = ( - cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.COUNTY) - .distinct() - .count() - ) - city = ( - cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.CITY) - .distinct() - .count() + 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 = ( - cls.get_filtered_domain_infos_by_org( - domain_informations, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT - ) - .distinct() - .count() + domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() ) school_district = ( - cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) - .distinct() - .count() + domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() ) - election_board = domain_informations.filter(is_election_board=True).distinct().count() + election_board = domains.filter(is_election_board=True).distinct().count() return [ domains_count, @@ -861,7 +706,6 @@ class DomainDataType(DomainExport): """ Overrides the columns for CSV export specific to DomainExport. """ - return [ "Domain name", "Status", @@ -879,13 +723,6 @@ class DomainDataType(DomainExport): "Invited domain managers", ] - @classmethod - def get_annotations_for_sort(cls): - """ - Get a dict of annotations to make available for sorting. - """ - return cls.get_computed_fields() - @classmethod def get_sort_fields(cls): """ @@ -893,9 +730,9 @@ class DomainDataType(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -936,6 +773,20 @@ class DomainDataType(DomainExport): """ return ["domain__permissions"] + @classmethod + def get_computed_fields(cls, delimiter=", ", **kwargs): + """ + Get a dict of computed fields. + """ + return { + "so_name": Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + } + @classmethod def get_related_table_fields(cls): """ @@ -1041,7 +892,7 @@ class DomainRequestsDataType: cls.safe_get(getattr(request, "region_field", None)), request.status, cls.safe_get(getattr(request, "election_office", None)), - request.converted_federal_type, + request.federal_type, cls.safe_get(getattr(request, "domain_type", None)), cls.safe_get(getattr(request, "additional_details", None)), cls.safe_get(getattr(request, "creator_approved_domains_count", None)), @@ -1092,13 +943,6 @@ class DomainDataFull(DomainExport): "Security contact email", ] - @classmethod - def get_annotations_for_sort(cls, delimiter=", "): - """ - Get a dict of annotations to make available for sorting. - """ - return cls.get_computed_fields() - @classmethod def get_sort_fields(cls): """ @@ -1106,9 +950,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1146,6 +990,20 @@ class DomainDataFull(DomainExport): ], ) + @classmethod + def get_computed_fields(cls, delimiter=", ", **kwargs): + """ + Get a dict of computed fields. + """ + return { + "so_name": Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + } + @classmethod def get_related_table_fields(cls): """ @@ -1179,13 +1037,6 @@ class DomainDataFederal(DomainExport): "Security contact email", ] - @classmethod - def get_annotations_for_sort(cls, delimiter=", "): - """ - Get a dict of annotations to make available for sorting. - """ - return cls.get_computed_fields() - @classmethod def get_sort_fields(cls): """ @@ -1193,9 +1044,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1234,6 +1085,20 @@ class DomainDataFederal(DomainExport): ], ) + @classmethod + def get_computed_fields(cls, delimiter=", ", **kwargs): + """ + Get a dict of computed fields. + """ + return { + "so_name": Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + } + @classmethod def get_related_table_fields(cls): """ @@ -1611,180 +1476,24 @@ class DomainRequestExport(BaseExport): # Return the model class that this export handles return DomainRequest - def get_filtered_domain_requests_by_org(domain_requests_to_filter, org_to_filter_by): - """Returns a list of Domain Requests that has been filtered by the given organization value""" - annotated_queryset = domain_requests_to_filter.annotate( - converted_generic_org_type=Case( - # Recreate the logic of the converted_generic_org_type property - # here in annotations - When(portfolio__isnull=False, then=F("portfolio__organization_type")), - default=F("generic_org_type"), - output_field=CharField(), - ) - ) - return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) - - # return domain_requests_to_filter.filter( - # # Filter based on the generic org value returned by converted_generic_org_type - # id__in=[ - # domainRequest.id - # for domainRequest in domain_requests_to_filter - # if domainRequest.converted_generic_org_type - # and domainRequest.converted_generic_org_type == org_to_filter_by - # ] - # ) - - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. - # This is for performance purposes. Since we are working with dictionary values and not - # model objects as we export data, trying to reinstate model objects in order to grab @property - # values negatively impacts performance. Therefore, we will follow best practice and use annotations - return { - "converted_generic_org_type": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__organization_type")), - # Otherwise, return the natively assigned value - default=F("generic_org_type"), - output_field=CharField(), - ), - "converted_federal_agency": Case( - # When portfolio is present, use its value instead - When( - Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), - then=F("portfolio__federal_agency__agency"), - ), - # Otherwise, return the natively assigned value - default=F("federal_agency__agency"), - output_field=CharField(), - ), - "converted_federal_type": Case( - # When portfolio is present, use its value instead - # NOTE: this is an @Property funciton in portfolio. - When( - Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), - then=F("portfolio__federal_agency__federal_type"), - ), - # Otherwise, return the natively assigned value - default=F("federal_type"), - output_field=CharField(), - ), - "converted_organization_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__organization_name")), - # Otherwise, return the natively assigned value - default=F("organization_name"), - output_field=CharField(), - ), - "converted_city": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__city")), - # Otherwise, return the natively assigned value - default=F("city"), - output_field=CharField(), - ), - "converted_state_territory": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__state_territory")), - # Otherwise, return the natively assigned value - default=F("state_territory"), - output_field=CharField(), - ), - "converted_so_email": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__email"), - output_field=CharField(), - ), - "converted_senior_official_last_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__last_name"), - output_field=CharField(), - ), - "converted_senior_official_first_name": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__first_name"), - output_field=CharField(), - ), - "converted_senior_official_title": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), - # Otherwise, return the natively assigned senior official - default=F("senior_official__title"), - output_field=CharField(), - ), - "converted_so_name": Case( - # When portfolio is present, use that senior official instead - When( - Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), - then=Concat( - Coalesce(F("portfolio__senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("portfolio__senior_official__last_name"), Value("")), - output_field=CharField(), - ), - ), - # Otherwise, return the natively assigned senior official - default=Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - output_field=CharField(), - ), - } - @classmethod def get_sliced_requests(cls, filter_condition): """Get filtered requests counts sliced by org type and election office.""" requests = DomainRequest.objects.all().filter(**filter_condition).distinct() requests_count = requests.count() - federal = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.FEDERAL) - .distinct() - .count() - ) - interstate = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.INTERSTATE) - .distinct() - .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 = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY) - .distinct() - .count() - ) - tribal = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.TRIBAL) - .distinct() - .count() - ) - county = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.COUNTY) - .distinct() - .count() - ) - city = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.CITY).distinct().count() + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).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 = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT) - .distinct() - .count() + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() ) school_district = ( - cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) - .distinct() - .count() + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() ) election_board = requests.filter(is_election_board=True).distinct().count() @@ -1808,11 +1517,11 @@ class DomainRequestExport(BaseExport): """ # Handle the federal_type field. Defaults to the wrong format. - federal_type = model.get("converted_federal_type") + federal_type = model.get("federal_type") human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None # Handle the org_type field - org_type = model.get("converted_generic_org_type") + org_type = model.get("generic_org_type") or model.get("organization_type") human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None # Handle the status field. Defaults to the wrong format. @@ -1860,19 +1569,19 @@ class DomainRequestExport(BaseExport): "Other contacts": model.get("all_other_contacts"), "Current websites": model.get("all_current_websites"), # Untouched FK fields - passed into the request dict. - "Federal agency": model.get("converted_federal_agency"), - "SO first name": model.get("converted_senior_official_first_name"), - "SO last name": model.get("converted_senior_official_last_name"), - "SO email": model.get("converted_so_email"), - "SO title/role": model.get("converted_senior_official_title"), + "Federal agency": model.get("federal_agency__agency"), + "SO first name": model.get("senior_official__first_name"), + "SO last name": model.get("senior_official__last_name"), + "SO email": model.get("senior_official__email"), + "SO title/role": model.get("senior_official__title"), "Creator first name": model.get("creator__first_name"), "Creator last name": model.get("creator__last_name"), "Creator email": model.get("creator__email"), "Investigator": model.get("investigator__email"), # Untouched fields - "Organization name": model.get("converted_organization_name"), - "City": model.get("converted_city"), - "State/territory": model.get("converted_state_territory"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State/territory": model.get("state_territory"), "Request purpose": model.get("purpose"), "CISA regional representative": model.get("cisa_representative_email"), "Last submitted date": model.get("last_submitted_date"), @@ -2015,34 +1724,24 @@ class DomainRequestDataFull(DomainRequestExport): """ Get a dict of computed fields. """ - # Get computed fields from the parent class - computed_fields = super().get_computed_fields() - - # Add additional computed fields - computed_fields.update( - { - "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), - "creator_active_requests_count": cls.get_creator_active_requests_count_query(), - "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), - "all_alternative_domains": StringAgg( - "alternative_domains__website", delimiter=delimiter, distinct=True + return { + "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), + "creator_active_requests_count": cls.get_creator_active_requests_count_query(), + "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), + "all_alternative_domains": StringAgg("alternative_domains__website", delimiter=delimiter, distinct=True), + # Coerce the other contacts object to "{first_name} {last_name} {email}" + "all_other_contacts": StringAgg( + Concat( + "other_contacts__first_name", + Value(" "), + "other_contacts__last_name", + Value(" "), + "other_contacts__email", ), - # Coerce the other contacts object to "{first_name} {last_name} {email}" - "all_other_contacts": StringAgg( - Concat( - "other_contacts__first_name", - Value(" "), - "other_contacts__last_name", - Value(" "), - "other_contacts__email", - ), - delimiter=delimiter, - distinct=True, - ), - } - ) - - return computed_fields + delimiter=delimiter, + distinct=True, + ), + } @classmethod def get_related_table_fields(cls): From 437981ff30c27b66dc92edd092f71829fb82bc20 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:44:58 -0700 Subject: [PATCH 104/135] Remove changes to newmember --- .../src/js/getgov/portfolio-member-page.js | 326 +++++++++--------- src/registrar/forms/portfolio.py | 152 +++++--- .../templates/portfolio_members_add_new.html | 43 ++- src/registrar/views/portfolios.py | 181 ++++++++-- src/registrar/views/utility/__init__.py | 1 - .../views/utility/permission_views.py | 20 -- 6 files changed, 449 insertions(+), 274 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index e7d797441..af25f0f1d 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -6,180 +6,194 @@ import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal export function initPortfolioNewMemberPageToggle() { - document.addEventListener("DOMContentLoaded", () => { - const wrapperDeleteAction = document.getElementById("wrapper-delete-action") - if (wrapperDeleteAction) { - const member_type = wrapperDeleteAction.getAttribute("data-member-type"); - const member_id = wrapperDeleteAction.getAttribute("data-member-id"); - const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); - const member_name = wrapperDeleteAction.getAttribute("data-member-name"); - const member_email = wrapperDeleteAction.getAttribute("data-member-email"); - const member_delete_url = `${member_type}-${member_id}/delete`; - const unique_id = `${member_type}-${member_id}`; - - let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); - - // This easter egg is only for fixtures that dont have names as we are displaying their emails - // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); - - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - closeButton.click(); - let delete_member_form = document.getElementById("member-delete-form"); - if (delete_member_form) { - delete_member_form.submit(); - } - }); + document.addEventListener("DOMContentLoaded", () => { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } }); - } - }); + }); + } + }); } /** - * Hooks up specialized listeners for handling form validation and modals - * on the Add New Member page. - */ +* Hooks up specialized listeners for handling form validation and modals +* on the Add New Member page. +*/ export function initAddNewMemberPageListeners() { - let add_member_form = document.getElementById("add_member_form"); - if (!add_member_form){ - return; - } +let add_member_form = document.getElementById("add_member_form"); +if (!add_member_form){ + return; +} +document.getElementById("confirm_new_member_submit").addEventListener("click", function() { + // Upon confirmation, submit the form + document.getElementById("add_member_form").submit(); +}); - // Hookup the submission buttons - document.getElementById("confirm_new_member_submit").addEventListener("click", function() { - // Upon confirmation, submit the form - document.getElementById("add_member_form").submit(); - }); +document.getElementById("add_member_form").addEventListener("submit", function(event) { + event.preventDefault(); // Prevents the form from submitting + const form = document.getElementById("add_member_form") + const formData = new FormData(form); - document.getElementById("add_member_form").addEventListener("submit", function(event) { - event.preventDefault(); // Prevents the form from submitting - const form = document.getElementById("add_member_form") - const formData = new FormData(form); - - // Check if the form is valid - // If the form is valid, open the confirmation modal - // If the form is invalid, submit it to trigger error - fetch(form.action, { - method: "POST", - body: formData, - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-CSRFToken": getCsrfToken() - } - }) - .then(response => response.json()) - .then(data => { - if (data.is_valid) { - // If the form is valid, show the confirmation modal before submitting - openAddMemberConfirmationModal(); - } else { - // If the form is not valid, trigger error messages by firing a submit event - form.submit(); - } - }); - }); - - /* - Populates contents of the "Add Member" confirmation modal - */ - function populatePermissionDetails(permission_details_div_id) { - const permissionDetailsContainer = document.getElementById("permission_details"); - permissionDetailsContainer.innerHTML = ""; // Clear previous content - - // Get all permission sections (divs with h3 and radio inputs) - const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - permissionSections.forEach(section => { - // Find the

      element text - const sectionTitle = section.textContent; - - // Find the associated radio buttons container (next fieldset) - const fieldset = section.nextElementSibling; - if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - // If a radio button is selected, get its label text - let selectedPermission = "No permission selected"; - if (selectedRadio) { - const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); - selectedPermission = label ? label.textContent : "No permission selected"; - } - - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); - - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); - - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); - } - }); - } - - /* - Updates and opens the "Add Member" confirmation modal. - */ - function openAddMemberConfirmationModal() { - //------- Populate modal details - // Get email value - let emailValue = document.getElementById('id_email').value; - document.getElementById('modalEmail').textContent = emailValue; - - // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="role"]:checked'); - - // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) - // This value does not have the first letter capitalized so let's capitalize it - let accessText = "No access level selected"; - - // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'organization_admin') { - populatePermissionDetails('member-admin-permissions'); - accessText = "Admin" - } else if (selectedAccess && selectedAccess.value === 'organization_member') { - populatePermissionDetails('member-basic-permissions'); - accessText = "Member" + // Check if the form is valid + // If the form is valid, open the confirmation modal + // If the form is invalid, submit it to trigger error + fetch(form.action, { + method: "POST", + body: formData, + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-CSRFToken": getCsrfToken() } + }) + .then(response => response.json()) + .then(data => { + if (data.is_valid) { + // If the form is valid, show the confirmation modal before submitting + openAddMemberConfirmationModal(); + } else { + // If the form is not valid, trigger error messages by firing a submit event + form.submit(); + } + }); +}); - document.getElementById('modalAccessLevel').textContent = accessText; +/* + Helper function to capitalize the first letter in a string (for display purposes) +*/ +function capitalizeFirstLetter(text) { + if (!text) return ''; // Return empty string if input is falsy + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/* + Populates contents of the "Add Member" confirmation modal +*/ +function populatePermissionDetails(permission_details_div_id) { + const permissionDetailsContainer = document.getElementById("permission_details"); + permissionDetailsContainer.innerHTML = ""; // Clear previous content + + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + + permissionSections.forEach(section => { + // Find the

      element text + const sectionTitle = section.textContent; + + // Find the associated radio buttons container (next fieldset) + const fieldset = section.nextElementSibling; + + if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { + // Get the selected radio button within this fieldset + const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + + // If a radio button is selected, get its label text + let selectedPermission = "No permission selected"; + if (selectedRadio) { + const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); + selectedPermission = label ? label.textContent : "No permission selected"; + } + + // Create new elements for the modal content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary"); + titleElement.classList.add("margin-bottom-0"); + + const permissionElement = document.createElement("p"); + permissionElement.textContent = selectedPermission; + permissionElement.classList.add("margin-top-0"); + + // Append to the modal content container + permissionDetailsContainer.appendChild(titleElement); + permissionDetailsContainer.appendChild(permissionElement); + } + }); +} + +/* + Updates and opens the "Add Member" confirmation modal. +*/ +function openAddMemberConfirmationModal() { + //------- Populate modal details + // Get email value + let emailValue = document.getElementById('id_email').value; + document.getElementById('modalEmail').textContent = emailValue; + + // Get selected radio button for access level + let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); + // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) + // This value does not have the first letter capitalized so let's capitalize it + let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; + document.getElementById('modalAccessLevel').textContent = accessText; + + // Populate permission details based on access level + if (selectedAccess && selectedAccess.value === 'admin') { + populatePermissionDetails('new-member-admin-permissions'); + } else { + populatePermissionDetails('new-member-basic-permissions'); + } + + //------- Show the modal + let modalTrigger = document.querySelector("#invite_member_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } +} - //------- Show the modal - let modalTrigger = document.querySelector("#invite_member_trigger"); - if (modalTrigger) { - modalTrigger.click(); - } - } } // Initalize the radio for the member pages export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { + console.log("new content 2") let memberForm = document.getElementById("member_form"); let newMemberForm = document.getElementById("add_member_form") - if (!memberForm && !newMemberForm) { - return; + if (memberForm) { + hookupRadioTogglerListener( + 'role', + { + 'organization_admin': 'member-admin-permissions', + 'organization_member': 'member-basic-permissions' + } + ); + }else if (newMemberForm){ + hookupRadioTogglerListener( + 'member_access_level', + { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + } + ); } - hookupRadioTogglerListener( - 'role', - { - 'organization_admin': 'member-admin-permissions', - 'organization_member': 'member-basic-permissions' - } - ) }); } diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index a3fa9ba18..34d334a3b 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -6,7 +6,7 @@ from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator from django.utils.safestring import mark_safe from registrar.models import ( - PortfolioInvitation, + User, UserPortfolioPermission, DomainInformation, Portfolio, @@ -218,6 +218,10 @@ class BasePortfolioMemberForm(forms.Form): if not cleaned_data.get(field_name): self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("domain_request_permission_member") == "no_access": + cleaned_data["domain_request_permission_member"] = None + return cleaned_data def save(self): @@ -248,27 +252,32 @@ class BasePortfolioMemberForm(forms.Form): # Function variables form_data = {} - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in instance.roles if instance.roles else False - perms = UserPortfolioPermission.get_portfolio_permissions(instance.roles, instance.additional_permissions) + perms = UserPortfolioPermission.get_portfolio_permissions( + instance.roles, instance.additional_permissions, get_list=False + ) + + # Explanation of this logic pattern: we can only display one item in the list at a time. + # But how do we determine what is most important to display in a list? Order-based hierarchy. + # Example: print(instance.roles) => (output) ["organization_admin", "organization_member"] + # If we can only pick one item in this list, we should pick organization_admin. # Get role - role = UserPortfolioRoleChoices.ORGANIZATION_MEMBER - if is_admin: - role = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + role = next((role for role in roles if role in instance.roles), None) + is_admin = role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN # Get domain request permission level - domain_request_permission = None - if UserPortfolioPermissionChoices.EDIT_REQUESTS in perms: - domain_request_permission = UserPortfolioPermissionChoices.EDIT_REQUESTS - elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in perms: - domain_request_permission = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + # First we get permissions we expect to display (ordered hierarchically). + # Then we check if this item exists in the list and return the first instance of it. + domain_permissions = [ + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + ] + domain_request_permission = next((perm for perm in domain_permissions if perm in perms), None) - # Get member permission level - member_permission = None - if UserPortfolioPermissionChoices.EDIT_MEMBERS in perms: - member_permission = UserPortfolioPermissionChoices.EDIT_MEMBERS - elif UserPortfolioPermissionChoices.VIEW_MEMBERS in perms: - member_permission = UserPortfolioPermissionChoices.VIEW_MEMBERS + # Get member permission level. + member_permissions = [UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS] + member_permission = next((perm for perm in member_permissions if perm in perms), None) # Build form data based on role. form_data = { @@ -304,24 +313,13 @@ class BasePortfolioMemberForm(forms.Form): instance: Updated instance """ role = cleaned_data.get("role") - member_permission_admin = cleaned_data.get("member_permission_admin") - domain_request_permission_admin = cleaned_data.get("domain_request_permission_admin") - domain_request_permission_member = cleaned_data.get("domain_request_permission_member") # Handle roles instance.roles = [role] # Handle additional_permissions - additional_permissions = set() - if role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN: - if domain_request_permission_admin: - additional_permissions.add(domain_request_permission_admin) - - if member_permission_admin: - additional_permissions.add(member_permission_admin) - else: - if domain_request_permission_member and domain_request_permission_member != "no_access": - additional_permissions.add(domain_request_permission_member) + valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) + additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} # Handle EDIT permissions (should be accompanied with a view permission) if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: @@ -336,7 +334,48 @@ class BasePortfolioMemberForm(forms.Form): return instance -class NewMemberForm(BasePortfolioMemberForm): +class NewMemberForm(forms.ModelForm): + member_access_level = forms.ChoiceField( + label="Select permission", + choices=[("admin", "Admin Access"), ("basic", "Basic Access")], + widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + admin_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + admin_org_members_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin member permission is required", + }, + ) + basic_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[ + ("view_only", "View all requests"), + ("view_and_create", "View all requests plus create requests"), + ("no_access", "No access"), + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Basic member permission is required", + }, + ) + email = forms.EmailField( label="Enter the email of the member you'd like to invite", max_length=None, @@ -353,23 +392,20 @@ class NewMemberForm(BasePortfolioMemberForm): required=True, ) - def __init__(self, *args, **kwargs): - self.portfolio = kwargs.pop("portfolio", None) - super().__init__(*args, **kwargs) + class Meta: + model = User + fields = ["email"] def clean(self): cleaned_data = super().clean() + # Lowercase the value of the 'email' field email_value = cleaned_data.get("email") if email_value: - # Check if user is already a member - if UserPortfolioPermission.objects.filter(user__email=email_value, portfolio=self.portfolio).exists(): - self.add_error("email", "User is already a member of this portfolio.") + cleaned_data["email"] = email_value.lower() - if PortfolioInvitation.objects.filter(email=email_value, portfolio=self.portfolio).exists(): - self.add_error("email", "An invitation already exists for this user.") ########################################## - # TODO: #3019 + # TODO: future ticket # (invite new member) ########################################## # Check for an existing user (if there isn't any, send an invite) @@ -378,14 +414,30 @@ class NewMemberForm(BasePortfolioMemberForm): # existingUser = User.objects.get(email=email_value) # except User.DoesNotExist: # raise forms.ValidationError("User with this email does not exist.") - return cleaned_data - def map_cleaned_data_to_instance(self, cleaned_data, instance): - """Override of the base class to add portfolio and email.""" - instance = super().map_cleaned_data_to_instance(cleaned_data, instance) - email = cleaned_data.get("email") - if email and isinstance(email, str): - email = email.lower() - instance.email = email - instance.portfolio = self.portfolio - return instance + member_access_level = cleaned_data.get("member_access_level") + + # Intercept the error messages so that we don't validate hidden inputs + if not member_access_level: + # If no member access level has been selected, delete error messages + # for all hidden inputs (which is everything except the e-mail input + # and member access selection) + for field in self.fields: + if field in self.errors and field != "email" and field != "member_access_level": + del self.errors[field] + return cleaned_data + + basic_dom_req_error = "basic_org_domain_request_permissions" + admin_dom_req_error = "admin_org_domain_request_permissions" + admin_member_error = "admin_org_members_permissions" + + if member_access_level == "admin" and basic_dom_req_error in self.errors: + # remove the error messages pertaining to basic permission inputs + del self.errors[basic_dom_req_error] + elif member_access_level == "basic": + # remove the error messages pertaining to admin permission inputs + if admin_dom_req_error in self.errors: + del self.errors[admin_dom_req_error] + if admin_member_error in self.errors: + del self.errors[admin_member_error] + return cleaned_data diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index df9200c39..466358915 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -56,14 +56,28 @@ Select the level of access for this member. * - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} - {% input_with_errors form.role %} + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} +
      + {% for radio in form.member_access_level %} + {{ radio.tag }} + + {% endfor %} +
      {% endwith %} -
      +

      Admin access permissions

      Member permissions available for admin-level acccess.

      @@ -71,7 +85,7 @@ text-primary-dark margin-bottom-0">Organization domain requests

      {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.domain_request_permission_admin %} + {% input_with_errors form.admin_org_domain_request_permissions %} {% endwith %}

      Organization members

      {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.member_permission_admin %} + {% input_with_errors form.admin_org_members_permissions %} {% endwith %}
      - -
      -

      Basic member permissions

      -

      Member permissions available for basic-level acccess.

      + +
      +

      Basic member permissions

      +

      Member permissions available for basic-level acccess.

      -

      Organization domain requests

      - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.domain_request_permission_member %} - {% endwith %} -
      +

      Organization domain requests

      + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.basic_org_domain_request_permissions %} + {% endwith %} +
      @@ -176,4 +190,3 @@ {% endblock portfolio_content%} - diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 53c500f51..a238de3fc 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.safestring import mark_safe from django.contrib import messages - +from django.conf import settings from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation @@ -16,7 +16,6 @@ from registrar.views.utility.permission_views import ( PortfolioDomainsPermissionView, PortfolioBasePermissionView, NoPortfolioDomainsPermissionView, - PortfolioInvitationCreatePermissionView, PortfolioMemberDomainsPermissionView, PortfolioMemberDomainsEditPermissionView, PortfolioMemberEditPermissionView, @@ -506,45 +505,163 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): return render(request, "portfolio_members.html") -class NewMemberView(PortfolioInvitationCreatePermissionView): + +class NewMemberView(PortfolioMembersPermissionView, FormMixin): + template_name = "portfolio_members_add_new.html" form_class = portfolioForms.NewMemberForm + def get_object(self, queryset=None): + """Get the portfolio object based on the session.""" + portfolio = self.request.session.get("portfolio") + if portfolio is None: + raise Http404("No organization found for this user") + return portfolio + def get_form_kwargs(self): - """Pass request and portfolio to form.""" + """Include the instance in the form kwargs.""" kwargs = super().get_form_kwargs() - kwargs["portfolio"] = self.request.session.get("portfolio") + kwargs["instance"] = self.get_object() return kwargs - def get_success_url(self): - return reverse("members") + def get(self, request, *args, **kwargs): + """Handle GET requests to display the form.""" + self.object = self.get_object() + form = self.get_form() + return self.render_to_response(self.get_context_data(form=form)) - def form_valid(self, form): - """Create portfolio invitation from form data.""" - if self.is_ajax(): - return JsonResponse({"is_valid": True}) + def post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_form() - # TODO: #3019 - this will probably have to be a small try/catch. Stub for posterity. - # requested_email = form.cleaned_data.get("email") - # send_success = self.send_portfolio_invitation_email(requested_email) - # if not send_success: - # return - - # Create instance using form's mapping method. - # Pass in a new object since we are adding a new record. - self.object = form.map_cleaned_data_to_instance(form.cleaned_data, PortfolioInvitation()) - self.object.save() - messages.success(self.request, f"{self.object.email} has been invited.") - return redirect(self.get_success_url()) - - # TODO: #3019 - # def send_portfolio_invitation_email(self, email): - # pass - - def form_invalid(self, form): - if self.is_ajax(): - return JsonResponse({"is_valid": False}) - return super().form_invalid(form) + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) def is_ajax(self): return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def form_invalid(self, form): + if self.is_ajax(): + return JsonResponse({"is_valid": False}) # Return a JSON response + else: + return super().form_invalid(form) # Handle non-AJAX requests normally + + def form_valid(self, form): + + if self.is_ajax(): + return JsonResponse({"is_valid": True}) # Return a JSON response + else: + return self.submit_new_member(form) + + def get_success_url(self): + """Redirect to members table.""" + return reverse("members") + + def _send_portfolio_invitation_email(self, email: str, requestor: User, add_success=True): + """Performs the sending of the member invitation email + email: string- email to send to + add_success: bool- default True indicates: + adding a success message to the view if the email sending succeeds + + raises EmailSendingError + """ + + # Set a default email address to send to for staff + requestor_email = settings.DEFAULT_FROM_EMAIL + + # Check if the email requestor has a valid email address + if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "": + requestor_email = requestor.email + elif not requestor.is_staff: + messages.error(self.request, "Can't send invitation email. No email is associated with your account.") + logger.error( + f"Can't send email to '{email}' on domain '{self.object}'." + f"No email exists for the requestor '{requestor.username}'.", + exc_info=True, + ) + return None + + # Check to see if an invite has already been sent + try: + invite = PortfolioInvitation.objects.get(email=email, portfolio=self.object) + if invite: # We have an existin invite + # check if the invite has already been accepted + if invite.status == PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED: + add_success = False + messages.warning( + self.request, + f"{email} is already a manager for this portfolio.", + ) + else: + add_success = False + # it has been sent but not accepted + messages.warning(self.request, f"{email} has already been invited to this portfolio") + return + except Exception as err: + logger.error(f"_send_portfolio_invitation_email() => An error occured: {err}") + + try: + logger.debug("requestor email: " + requestor_email) + + # send_templated_email( + # "emails/portfolio_invitation.txt", + # "emails/portfolio_invitation_subject.txt", + # to_address=email, + # context={ + # "portfolio": self.object, + # "requestor_email": requestor_email, + # }, + # ) + except EmailSendingError as exc: + logger.warn( + "Could not sent email invitation to %s for domain %s", + email, + self.object, + exc_info=True, + ) + raise EmailSendingError("Could not send email invitation.") from exc + else: + if add_success: + messages.success(self.request, f"{email} has been invited.") + + def _make_invitation(self, email_address: str, requestor: User, add_success=True): + """Make a Member invitation for this email and redirect with a message.""" + try: + self._send_portfolio_invitation_email(email=email_address, requestor=requestor, add_success=add_success) + except EmailSendingError: + logger.warn( + "Could not send email invitation (EmailSendingError)", + self.object, + exc_info=True, + ) + messages.warning(self.request, "Could not send email invitation.") + except Exception: + logger.warn( + "Could not send email invitation (Other Exception)", + self.object, + exc_info=True, + ) + messages.warning(self.request, "Could not send email invitation.") + else: + # (NOTE: only create a MemberInvitation if the e-mail sends correctly) + PortfolioInvitation.objects.get_or_create(email=email_address, portfolio=self.object) + return redirect(self.get_success_url()) + + def submit_new_member(self, form): + """Add the specified user as a member + for this portfolio. + Throws EmailSendingError.""" + requested_email = form.cleaned_data["email"] + requestor = self.request.user + + requested_user = User.objects.filter(email=requested_email).first() + permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists() + if not requested_user or not permission_exists: + return self._make_invitation(requested_email, requestor) + else: + if permission_exists: + messages.warning(self.request, "User is already a member of this portfolio.") + return redirect(self.get_success_url()) diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index fbf44fda1..6798eb4ee 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -9,6 +9,5 @@ from .permission_views import ( PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, DomainInvitationPermissionCancelView, - PortfolioInvitationCreatePermissionView, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 45474bddc..54ce5a921 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -1,7 +1,6 @@ """View classes that enforce authorization.""" import abc # abstract base class -from django.views.generic.edit import CreateView from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models.portfolio_invitation import PortfolioInvitation @@ -227,25 +226,6 @@ class PortfolioBasePermissionView(PortfolioBasePermission, DetailView, abc.ABC): raise NotImplementedError -class PortfolioInvitationCreatePermissionView(PortfolioInvitationCreatePermission, CreateView, abc.ABC): - """Abstract base view for portfolio views that enforces permissions. - - This abstract view cannot be instantiated. Actual views must specify - `template_name`. - """ - - # DetailView property for what model this is viewing - model = PortfolioInvitation - # variable name in template context for the model object - context_object_name = "portfolio_invitation" - - # Abstract property enforces NotImplementedError on an attribute. - @property - @abc.abstractmethod - def template_name(self): - raise NotImplementedError - - class PortfolioDomainsPermissionView(PortfolioDomainsPermission, PortfolioBasePermissionView, abc.ABC): """Abstract base view for portfolio domains views that enforces permissions. From 08082fb0aac6628e3994f63718fd3b9e77e91ea5 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:51:03 -0700 Subject: [PATCH 105/135] cleanup pt 2 --- .../src/js/getgov/portfolio-member-page.js | 236 +++++++++--------- .../templates/portfolio_members_add_new.html | 1 + src/registrar/tests/test_views_portfolio.py | 31 +-- src/registrar/views/utility/mixins.py | 18 -- .../views/utility/permission_views.py | 2 - 5 files changed, 135 insertions(+), 153 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index af25f0f1d..280c087f0 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -7,41 +7,41 @@ import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal export function initPortfolioNewMemberPageToggle() { document.addEventListener("DOMContentLoaded", () => { - const wrapperDeleteAction = document.getElementById("wrapper-delete-action") - if (wrapperDeleteAction) { - const member_type = wrapperDeleteAction.getAttribute("data-member-type"); - const member_id = wrapperDeleteAction.getAttribute("data-member-id"); - const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); - const member_name = wrapperDeleteAction.getAttribute("data-member-name"); - const member_email = wrapperDeleteAction.getAttribute("data-member-email"); - const member_delete_url = `${member_type}-${member_id}/delete`; - const unique_id = `${member_type}-${member_id}`; - - let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); - - // This easter egg is only for fixtures that dont have names as we are displaying their emails - // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); - - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - closeButton.click(); - let delete_member_form = document.getElementById("member-delete-form"); - if (delete_member_form) { - delete_member_form.submit(); - } - }); + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } }); - } - }); + }); + } +}); } @@ -52,122 +52,122 @@ export function initPortfolioNewMemberPageToggle() { export function initAddNewMemberPageListeners() { let add_member_form = document.getElementById("add_member_form"); if (!add_member_form){ - return; + return; } document.getElementById("confirm_new_member_submit").addEventListener("click", function() { - // Upon confirmation, submit the form - document.getElementById("add_member_form").submit(); +// Upon confirmation, submit the form +document.getElementById("add_member_form").submit(); }); document.getElementById("add_member_form").addEventListener("submit", function(event) { - event.preventDefault(); // Prevents the form from submitting - const form = document.getElementById("add_member_form") - const formData = new FormData(form); +event.preventDefault(); // Prevents the form from submitting +const form = document.getElementById("add_member_form") +const formData = new FormData(form); - // Check if the form is valid - // If the form is valid, open the confirmation modal - // If the form is invalid, submit it to trigger error - fetch(form.action, { - method: "POST", - body: formData, - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-CSRFToken": getCsrfToken() - } - }) - .then(response => response.json()) - .then(data => { - if (data.is_valid) { - // If the form is valid, show the confirmation modal before submitting - openAddMemberConfirmationModal(); - } else { - // If the form is not valid, trigger error messages by firing a submit event - form.submit(); - } - }); +// Check if the form is valid +// If the form is valid, open the confirmation modal +// If the form is invalid, submit it to trigger error +fetch(form.action, { + method: "POST", + body: formData, + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-CSRFToken": getCsrfToken() + } +}) +.then(response => response.json()) +.then(data => { + if (data.is_valid) { + // If the form is valid, show the confirmation modal before submitting + openAddMemberConfirmationModal(); + } else { + // If the form is not valid, trigger error messages by firing a submit event + form.submit(); + } +}); }); /* - Helper function to capitalize the first letter in a string (for display purposes) +Helper function to capitalize the first letter in a string (for display purposes) */ function capitalizeFirstLetter(text) { - if (!text) return ''; // Return empty string if input is falsy - return text.charAt(0).toUpperCase() + text.slice(1); +if (!text) return ''; // Return empty string if input is falsy +return text.charAt(0).toUpperCase() + text.slice(1); } /* - Populates contents of the "Add Member" confirmation modal +Populates contents of the "Add Member" confirmation modal */ function populatePermissionDetails(permission_details_div_id) { - const permissionDetailsContainer = document.getElementById("permission_details"); - permissionDetailsContainer.innerHTML = ""; // Clear previous content +const permissionDetailsContainer = document.getElementById("permission_details"); +permissionDetailsContainer.innerHTML = ""; // Clear previous content - // Get all permission sections (divs with h3 and radio inputs) - const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); +// Get all permission sections (divs with h3 and radio inputs) +const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - permissionSections.forEach(section => { - // Find the

      element text - const sectionTitle = section.textContent; +permissionSections.forEach(section => { + // Find the

      element text + const sectionTitle = section.textContent; - // Find the associated radio buttons container (next fieldset) - const fieldset = section.nextElementSibling; + // Find the associated radio buttons container (next fieldset) + const fieldset = section.nextElementSibling; - if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { + // Get the selected radio button within this fieldset + const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - // If a radio button is selected, get its label text - let selectedPermission = "No permission selected"; - if (selectedRadio) { - const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); - selectedPermission = label ? label.textContent : "No permission selected"; - } + // If a radio button is selected, get its label text + let selectedPermission = "No permission selected"; + if (selectedRadio) { + const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); + selectedPermission = label ? label.textContent : "No permission selected"; + } - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); + // Create new elements for the modal content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary"); + titleElement.classList.add("margin-bottom-0"); - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); + const permissionElement = document.createElement("p"); + permissionElement.textContent = selectedPermission; + permissionElement.classList.add("margin-top-0"); - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); - } - }); + // Append to the modal content container + permissionDetailsContainer.appendChild(titleElement); + permissionDetailsContainer.appendChild(permissionElement); + } +}); } /* - Updates and opens the "Add Member" confirmation modal. +Updates and opens the "Add Member" confirmation modal. */ function openAddMemberConfirmationModal() { - //------- Populate modal details - // Get email value - let emailValue = document.getElementById('id_email').value; - document.getElementById('modalEmail').textContent = emailValue; + //------- Populate modal details + // Get email value + let emailValue = document.getElementById('id_email').value; + document.getElementById('modalEmail').textContent = emailValue; - // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); - // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) - // This value does not have the first letter capitalized so let's capitalize it - let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; - document.getElementById('modalAccessLevel').textContent = accessText; + // Get selected radio button for access level + let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); + // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) + // This value does not have the first letter capitalized so let's capitalize it + let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; + document.getElementById('modalAccessLevel').textContent = accessText; - // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { - populatePermissionDetails('new-member-admin-permissions'); - } else { - populatePermissionDetails('new-member-basic-permissions'); + // Populate permission details based on access level + if (selectedAccess && selectedAccess.value === 'admin') { + populatePermissionDetails('new-member-admin-permissions'); + } else { + populatePermissionDetails('new-member-basic-permissions'); + } + + //------- Show the modal + let modalTrigger = document.querySelector("#invite_member_trigger"); + if (modalTrigger) { + modalTrigger.click(); } - - //------- Show the modal - let modalTrigger = document.querySelector("#invite_member_trigger"); - if (modalTrigger) { - modalTrigger.click(); - } } } diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 466358915..655b01852 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -190,3 +190,4 @@ {% endblock portfolio_content%} + diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index f5f1a4401..01383ae77 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2567,20 +2567,18 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): final_response = self.client.post( reverse("new-member"), { - "role": "organization_member", - "domain_request_permission_member": "view_all_requests", + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", "email": self.new_member_email, }, ) # Ensure the final submission is successful self.assertEqual(final_response.status_code, 302) # redirects after success + # Validate Database Changes portfolio_invite = PortfolioInvitation.objects.filter( - email=self.new_member_email, - portfolio=self.portfolio, - roles__exact=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], - additional_permissions__exact=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + email=self.new_member_email, portfolio=self.portfolio ).first() self.assertIsNotNone(portfolio_invite) self.assertEqual(portfolio_invite.email, self.new_member_email) @@ -2602,14 +2600,15 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "role": "organization_member", - "domain_request_permission_member": "view_all_requests", + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", "email": self.invited_member_email, }, ) - # Unsucessful form submissions return the same page with a 200 - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["form"].errors["email"][0], "An invitation already exists for this user.") + self.assertEqual(response.status_code, 302) # Redirects + + # TODO: verify messages + # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) @@ -2631,13 +2630,14 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "role": "organization_member", - "domain_request_permissions_member": "view_all_requests", + "member_access_level": "basic", + "basic_org_domain_request_permissions": "view_only", "email": self.user.email, }, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["form"].errors["email"][0], "User is already a member of this portfolio.") + self.assertEqual(response.status_code, 302) # Redirects + + # TODO: verify messages # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() @@ -2645,6 +2645,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): class TestEditPortfolioMemberView(WebTest): + """Tests for the edit member page on portfolios""" def setUp(self): self.user = create_user() diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index e62944c40..11384ca09 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -466,24 +466,6 @@ class PortfolioBasePermission(PermissionsLoginMixin): return self.request.user.is_org_user(self.request) -class PortfolioInvitationCreatePermission(PortfolioBasePermission): - """Permission mixin that redirects to portfolio pages if user - has access, otherwise 403""" - - def has_permission(self): - """Check if this user has access to this portfolio. - - The user is in self.request.user and the portfolio can be looked - up from the portfolio's primary key in self.kwargs["pk"] - """ - has_perm = super().has_permission() - if not has_perm: - return False - - portfolio = self.request.session.get("portfolio") - return self.request.user.has_edit_members_portfolio_permission(portfolio) - - class PortfolioDomainsPermission(PortfolioBasePermission): """Permission mixin that allows access to portfolio domain pages if user has access, otherwise 403""" diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 54ce5a921..c49f2daa1 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -3,7 +3,6 @@ import abc # abstract base class from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio -from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole @@ -15,7 +14,6 @@ from .mixins import ( DomainRequestWizardPermission, PortfolioDomainRequestsPermission, PortfolioDomainsPermission, - PortfolioInvitationCreatePermission, PortfolioMemberDomainsPermission, PortfolioMemberDomainsEditPermission, PortfolioMemberEditPermission, From 6965607f61d28aa400505efe231d4ecdc80c6c8a Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:55:17 -0700 Subject: [PATCH 106/135] Update portfolio-member-page.js --- .../src/js/getgov/portfolio-member-page.js | 304 +++++++++--------- 1 file changed, 152 insertions(+), 152 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 280c087f0..83fee661c 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -6,169 +6,169 @@ import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal export function initPortfolioNewMemberPageToggle() { - document.addEventListener("DOMContentLoaded", () => { - const wrapperDeleteAction = document.getElementById("wrapper-delete-action") - if (wrapperDeleteAction) { - const member_type = wrapperDeleteAction.getAttribute("data-member-type"); - const member_id = wrapperDeleteAction.getAttribute("data-member-id"); - const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); - const member_name = wrapperDeleteAction.getAttribute("data-member-name"); - const member_email = wrapperDeleteAction.getAttribute("data-member-email"); - const member_delete_url = `${member_type}-${member_id}/delete`; - const unique_id = `${member_type}-${member_id}`; - - let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); - - // This easter egg is only for fixtures that dont have names as we are displaying their emails - // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); - - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - closeButton.click(); - let delete_member_form = document.getElementById("member-delete-form"); - if (delete_member_form) { - delete_member_form.submit(); - } - }); - }); - } -}); + document.addEventListener("DOMContentLoaded", () => { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } + }); + }); + } + }); } /** -* Hooks up specialized listeners for handling form validation and modals -* on the Add New Member page. -*/ + * Hooks up specialized listeners for handling form validation and modals + * on the Add New Member page. + */ export function initAddNewMemberPageListeners() { -let add_member_form = document.getElementById("add_member_form"); -if (!add_member_form){ - return; -} -document.getElementById("confirm_new_member_submit").addEventListener("click", function() { -// Upon confirmation, submit the form -document.getElementById("add_member_form").submit(); -}); + let add_member_form = document.getElementById("add_member_form"); + if (!add_member_form){ + return; + } + document.getElementById("confirm_new_member_submit").addEventListener("click", function() { + // Upon confirmation, submit the form + document.getElementById("add_member_form").submit(); + }); -document.getElementById("add_member_form").addEventListener("submit", function(event) { -event.preventDefault(); // Prevents the form from submitting -const form = document.getElementById("add_member_form") -const formData = new FormData(form); + document.getElementById("add_member_form").addEventListener("submit", function(event) { + event.preventDefault(); // Prevents the form from submitting + const form = document.getElementById("add_member_form") + const formData = new FormData(form); -// Check if the form is valid -// If the form is valid, open the confirmation modal -// If the form is invalid, submit it to trigger error -fetch(form.action, { - method: "POST", - body: formData, - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-CSRFToken": getCsrfToken() - } -}) -.then(response => response.json()) -.then(data => { - if (data.is_valid) { - // If the form is valid, show the confirmation modal before submitting - openAddMemberConfirmationModal(); - } else { - // If the form is not valid, trigger error messages by firing a submit event - form.submit(); - } -}); -}); - -/* -Helper function to capitalize the first letter in a string (for display purposes) -*/ -function capitalizeFirstLetter(text) { -if (!text) return ''; // Return empty string if input is falsy -return text.charAt(0).toUpperCase() + text.slice(1); -} - -/* -Populates contents of the "Add Member" confirmation modal -*/ -function populatePermissionDetails(permission_details_div_id) { -const permissionDetailsContainer = document.getElementById("permission_details"); -permissionDetailsContainer.innerHTML = ""; // Clear previous content - -// Get all permission sections (divs with h3 and radio inputs) -const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - -permissionSections.forEach(section => { - // Find the

      element text - const sectionTitle = section.textContent; - - // Find the associated radio buttons container (next fieldset) - const fieldset = section.nextElementSibling; - - if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - - // If a radio button is selected, get its label text - let selectedPermission = "No permission selected"; - if (selectedRadio) { - const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); - selectedPermission = label ? label.textContent : "No permission selected"; + // Check if the form is valid + // If the form is valid, open the confirmation modal + // If the form is invalid, submit it to trigger error + fetch(form.action, { + method: "POST", + body: formData, + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-CSRFToken": getCsrfToken() } + }) + .then(response => response.json()) + .then(data => { + if (data.is_valid) { + // If the form is valid, show the confirmation modal before submitting + openAddMemberConfirmationModal(); + } else { + // If the form is not valid, trigger error messages by firing a submit event + form.submit(); + } + }); + }); - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); - - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); - - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); - } -}); -} - -/* -Updates and opens the "Add Member" confirmation modal. -*/ -function openAddMemberConfirmationModal() { - //------- Populate modal details - // Get email value - let emailValue = document.getElementById('id_email').value; - document.getElementById('modalEmail').textContent = emailValue; - - // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); - // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) - // This value does not have the first letter capitalized so let's capitalize it - let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; - document.getElementById('modalAccessLevel').textContent = accessText; - - // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { - populatePermissionDetails('new-member-admin-permissions'); - } else { - populatePermissionDetails('new-member-basic-permissions'); + /* + Helper function to capitalize the first letter in a string (for display purposes) + */ + function capitalizeFirstLetter(text) { + if (!text) return ''; // Return empty string if input is falsy + return text.charAt(0).toUpperCase() + text.slice(1); } - //------- Show the modal - let modalTrigger = document.querySelector("#invite_member_trigger"); - if (modalTrigger) { - modalTrigger.click(); - } -} + /* + Populates contents of the "Add Member" confirmation modal + */ + function populatePermissionDetails(permission_details_div_id) { + const permissionDetailsContainer = document.getElementById("permission_details"); + permissionDetailsContainer.innerHTML = ""; // Clear previous content + + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + + permissionSections.forEach(section => { + // Find the

      element text + const sectionTitle = section.textContent; + + // Find the associated radio buttons container (next fieldset) + const fieldset = section.nextElementSibling; + + if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { + // Get the selected radio button within this fieldset + const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + + // If a radio button is selected, get its label text + let selectedPermission = "No permission selected"; + if (selectedRadio) { + const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); + selectedPermission = label ? label.textContent : "No permission selected"; + } + + // Create new elements for the modal content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary"); + titleElement.classList.add("margin-bottom-0"); + + const permissionElement = document.createElement("p"); + permissionElement.textContent = selectedPermission; + permissionElement.classList.add("margin-top-0"); + + // Append to the modal content container + permissionDetailsContainer.appendChild(titleElement); + permissionDetailsContainer.appendChild(permissionElement); + } + }); + } + + /* + Updates and opens the "Add Member" confirmation modal. + */ + function openAddMemberConfirmationModal() { + //------- Populate modal details + // Get email value + let emailValue = document.getElementById('id_email').value; + document.getElementById('modalEmail').textContent = emailValue; + + // Get selected radio button for access level + let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); + // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) + // This value does not have the first letter capitalized so let's capitalize it + let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; + document.getElementById('modalAccessLevel').textContent = accessText; + + // Populate permission details based on access level + if (selectedAccess && selectedAccess.value === 'admin') { + populatePermissionDetails('new-member-admin-permissions'); + } else { + populatePermissionDetails('new-member-basic-permissions'); + } + + //------- Show the modal + let modalTrigger = document.querySelector("#invite_member_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } } From adfd6be7bbba732cce481c42b0769cf79e2bbeac Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:59:33 -0700 Subject: [PATCH 107/135] Fix some merge things --- src/registrar/views/portfolios.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index a238de3fc..855194f6b 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,15 +1,17 @@ import logging +from django.conf import settings + from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.safestring import mark_safe from django.contrib import messages -from django.conf import settings from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.utility.email import EmailSendingError from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, @@ -505,7 +507,6 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): return render(request, "portfolio_members.html") - class NewMemberView(PortfolioMembersPermissionView, FormMixin): template_name = "portfolio_members_add_new.html" From c3480893dab229ce6a9f8e5ef65cfa052126c2d3 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:06:40 -0700 Subject: [PATCH 108/135] Readd modelforms --- src/registrar/forms/portfolio.py | 276 +++++++++++++++++++------------ 1 file changed, 166 insertions(+), 110 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 34d334a3b..935c7c019 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -5,12 +5,14 @@ from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator from django.utils.safestring import mark_safe + from registrar.models import ( - User, + PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, + User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -109,6 +111,169 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data +class PortfolioMemberForm(forms.ModelForm): + """ + Form for updating a portfolio member. + """ + + roles = forms.MultipleChoiceField( + choices=UserPortfolioRoleChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Roles", + ) + + additional_permissions = forms.MultipleChoiceField( + choices=UserPortfolioPermissionChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Additional Permissions", + ) + + class Meta: + model = UserPortfolioPermission + fields = [ + "roles", + "additional_permissions", + ] + + +class PortfolioInvitedMemberForm(forms.ModelForm): + """ + Form for updating a portfolio invited member. + """ + + roles = forms.MultipleChoiceField( + choices=UserPortfolioRoleChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Roles", + ) + + additional_permissions = forms.MultipleChoiceField( + choices=UserPortfolioPermissionChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Additional Permissions", + ) + + class Meta: + model = PortfolioInvitation + fields = [ + "roles", + "additional_permissions", + ] + + +class NewMemberForm(forms.ModelForm): + member_access_level = forms.ChoiceField( + label="Select permission", + choices=[("admin", "Admin Access"), ("basic", "Basic Access")], + widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + admin_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + admin_org_members_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin member permission is required", + }, + ) + basic_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[ + ("view_only", "View all requests"), + ("view_and_create", "View all requests plus create requests"), + ("no_access", "No access"), + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Basic member permission is required", + }, + ) + + email = forms.EmailField( + label="Enter the email of the member you'd like to invite", + max_length=None, + error_messages={ + "invalid": ("Enter an email address in the required format, like name@example.com."), + "required": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + ) + + class Meta: + model = User + fields = ["email"] + + def clean(self): + cleaned_data = super().clean() + + # Lowercase the value of the 'email' field + email_value = cleaned_data.get("email") + if email_value: + cleaned_data["email"] = email_value.lower() + + ########################################## + # TODO: future ticket + # (invite new member) + ########################################## + # Check for an existing user (if there isn't any, send an invite) + # if email_value: + # try: + # existingUser = User.objects.get(email=email_value) + # except User.DoesNotExist: + # raise forms.ValidationError("User with this email does not exist.") + + member_access_level = cleaned_data.get("member_access_level") + + # Intercept the error messages so that we don't validate hidden inputs + if not member_access_level: + # If no member access level has been selected, delete error messages + # for all hidden inputs (which is everything except the e-mail input + # and member access selection) + for field in self.fields: + if field in self.errors and field != "email" and field != "member_access_level": + del self.errors[field] + return cleaned_data + + basic_dom_req_error = "basic_org_domain_request_permissions" + admin_dom_req_error = "admin_org_domain_request_permissions" + admin_member_error = "admin_org_members_permissions" + + if member_access_level == "admin" and basic_dom_req_error in self.errors: + # remove the error messages pertaining to basic permission inputs + del self.errors[basic_dom_req_error] + elif member_access_level == "basic": + # remove the error messages pertaining to admin permission inputs + if admin_dom_req_error in self.errors: + del self.errors[admin_dom_req_error] + if admin_member_error in self.errors: + del self.errors[admin_member_error] + return cleaned_data + + class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( @@ -332,112 +497,3 @@ class BasePortfolioMemberForm(forms.Form): role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False) instance.additional_permissions = list(additional_permissions - role_permissions) return instance - - -class NewMemberForm(forms.ModelForm): - member_access_level = forms.ChoiceField( - label="Select permission", - choices=[("admin", "Admin Access"), ("basic", "Basic Access")], - widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), - required=True, - error_messages={ - "required": "Member access level is required", - }, - ) - admin_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin domain request permission is required", - }, - ) - admin_org_members_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin member permission is required", - }, - ) - basic_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[ - ("view_only", "View all requests"), - ("view_and_create", "View all requests plus create requests"), - ("no_access", "No access"), - ], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Basic member permission is required", - }, - ) - - email = forms.EmailField( - label="Enter the email of the member you'd like to invite", - max_length=None, - error_messages={ - "invalid": ("Enter an email address in the required format, like name@example.com."), - "required": ("Enter an email address in the required format, like name@example.com."), - }, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], - required=True, - ) - - class Meta: - model = User - fields = ["email"] - - def clean(self): - cleaned_data = super().clean() - - # Lowercase the value of the 'email' field - email_value = cleaned_data.get("email") - if email_value: - cleaned_data["email"] = email_value.lower() - - ########################################## - # TODO: future ticket - # (invite new member) - ########################################## - # Check for an existing user (if there isn't any, send an invite) - # if email_value: - # try: - # existingUser = User.objects.get(email=email_value) - # except User.DoesNotExist: - # raise forms.ValidationError("User with this email does not exist.") - - member_access_level = cleaned_data.get("member_access_level") - - # Intercept the error messages so that we don't validate hidden inputs - if not member_access_level: - # If no member access level has been selected, delete error messages - # for all hidden inputs (which is everything except the e-mail input - # and member access selection) - for field in self.fields: - if field in self.errors and field != "email" and field != "member_access_level": - del self.errors[field] - return cleaned_data - - basic_dom_req_error = "basic_org_domain_request_permissions" - admin_dom_req_error = "admin_org_domain_request_permissions" - admin_member_error = "admin_org_members_permissions" - - if member_access_level == "admin" and basic_dom_req_error in self.errors: - # remove the error messages pertaining to basic permission inputs - del self.errors[basic_dom_req_error] - elif member_access_level == "basic": - # remove the error messages pertaining to admin permission inputs - if admin_dom_req_error in self.errors: - del self.errors[admin_dom_req_error] - if admin_member_error in self.errors: - del self.errors[admin_member_error] - return cleaned_data From a9923f4cc951022ac5f4068bf6d9ae106c7f9fd3 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:09:01 -0700 Subject: [PATCH 109/135] Update portfolio.py --- src/registrar/forms/portfolio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 935c7c019..5e3a7b324 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -273,7 +273,6 @@ class NewMemberForm(forms.ModelForm): del self.errors[admin_member_error] return cleaned_data - class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( From f19ff3cd66e5ea4d9b1ac671c2c69d3c689929c7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:25:21 -0700 Subject: [PATCH 110/135] PR suggestions --- src/registrar/admin.py | 4 -- src/registrar/forms/__init__.py | 1 + src/registrar/forms/portfolio.py | 43 ++++++------------- .../models/user_portfolio_permission.py | 7 ++- .../views/utility/permission_views.py | 1 + 5 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 72eff6d79..4465b7098 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4004,10 +4004,6 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") - # Normally you have to first enable the org feature then navigate to an org before you see these. - # Lets just auto-populate it on page load to make development easier. - extra_context["organization_members"] = flag_is_active_for_user(request.user, "organization_members") - extra_context["organization_requests"] = flag_is_active_for_user(request.user, "organization_requests") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 033e955ed..121e2b3f7 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -13,4 +13,5 @@ from .domain import ( ) from .portfolio import ( PortfolioOrgAddressForm, + PortfolioMemberForm, ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5e3a7b324..ecd21b8ee 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -273,7 +273,11 @@ class NewMemberForm(forms.ModelForm): del self.errors[admin_member_error] return cleaned_data + class BasePortfolioMemberForm(forms.Form): + """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" + + # The label for each of these has a red "required" star. We can just embed that here for simplicity. required_star = '*' role = forms.ChoiceField( choices=[ @@ -354,25 +358,11 @@ class BasePortfolioMemberForm(forms.Form): } def clean(self): - """ - Validates form data based on selected role and its required fields. - - Since form fields are dynamically shown/hidden via JavaScript based on role selection, - we only validate fields that are relevant to the selected role: - - organization_admin: ["member_permission_admin", "domain_request_permission_admin"] - - organization_member: ["domain_request_permission_member"] - This ensures users aren't required to fill out hidden fields and maintains - proper validation based on their role selection. - - NOTE: This page uses ROLE_REQUIRED_FIELDS for the aforementioned mapping. - Raises: - ValueError: If ROLE_REQUIRED_FIELDS references a non-existent form field - """ + """Validates form data based on selected role and its required fields.""" cleaned_data = super().clean() role = cleaned_data.get("role") - # Get required fields for the selected role. - # Then validate all required fields for the role. + # Get required fields for the selected role. Then validate all required fields for the role. required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) for field_name in required_fields: # Helpful error for if this breaks @@ -394,15 +384,17 @@ class BasePortfolioMemberForm(forms.Form): self.instance.save() return self.instance + # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: + # map_instance_to_form => called on init to set self.instance. + # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) + # into a dictionary representation for the form to use automatically. + + # map_cleaned_data_to_instance => called on save() to save the instance to the db. + # Takes the self.cleaned_data dict, and converts this dict back to the object. + def map_instance_to_form(self, instance): """ Maps user instance to form fields, handling roles and permissions. - - Determines: - - User's role (admin vs member) - - Domain request permissions (EDIT_REQUESTS, VIEW_ALL_REQUESTS, or "no_access") - - Member management permissions (EDIT_MEMBERS or VIEW_MEMBERS) - Returns form data dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", @@ -462,13 +454,6 @@ class BasePortfolioMemberForm(forms.Form): def map_cleaned_data_to_instance(self, cleaned_data, instance): """ Maps cleaned data to a member instance, setting roles and permissions. - - Additional permissions logic: - - For org admins: Adds domain request and member admin permissions if selected - - For other roles: Adds domain request member permissions if not 'no_access' - - Automatically adds VIEW permissions when EDIT permissions are granted - - Filters out permissions already granted by base role - Args: cleaned_data (dict): Cleaned data containing role and permission choices instance: Instance to update diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index f312f3dd0..25abb6748 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -111,7 +111,12 @@ class UserPortfolioPermission(TimeStampedModel): @classmethod def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True): - """Class method to return a list of permissions based on roles and addtl permissions""" + """Class method to return a list of permissions based on roles and addtl permissions. + Params: + roles => An array of roles + additional_permissions => An array of additional_permissions + get_list => If true, returns a list of perms. If false, returns a set of perms. + """ # Use a set to avoid duplicate permissions portfolio_permissions = set() if roles: diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index c49f2daa1..a3067d3a2 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -1,6 +1,7 @@ """View classes that enforce authorization.""" import abc # abstract base class + from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models.user import User From 0e1d07d59bcadbe81d1c60b527a92f3482ef1718 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:32:47 -0700 Subject: [PATCH 111/135] Update portfolio.py --- src/registrar/forms/portfolio.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ecd21b8ee..6e1e7d43c 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -331,7 +331,9 @@ class BasePortfolioMemberForm(forms.Form): }, ) - # Tracks what form elements are required for a given role choice + # Tracks what form elements are required for a given role choice. + # All of the fields included here have "required=False" by default as they are conditionally required. + # see def clean() for more details. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ "domain_request_permission_admin", From a314649de2e42c209b02e7565ab19f3736443545 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:33:34 -0700 Subject: [PATCH 112/135] Update portfolio.py --- src/registrar/forms/portfolio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 6e1e7d43c..ddfa93bc1 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -387,7 +387,7 @@ class BasePortfolioMemberForm(forms.Form): return self.instance # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: - # map_instance_to_form => called on init to set self.instance. + # map_instance_to_form => called on init to set self.initial. # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) # into a dictionary representation for the form to use automatically. From dec9e3362dc2534b6953b122a46bc599585ef87e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:02:27 -0700 Subject: [PATCH 113/135] Condense logic and change names --- src/registrar/forms/portfolio.py | 89 +++++++++++++++----------------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ddfa93bc1..ce164607e 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -281,6 +281,7 @@ class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( choices=[ + # Uses .value because the choice has a different label (on /admin) (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), ], @@ -345,10 +346,12 @@ class BasePortfolioMemberForm(forms.Form): } def __init__(self, *args, instance=None, **kwargs): + """Initialize self.instance, self.initial, and descriptions under each radio button. + Uses map_instance_to_initial to set the initial dictionary.""" super().__init__(*args, **kwargs) if instance: self.instance = instance - self.initial = self.map_instance_to_form(self.instance) + self.initial = self.map_instance_to_initial(self.instance) # Adds a

      description beneath each role option self.fields["role"].descriptions = { "organization_admin": UserPortfolioRoleChoices.get_role_description( @@ -359,6 +362,14 @@ class BasePortfolioMemberForm(forms.Form): ), } + def save(self): + """Saves self.instance by grabbing data from self.cleaned_data. + Uses map_cleaned_data_to_instance. + """ + self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) + self.instance.save() + return self.instance + def clean(self): """Validates form data based on selected role and its required fields.""" cleaned_data = super().clean() @@ -380,23 +391,17 @@ class BasePortfolioMemberForm(forms.Form): return cleaned_data - def save(self): - """Save the form data to the instance""" - self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) - self.instance.save() - return self.instance - - # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: - # map_instance_to_form => called on init to set self.initial. + # Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work: + # map_instance_to_initial => called on init to set self.initial. # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) # into a dictionary representation for the form to use automatically. # map_cleaned_data_to_instance => called on save() to save the instance to the db. # Takes the self.cleaned_data dict, and converts this dict back to the object. - def map_instance_to_form(self, instance): + def map_instance_to_initial(self, instance): """ - Maps user instance to form fields, handling roles and permissions. + Maps self.instance to self.initial, handling roles and permissions. Returns form data dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", @@ -405,57 +410,47 @@ class BasePortfolioMemberForm(forms.Form): "domain_request_permission_member": permission level if member } """ - if not instance: - return {} - # Function variables form_data = {} perms = UserPortfolioPermission.get_portfolio_permissions( instance.roles, instance.additional_permissions, get_list=False ) - # Explanation of this logic pattern: we can only display one item in the list at a time. - # But how do we determine what is most important to display in a list? Order-based hierarchy. - # Example: print(instance.roles) => (output) ["organization_admin", "organization_member"] - # If we can only pick one item in this list, we should pick organization_admin. - - # Get role - roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - role = next((role for role in roles if role in instance.roles), None) - is_admin = role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN - - # Get domain request permission level - # First we get permissions we expect to display (ordered hierarchically). - # Then we check if this item exists in the list and return the first instance of it. - domain_permissions = [ + # Get the available options for roles, domains, and member. + roles = [ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + ] + domain_perms = [ UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] - domain_request_permission = next((perm for perm in domain_permissions if perm in perms), None) + member_perms = [ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] - # Get member permission level. - member_permissions = [UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS] - member_permission = next((perm for perm in member_permissions if perm in perms), None) - - # Build form data based on role. - form_data = { - "role": role, - "member_permission_admin": getattr(member_permission, "value", None) if is_admin else None, - "domain_request_permission_admin": getattr(domain_request_permission, "value", None) if is_admin else None, - "domain_request_permission_member": ( - getattr(domain_request_permission, "value", None) if not is_admin else None - ), - } - - # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. - if domain_request_permission is None and not is_admin: - form_data["domain_request_permission_member"] = "no_access" + # Build form data based on role (which options are available). + # Get which one should be "selected" by assuming that EDIT takes precedence over view, + # and ADMIN takes precedence over MEMBER. + selected_role = next((role for role in roles if role in instance.roles), None) + form_data = {"role": selected_role} + is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN + if is_admin: + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) + selected_member_permission = next((perm for perm in member_perms if perm in perms), None) + form_data["domain_request_permission_admin"] = selected_domain_permission + form_data["member_permission_admin"] = selected_member_permission + else: + # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") + form_data["domain_request_permission_member"] = selected_domain_permission return form_data def map_cleaned_data_to_instance(self, cleaned_data, instance): """ - Maps cleaned data to a member instance, setting roles and permissions. + Maps self.cleaned_data to self.instance, setting roles and permissions. Args: cleaned_data (dict): Cleaned data containing role and permission choices instance: Instance to update From 57c7c6f709051d41ed392124d732d670d06257fb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:56:29 -0700 Subject: [PATCH 114/135] Revert "revert csv-export" This reverts commit b0d1bc26da85e0e6ac8bae4746d529089a6b61de. --- src/registrar/tests/test_reports.py | 140 ++++---- src/registrar/utility/csv_export.py | 523 ++++++++++++++++++++++------ 2 files changed, 479 insertions(+), 184 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index f91c5b299..cafaff7b1 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,32 +251,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," - "SO email,Security contact email,Domain managers,Invited domain managers\n" - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," - "meoward@rocks.com,\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," - ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," - "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," - "security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency," + "Organization name,City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," "meoward@rocks.com,squeaker@rocks.com\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "squeaker@rocks.com\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal," + "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -312,20 +315,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," - '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," + "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" - "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" - "zdomain12.govInterstateReady(blank)\n" + "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" + "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,7 +611,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" - self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -646,7 +645,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -683,7 +681,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -721,10 +718,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) - @less_console_noise_decorator + # @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -766,35 +762,34 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() + expected_content = ( # Header - "Domain request,Status,Domain type,Federal type," - "Federal agency,Organization name,Election office,City,State/territory," - "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," - "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," - "SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," + "City,State/territory,Region,Creator first name,Creator last name,Creator email," + "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," + "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," - "testy@town.com," - "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' - 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' - "CISA-last-name " - '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' - 'testy2@town.com"' - ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com" - ",cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com," + "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," + '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' + 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' + 'Testy Tester testy2@town.com",' + 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," + "Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," + "Testy Tester testy2@town.com," + "cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -862,7 +857,6 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user - self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index a03e51de5..97feae20c 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -414,8 +414,11 @@ class MemberExport(BaseExport): ) .values(*shared_columns) ) - - return convert_queryset_to_dict(permissions.union(invitations), is_model=False) + # Adding a order_by increases output predictability. + # Doesn't matter as much for normal use, but makes tests easier. + # We should also just be ordering by default anyway. + members = permissions.union(invitations).order_by("email_display") + return convert_queryset_to_dict(members, is_model=False) @classmethod def get_invited_by_query(cls, object_id_query): @@ -525,6 +528,115 @@ class DomainExport(BaseExport): # Return the model class that this export handles return DomainInformation + @classmethod + def get_computed_fields(cls, **kwargs): + """ + Get a dict of computed fields. + """ + # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. + # This is for performance purposes. Since we are working with dictionary values and not + # model objects as we export data, trying to reinstate model objects in order to grab @property + # values negatively impacts performance. Therefore, we will follow best practice and use annotations + return { + "converted_generic_org_type": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + output_field=CharField(), + ), + "converted_federal_agency": Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__agency"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__agency"), + output_field=CharField(), + ), + "converted_federal_type": Case( + # When portfolio is present, use its value instead + # NOTE: this is an @Property funciton in portfolio. + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_type"), + output_field=CharField(), + ), + "converted_organization_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_name")), + # Otherwise, return the natively assigned value + default=F("organization_name"), + output_field=CharField(), + ), + "converted_city": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__city")), + # Otherwise, return the natively assigned value + default=F("city"), + output_field=CharField(), + ), + "converted_state_territory": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__state_territory")), + # Otherwise, return the natively assigned value + default=F("state_territory"), + output_field=CharField(), + ), + "converted_so_email": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__email"), + output_field=CharField(), + ), + "converted_senior_official_last_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__last_name"), + output_field=CharField(), + ), + "converted_senior_official_first_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__first_name"), + output_field=CharField(), + ), + "converted_senior_official_title": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__title"), + output_field=CharField(), + ), + "converted_so_name": Case( + # When portfolio is present, use that senior official instead + When( + Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), + then=Concat( + Coalesce(F("portfolio__senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("portfolio__senior_official__last_name"), Value("")), + output_field=CharField(), + ), + ), + # Otherwise, return the natively assigned senior official + default=Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + output_field=CharField(), + ), + } + @classmethod def update_queryset(cls, queryset, **kwargs): """ @@ -614,10 +726,10 @@ class DomainExport(BaseExport): if first_ready_on is None: first_ready_on = "(blank)" - # organization_type has generic_org_type AND is_election - domain_org_type = model.get("organization_type") + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("federal_type") + domain_federal_type = model.get("converted_federal_type") human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) domain_type = human_readable_domain_org_type if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: @@ -640,12 +752,12 @@ class DomainExport(BaseExport): "First ready on": first_ready_on, "Expiration date": expiration_date, "Domain type": domain_type, - "Agency": model.get("federal_agency__agency"), - "Organization name": model.get("organization_name"), - "City": model.get("city"), - "State": model.get("state_territory"), - "SO": model.get("so_name"), - "SO email": model.get("senior_official__email"), + "Agency": model.get("converted_federal_agency"), + "Organization name": model.get("converted_organization_name"), + "City": model.get("converted_city"), + "State": model.get("converted_state_territory"), + "SO": model.get("converted_so_name"), + "SO email": model.get("converted_so_email"), "Security contact email": security_contact_email, "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), @@ -654,8 +766,23 @@ class DomainExport(BaseExport): } row = [FIELDS.get(column, "") for column in columns] + return row + def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): + """Returns a list of Domain Requests that has been filtered by the given organization value.""" + + annotated_queryset = domain_infos_to_filter.annotate( + converted_generic_org_type=Case( + # Recreate the logic of the converted_generic_org_type property + # here in annotations + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + default=F("generic_org_type"), + output_field=CharField(), + ) + ) + return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) + @classmethod def get_sliced_domains(cls, filter_condition): """Get filtered domains counts sliced by org type and election office. @@ -663,23 +790,51 @@ class DomainExport(BaseExport): when a domain has more that one manager. """ - 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() + domain_informations = DomainInformation.objects.all().filter(**filter_condition).distinct() + domains_count = domain_informations.count() + federal = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.FEDERAL) + .distinct() + .count() + ) + interstate = cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.INTERSTATE + ).count() + state_or_territory = ( + cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY + ) + .distinct() + .count() + ) + tribal = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.TRIBAL) + .distinct() + .count() + ) + county = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.COUNTY) + .distinct() + .count() + ) + city = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.CITY) + .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() + cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT + ) + .distinct() + .count() ) school_district = ( - domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) + .distinct() + .count() ) - election_board = domains.filter(is_election_board=True).distinct().count() + election_board = domain_informations.filter(is_election_board=True).distinct().count() return [ domains_count, @@ -706,6 +861,7 @@ class DomainDataType(DomainExport): """ Overrides the columns for CSV export specific to DomainExport. """ + return [ "Domain name", "Status", @@ -723,6 +879,13 @@ class DomainDataType(DomainExport): "Invited domain managers", ] + @classmethod + def get_annotations_for_sort(cls): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -730,9 +893,9 @@ class DomainDataType(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -773,20 +936,6 @@ class DomainDataType(DomainExport): """ return ["domain__permissions"] - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -892,7 +1041,7 @@ class DomainRequestsDataType: cls.safe_get(getattr(request, "region_field", None)), request.status, cls.safe_get(getattr(request, "election_office", None)), - request.federal_type, + request.converted_federal_type, cls.safe_get(getattr(request, "domain_type", None)), cls.safe_get(getattr(request, "additional_details", None)), cls.safe_get(getattr(request, "creator_approved_domains_count", None)), @@ -943,6 +1092,13 @@ class DomainDataFull(DomainExport): "Security contact email", ] + @classmethod + def get_annotations_for_sort(cls, delimiter=", "): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -950,9 +1106,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -990,20 +1146,6 @@ class DomainDataFull(DomainExport): ], ) - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -1037,6 +1179,13 @@ class DomainDataFederal(DomainExport): "Security contact email", ] + @classmethod + def get_annotations_for_sort(cls, delimiter=", "): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -1044,9 +1193,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -1085,20 +1234,6 @@ class DomainDataFederal(DomainExport): ], ) - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -1476,24 +1611,180 @@ class DomainRequestExport(BaseExport): # Return the model class that this export handles return DomainRequest + def get_filtered_domain_requests_by_org(domain_requests_to_filter, org_to_filter_by): + """Returns a list of Domain Requests that has been filtered by the given organization value""" + annotated_queryset = domain_requests_to_filter.annotate( + converted_generic_org_type=Case( + # Recreate the logic of the converted_generic_org_type property + # here in annotations + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + default=F("generic_org_type"), + output_field=CharField(), + ) + ) + return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) + + # return domain_requests_to_filter.filter( + # # Filter based on the generic org value returned by converted_generic_org_type + # id__in=[ + # domainRequest.id + # for domainRequest in domain_requests_to_filter + # if domainRequest.converted_generic_org_type + # and domainRequest.converted_generic_org_type == org_to_filter_by + # ] + # ) + + @classmethod + def get_computed_fields(cls, delimiter=", ", **kwargs): + """ + Get a dict of computed fields. + """ + # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. + # This is for performance purposes. Since we are working with dictionary values and not + # model objects as we export data, trying to reinstate model objects in order to grab @property + # values negatively impacts performance. Therefore, we will follow best practice and use annotations + return { + "converted_generic_org_type": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + output_field=CharField(), + ), + "converted_federal_agency": Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__agency"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__agency"), + output_field=CharField(), + ), + "converted_federal_type": Case( + # When portfolio is present, use its value instead + # NOTE: this is an @Property funciton in portfolio. + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_type"), + output_field=CharField(), + ), + "converted_organization_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_name")), + # Otherwise, return the natively assigned value + default=F("organization_name"), + output_field=CharField(), + ), + "converted_city": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__city")), + # Otherwise, return the natively assigned value + default=F("city"), + output_field=CharField(), + ), + "converted_state_territory": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__state_territory")), + # Otherwise, return the natively assigned value + default=F("state_territory"), + output_field=CharField(), + ), + "converted_so_email": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__email"), + output_field=CharField(), + ), + "converted_senior_official_last_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__last_name"), + output_field=CharField(), + ), + "converted_senior_official_first_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__first_name"), + output_field=CharField(), + ), + "converted_senior_official_title": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__title"), + output_field=CharField(), + ), + "converted_so_name": Case( + # When portfolio is present, use that senior official instead + When( + Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), + then=Concat( + Coalesce(F("portfolio__senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("portfolio__senior_official__last_name"), Value("")), + output_field=CharField(), + ), + ), + # Otherwise, return the natively assigned senior official + default=Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + output_field=CharField(), + ), + } + @classmethod def get_sliced_requests(cls, filter_condition): """Get filtered requests counts sliced by org type and election office.""" 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() + federal = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.FEDERAL) + .distinct() + .count() + ) + interstate = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.INTERSTATE) + .distinct() + .count() + ) + state_or_territory = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY) + .distinct() + .count() + ) + tribal = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.TRIBAL) + .distinct() + .count() + ) + county = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.COUNTY) + .distinct() + .count() + ) + city = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.CITY).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() + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT) + .distinct() + .count() ) school_district = ( - requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) + .distinct() + .count() ) election_board = requests.filter(is_election_board=True).distinct().count() @@ -1517,11 +1808,11 @@ class DomainRequestExport(BaseExport): """ # Handle the federal_type field. Defaults to the wrong format. - federal_type = model.get("federal_type") + federal_type = model.get("converted_federal_type") human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None # Handle the org_type field - org_type = model.get("generic_org_type") or model.get("organization_type") + org_type = model.get("converted_generic_org_type") human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None # Handle the status field. Defaults to the wrong format. @@ -1569,19 +1860,19 @@ class DomainRequestExport(BaseExport): "Other contacts": model.get("all_other_contacts"), "Current websites": model.get("all_current_websites"), # Untouched FK fields - passed into the request dict. - "Federal agency": model.get("federal_agency__agency"), - "SO first name": model.get("senior_official__first_name"), - "SO last name": model.get("senior_official__last_name"), - "SO email": model.get("senior_official__email"), - "SO title/role": model.get("senior_official__title"), + "Federal agency": model.get("converted_federal_agency"), + "SO first name": model.get("converted_senior_official_first_name"), + "SO last name": model.get("converted_senior_official_last_name"), + "SO email": model.get("converted_so_email"), + "SO title/role": model.get("converted_senior_official_title"), "Creator first name": model.get("creator__first_name"), "Creator last name": model.get("creator__last_name"), "Creator email": model.get("creator__email"), "Investigator": model.get("investigator__email"), # Untouched fields - "Organization name": model.get("organization_name"), - "City": model.get("city"), - "State/territory": model.get("state_territory"), + "Organization name": model.get("converted_organization_name"), + "City": model.get("converted_city"), + "State/territory": model.get("converted_state_territory"), "Request purpose": model.get("purpose"), "CISA regional representative": model.get("cisa_representative_email"), "Last submitted date": model.get("last_submitted_date"), @@ -1724,24 +2015,34 @@ class DomainRequestDataFull(DomainRequestExport): """ Get a dict of computed fields. """ - return { - "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), - "creator_active_requests_count": cls.get_creator_active_requests_count_query(), - "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), - "all_alternative_domains": StringAgg("alternative_domains__website", delimiter=delimiter, distinct=True), - # Coerce the other contacts object to "{first_name} {last_name} {email}" - "all_other_contacts": StringAgg( - Concat( - "other_contacts__first_name", - Value(" "), - "other_contacts__last_name", - Value(" "), - "other_contacts__email", + # Get computed fields from the parent class + computed_fields = super().get_computed_fields() + + # Add additional computed fields + computed_fields.update( + { + "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), + "creator_active_requests_count": cls.get_creator_active_requests_count_query(), + "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), + "all_alternative_domains": StringAgg( + "alternative_domains__website", delimiter=delimiter, distinct=True ), - delimiter=delimiter, - distinct=True, - ), - } + # Coerce the other contacts object to "{first_name} {last_name} {email}" + "all_other_contacts": StringAgg( + Concat( + "other_contacts__first_name", + Value(" "), + "other_contacts__last_name", + Value(" "), + "other_contacts__email", + ), + delimiter=delimiter, + distinct=True, + ), + } + ) + + return computed_fields @classmethod def get_related_table_fields(cls): From 8bfeedf6ef43eb1d8df216834a9f0da9804554d1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:09:09 -0700 Subject: [PATCH 115/135] remove converted fields --- src/registrar/utility/csv_export.py | 122 ++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 97feae20c..310bfd8c3 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1077,6 +1077,67 @@ class DomainDataFull(DomainExport): Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def parse_row(cls, columns, model): + """ + Given a set of columns and a model dictionary, generate a new row from cleaned column data. + """ + + status = model.get("domain__state") + human_readable_status = Domain.State.get_state_label(status) + + expiration_date = model.get("domain__expiration_date") + if expiration_date is None: + expiration_date = "(blank)" + + first_ready_on = model.get("domain__first_ready") + if first_ready_on is None: + first_ready_on = "(blank)" + + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") + human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) + domain_federal_type = model.get("converted_federal_type") + human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) + domain_type = human_readable_domain_org_type + if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: + domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" + + security_contact_email = model.get("security_contact_email") + invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + if ( + not security_contact_email + or not isinstance(security_contact_email, str) + or security_contact_email.lower().strip() in invalid_emails + ): + security_contact_email = "(blank)" + + # create a dictionary of fields which can be included in output. + # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": human_readable_status, + "First ready on": first_ready_on, + "Expiration date": expiration_date, + "Domain type": domain_type, + "Agency": model.get("federal_agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("so_email"), + "Security contact email": security_contact_email, + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + + row = [FIELDS.get(column, "") for column in columns] + + return row + @classmethod def get_columns(cls): """ @@ -1164,6 +1225,67 @@ class DomainDataFederal(DomainExport): Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def parse_row(cls, columns, model): + """ + Given a set of columns and a model dictionary, generate a new row from cleaned column data. + """ + + status = model.get("domain__state") + human_readable_status = Domain.State.get_state_label(status) + + expiration_date = model.get("domain__expiration_date") + if expiration_date is None: + expiration_date = "(blank)" + + first_ready_on = model.get("domain__first_ready") + if first_ready_on is None: + first_ready_on = "(blank)" + + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") + human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) + domain_federal_type = model.get("converted_federal_type") + human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) + domain_type = human_readable_domain_org_type + if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: + domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" + + security_contact_email = model.get("security_contact_email") + invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + if ( + not security_contact_email + or not isinstance(security_contact_email, str) + or security_contact_email.lower().strip() in invalid_emails + ): + security_contact_email = "(blank)" + + # create a dictionary of fields which can be included in output. + # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": human_readable_status, + "First ready on": first_ready_on, + "Expiration date": expiration_date, + "Domain type": domain_type, + "Agency": model.get("federal_agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("so_email"), + "Security contact email": security_contact_email, + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + + row = [FIELDS.get(column, "") for column in columns] + + return row + @classmethod def get_columns(cls): """ From 0cd504eb781bf47160f4188b77e7e6196246431d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:16:17 -0700 Subject: [PATCH 116/135] Update csv_export.py --- src/registrar/utility/csv_export.py | 133 +++++++--------------------- 1 file changed, 34 insertions(+), 99 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 310bfd8c3..07014f185 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -744,30 +744,41 @@ class DomainExport(BaseExport): ): security_contact_email = "(blank)" + model["status"] = human_readable_status + model["first_ready_on"] = first_ready_on + model["expiration_date"] = expiration_date + model["domain_type"] = domain_type + model["security_contact_email"] = security_contact_email # create a dictionary of fields which can be included in output. # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = cls.get_fields(model) + + row = [FIELDS.get(column, "") for column in columns] + + return row + + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("converted_federal_agency"), "Organization name": model.get("converted_organization_name"), "City": model.get("converted_city"), "State": model.get("converted_state_territory"), "SO": model.get("converted_so_name"), "SO email": model.get("converted_so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): """Returns a list of Domain Requests that has been filtered by the given organization value.""" @@ -1079,64 +1090,26 @@ class DomainDataFull(DomainExport): # NOTE - this override is temporary. Delete this after we consolidate these @property fields. @classmethod - def parse_row(cls, columns, model): - """ - Given a set of columns and a model dictionary, generate a new row from cleaned column data. - """ - - status = model.get("domain__state") - human_readable_status = Domain.State.get_state_label(status) - - expiration_date = model.get("domain__expiration_date") - if expiration_date is None: - expiration_date = "(blank)" - - first_ready_on = model.get("domain__first_ready") - if first_ready_on is None: - first_ready_on = "(blank)" - - # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") - human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("converted_federal_type") - human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) - domain_type = human_readable_domain_org_type - if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: - domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" - - security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} - if ( - not security_contact_email - or not isinstance(security_contact_email, str) - or security_contact_email.lower().strip() in invalid_emails - ): - security_contact_email = "(blank)" - - # create a dictionary of fields which can be included in output. - # "extra_fields" are precomputed fields (generated in the DB or parsed). + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("federal_agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), "SO email": model.get("so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS @classmethod def get_columns(cls): @@ -1227,64 +1200,26 @@ class DomainDataFederal(DomainExport): # NOTE - this override is temporary. Delete this after we consolidate these @property fields. @classmethod - def parse_row(cls, columns, model): - """ - Given a set of columns and a model dictionary, generate a new row from cleaned column data. - """ - - status = model.get("domain__state") - human_readable_status = Domain.State.get_state_label(status) - - expiration_date = model.get("domain__expiration_date") - if expiration_date is None: - expiration_date = "(blank)" - - first_ready_on = model.get("domain__first_ready") - if first_ready_on is None: - first_ready_on = "(blank)" - - # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") - human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("converted_federal_type") - human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) - domain_type = human_readable_domain_org_type - if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: - domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" - - security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} - if ( - not security_contact_email - or not isinstance(security_contact_email, str) - or security_contact_email.lower().strip() in invalid_emails - ): - security_contact_email = "(blank)" - - # create a dictionary of fields which can be included in output. - # "extra_fields" are precomputed fields (generated in the DB or parsed). + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("federal_agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), "SO email": model.get("so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS @classmethod def get_columns(cls): From 26fd19ffe80e15c7381ed52802be42da02075880 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:21:06 -0700 Subject: [PATCH 117/135] Update test_reports.py --- src/registrar/tests/test_reports.py | 138 +++++++++++++++------------- 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cafaff7b1..f91c5b299 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,35 +251,32 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency," - "Organization name,City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - "meoward@rocks.com,squeaker@rocks.com\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," - "World War I Centennial Commission,,,, ,,(blank)," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," + "SO email,Security contact email,Domain managers,Invited domain managers\n" + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," "meoward@rocks.com,\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," + ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal," - "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," + "security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "meoward@rocks.com,squeaker@rocks.com\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -315,17 +312,20 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + "City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," + '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" - "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" - "zdomain12.gov,Interstate,Ready,(blank)\n" + "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" + "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" + "zdomain12.govInterstateReady(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,6 +611,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" + self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -645,6 +646,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -681,6 +683,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -718,9 +721,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) - # @less_console_noise_decorator + @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -762,34 +766,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() - expected_content = ( # Header - "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," - "City,State/territory,Region,Creator first name,Creator last name,Creator email," - "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," - "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type," + "Federal agency,Organization name,Election office,City,State/territory," + "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," + "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," + "SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," - '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' - 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' - 'Testy Tester testy2@town.com",' - 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," - "Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," - "Testy Tester testy2@town.com," - "cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," + "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," + "testy@town.com," + "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' + 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' + "CISA-last-name " + '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' + 'testy2@town.com"' + ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com" + ",cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -857,6 +862,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user + self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 From 431b0e80cf316558c3bd1eea72aba142ae379f5b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:21:52 -0700 Subject: [PATCH 118/135] Revert "Update test_reports.py" This reverts commit 26fd19ffe80e15c7381ed52802be42da02075880. --- src/registrar/tests/test_reports.py | 140 +++++++++++++--------------- 1 file changed, 67 insertions(+), 73 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index f91c5b299..cafaff7b1 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,32 +251,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," - "SO email,Security contact email,Domain managers,Invited domain managers\n" - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," - "meoward@rocks.com,\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," - ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," - "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," - "security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency," + "Organization name,City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," "meoward@rocks.com,squeaker@rocks.com\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "squeaker@rocks.com\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal," + "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -312,20 +315,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," - '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," + "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" - "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" - "zdomain12.govInterstateReady(blank)\n" + "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" + "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,7 +611,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" - self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -646,7 +645,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -683,7 +681,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -721,10 +718,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) - @less_console_noise_decorator + # @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -766,35 +762,34 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() + expected_content = ( # Header - "Domain request,Status,Domain type,Federal type," - "Federal agency,Organization name,Election office,City,State/territory," - "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," - "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," - "SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," + "City,State/territory,Region,Creator first name,Creator last name,Creator email," + "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," + "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," - "testy@town.com," - "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' - 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' - "CISA-last-name " - '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' - 'testy2@town.com"' - ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com" - ",cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com," + "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," + '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' + 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' + 'Testy Tester testy2@town.com",' + 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," + "Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," + "Testy Tester testy2@town.com," + "cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -862,7 +857,6 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user - self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 From d2d787c8eaf8562c35a791f2f22825ccaaca15a7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:24:59 -0700 Subject: [PATCH 119/135] Revert relevant tests back to normal --- src/registrar/tests/test_reports.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cafaff7b1..995782eea 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator From 297be33e645bbf454fe43084099db661912c3286 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:42:44 -0700 Subject: [PATCH 120/135] remove log --- src/registrar/assets/src/js/getgov/portfolio-member-page.js | 1 - src/registrar/forms/portfolio.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 83fee661c..02d927438 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -175,7 +175,6 @@ export function initAddNewMemberPageListeners() { // Initalize the radio for the member pages export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { - console.log("new content 2") let memberForm = document.getElementById("member_form"); let newMemberForm = document.getElementById("add_member_form") if (memberForm) { diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ce164607e..eaa885a85 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -433,7 +433,8 @@ class BasePortfolioMemberForm(forms.Form): # Build form data based on role (which options are available). # Get which one should be "selected" by assuming that EDIT takes precedence over view, # and ADMIN takes precedence over MEMBER. - selected_role = next((role for role in roles if role in instance.roles), None) + roles = instance.roles or [] + selected_role = next((role for role in roles if role in roles), None) form_data = {"role": selected_role} is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN if is_admin: From 8b72c654b8a3fb5c234b03e3a7bec334664dc8f1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:49:28 -0700 Subject: [PATCH 121/135] Update csv_export.py --- src/registrar/utility/csv_export.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 07014f185..93fcaaf84 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1097,12 +1097,12 @@ class DomainDataFull(DomainExport): "First ready on": model.get("first_ready_on"), "Expiration date": model.get("expiration_date"), "Domain type": model.get("domain_type"), - "Agency": model.get("federal_agency"), + "Agency": model.get("federal_agency__agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), - "SO email": model.get("so_email"), + "SO email": model.get("senior_official__email"), "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), @@ -1207,12 +1207,12 @@ class DomainDataFederal(DomainExport): "First ready on": model.get("first_ready_on"), "Expiration date": model.get("expiration_date"), "Domain type": model.get("domain_type"), - "Agency": model.get("federal_agency"), + "Agency": model.get("federal_agency__agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), - "SO email": model.get("so_email"), + "SO email": model.get("senior_official__email"), "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), From fcdc0f0f0fc4f5d319530619260ab9ee5aaf287d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:53:17 -0700 Subject: [PATCH 122/135] Fix different ordering --- src/registrar/utility/csv_export.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 93fcaaf84..4de947594 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1140,9 +1140,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1250,9 +1250,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] From b0cf5df7984800fa1283301f67d61883e58dee1f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:06:47 -0700 Subject: [PATCH 123/135] add zap --- src/zap.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zap.conf b/src/zap.conf index 65468773a..782eaa0e4 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -75,6 +75,7 @@ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/prototype-dns +10038 OUTOFSCOPE http://app:8080/suborganization # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers From 6b2552bdbc3f4ee093dcf9edfa5bd1d428781c30 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:08:05 -0700 Subject: [PATCH 124/135] Revert "add zap" This reverts commit b0cf5df7984800fa1283301f67d61883e58dee1f. --- src/zap.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 782eaa0e4..65468773a 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -75,7 +75,6 @@ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/prototype-dns -10038 OUTOFSCOPE http://app:8080/suborganization # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers From fa6b4b74b8be1ddeda23a4aed286a7edbc0261b4 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 18 Dec 2024 23:20:53 -0700 Subject: [PATCH 125/135] linted --- src/registrar/admin.py | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 374a5f5aa..74401ecc9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1829,7 +1829,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): form = DomainRequestAdminForm change_form_template = "django/admin/domain_request_change_form.html" - + # ------ Filters ------ # Define custom filters class StatusListFilter(MultipleChoiceListFilter): @@ -1966,7 +1966,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return queryset.filter(is_election_board=True) if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) - + class PortfolioFilter(admin.SimpleListFilter): """Define a custom filter for portfolio""" @@ -1978,7 +1978,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ("1", _("Yes")), ("0", _("No")), ) - + def queryset(self, request, queryset): if self.value() == "1": return queryset.filter(Q(portfolio__isnull=False)) @@ -1992,24 +1992,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore - @admin.display(description=_("Requested Domain")) def custom_requested_domain(self, obj): # Example: Show different icons based on `status` url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}" text = obj.requested_domain - icon = '' if obj.portfolio: - return format_html( - ' {}', - url, - text - ) - return format_html( - '{}', - url, - text - ) + return format_html(' {}', url, text) + return format_html('{}', url, text) + custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore # ------ Converted fields ------ @@ -2025,11 +2016,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if obj.portfolio: url = reverse("admin:registrar_portfolio_changelist") + f"{obj.portfolio.id}" text = obj.converted_organization_name - return format_html( - '{}', - url, - text - ) + return format_html('{}', url, text) else: return obj.converted_organization_name @@ -2049,7 +2036,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def converted_state_territory(self, obj): return obj.converted_state_territory - # ------ Portfolio fields ------ # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: @@ -2746,7 +2732,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Further filter the queryset by the portfolio qs = qs.filter(portfolio=portfolio_id) return qs - + def get_search_results(self, request, queryset, search_term): # Call the parent's method to apply default search logic base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) @@ -3822,8 +3808,7 @@ class PortfolioAdmin(ListHeaderAdmin): # Even though this is empty, I will leave it as a stub for easy changes in the future # rather than strip it out of our logic. - analyst_readonly_fields = [ - ] + analyst_readonly_fields = [] # type: ignore def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio From 0959b72002b5c6e20a7074ed93d7f7f1903dd125 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 18 Dec 2024 23:26:13 -0700 Subject: [PATCH 126/135] fixed unit tests --- src/registrar/tests/test_admin_request.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index df0902719..439f4fab0 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1731,9 +1731,6 @@ class TestDomainRequestAdmin(MockEppLib): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", ] self.assertEqual(readonly_fields, expected_fields) @@ -1967,6 +1964,7 @@ class TestDomainRequestAdmin(MockEppLib): # Grab the current list of table filters readonly_fields = self.admin.get_list_filter(request) expected_fields = ( + DomainRequestAdmin.PortfolioFilter, DomainRequestAdmin.StatusListFilter, DomainRequestAdmin.GenericOrgFilter, DomainRequestAdmin.FederalTypeFilter, From 0777cf1334e48ff607ddd5061d7e5b52bda0165d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:10:26 -0700 Subject: [PATCH 127/135] Comment comet --- src/registrar/utility/csv_export.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 4de947594..3b3fe350c 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -757,7 +757,10 @@ class DomainExport(BaseExport): return row - # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFull and DomainDataFederal are + # pulling the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): FIELDS = { @@ -1088,7 +1091,10 @@ class DomainDataFull(DomainExport): Inherits from BaseExport -> DomainExport """ - # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFull is + # pulling the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): FIELDS = { @@ -1198,7 +1204,10 @@ class DomainDataFederal(DomainExport): Inherits from BaseExport -> DomainExport """ - # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFederal is + # pulling the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): FIELDS = { From e9d0a5425134d020e35aa6b6b8ac32eac70b7915 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:20:25 -0700 Subject: [PATCH 128/135] Update csv_export.py --- src/registrar/utility/csv_export.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 3b3fe350c..40d84e251 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -759,7 +759,8 @@ class DomainExport(BaseExport): # NOTE - this override is temporary. # We are running into a problem where DomainDataFull and DomainDataFederal are - # pulling the portfolio name, rather than the suborganization name. + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): @@ -1093,7 +1094,8 @@ class DomainDataFull(DomainExport): # NOTE - this override is temporary. # We are running into a problem where DomainDataFull is - # pulling the portfolio name, rather than the suborganization name. + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): @@ -1206,7 +1208,8 @@ class DomainDataFederal(DomainExport): # NOTE - this override is temporary. # We are running into a problem where DomainDataFederal is - # pulling the portfolio name, rather than the suborganization name. + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. # This can be removed after that gets fixed. @classmethod def get_fields(cls, model): From 5f5ca0b780d50a67ce6d76f84f9bdd01c162dcd6 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:25:04 -0700 Subject: [PATCH 129/135] Update csv_export.py --- src/registrar/utility/csv_export.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 40d84e251..66809777b 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1097,6 +1097,12 @@ class DomainDataFull(DomainExport): # pulling the wrong data. # For example, the portfolio name, rather than the suborganization name. # This can be removed after that gets fixed. + # The following fields are changed from DomainExport: + # converted_organization_name => organization_name + # converted_city => city + # converted_state_territory => state_territory + # converted_so_name => so_name + # converted_so_email => senior_official__email @classmethod def get_fields(cls, model): FIELDS = { @@ -1207,10 +1213,16 @@ class DomainDataFederal(DomainExport): """ # NOTE - this override is temporary. - # We are running into a problem where DomainDataFederal is + # We are running into a problem where DomainDataFull is # pulling the wrong data. # For example, the portfolio name, rather than the suborganization name. # This can be removed after that gets fixed. + # The following fields are changed from DomainExport: + # converted_organization_name => organization_name + # converted_city => city + # converted_state_territory => state_territory + # converted_so_name => so_name + # converted_so_email => senior_official__email @classmethod def get_fields(cls, model): FIELDS = { From d9c09bfda81bf2f490185d6590b8a716005af7ac Mon Sep 17 00:00:00 2001 From: lizpearl Date: Fri, 20 Dec 2024 10:47:30 -0600 Subject: [PATCH 130/135] Add new users --- src/registrar/fixtures/fixtures_users.py | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/registrar/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index a8cdb5b9a..e40998231 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -151,6 +151,27 @@ class UserFixture: "email": "skey@truss.works", "title": "Designer", }, + { + "username": "f20b7a53-f40d-48f8-8c12-f42f35eede92", + "first_name": "Kimberly", + "last_name": "Aralar", + "email": "kimberly.aralar@gsa.gov", + "title": "Designer", + }, + { + "username": "4aa78480-6272-42f9-ac29-a034ebdd9231", + "first_name": "Kaitlin", + "last_name": "Abbitt", + "email": "kaitlin.abbitt@cisa.dhs.gov", + "title": "Product Manager", + }, + { + "username": "5e54fd98-6c11-4cb3-82b6-93ed8be50a61", + "first_name": "Gina", + "last_name": "Summers", + "email": "gina.summers@ecstech.com", + "title": "Scrum Master", + }, ] STAFF = [ @@ -257,6 +278,18 @@ class UserFixture: "last_name": "Key-Analyst", "email": "skey+1@truss.works", }, + { + "username": "cf2b32fe-280d-4bc0-96c2-99eec09ba4da", + "first_name": "Kimberly-Analyst", + "last_name": "Aralar-Analyst", + "email": "kimberly.aralar+1@gsa.gov", + }, + { + "username": "80db923e-ac64-4128-9b6f-e54b2174a09b", + "first_name": "Kaitlin-Analyst", + "last_name": "Abbitt-Analyst", + "email": "kaitlin.abbitt@gwe.cisa.dhs.gov", + }, ] # Additional emails to add to the AllowedEmail whitelist. From fd44a7dd4bb302150d4f20c1d6e1182b2a2e8e9c Mon Sep 17 00:00:00 2001 From: CuriousX Date: Fri, 20 Dec 2024 16:45:01 -0700 Subject: [PATCH 131/135] Update src/registrar/admin.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/admin.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 74401ecc9..c1e08beb9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1980,10 +1980,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) def queryset(self, request, queryset): - if self.value() == "1": - return queryset.filter(Q(portfolio__isnull=False)) - if self.value() == "0": - return queryset.filter(Q(portfolio__isnull=True)) + filter_for_portfolio = self.value() == "1" + return queryset.filter(portfolio__isnull=filter_for_portfolio) # ------ Custom fields ------ def custom_election_board(self, obj): From 3e0a5a87e0939ab69d59d92c964476149756cfb9 Mon Sep 17 00:00:00 2001 From: CuriousX Date: Fri, 20 Dec 2024 16:45:09 -0700 Subject: [PATCH 132/135] Update src/registrar/admin.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c1e08beb9..649bcfa32 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2012,7 +2012,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def converted_organization_name(self, obj): # Example: Show different icons based on `status` if obj.portfolio: - url = reverse("admin:registrar_portfolio_changelist") + f"{obj.portfolio.id}" + url = reverse("admin:registrar_portfolio_change", args=[obj.portfolio.id]) text = obj.converted_organization_name return format_html('{}', url, text) else: From 2c4f7d06b7a3ad7a25f8f228244a4bd3ab6f6a68 Mon Sep 17 00:00:00 2001 From: CuriousX Date: Fri, 20 Dec 2024 16:45:15 -0700 Subject: [PATCH 133/135] Update src/registrar/admin.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 649bcfa32..4456c9cdc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1993,7 +1993,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): @admin.display(description=_("Requested Domain")) def custom_requested_domain(self, obj): # Example: Show different icons based on `status` - url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}" + url = reverse("admin:registrar_domainrequest_changelist", args=[obj.id]) text = obj.requested_domain if obj.portfolio: return format_html(' {}', url, text) From de21ff45d7169cf18e1ecd0700d7d77ebd19bf43 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 20 Dec 2024 17:36:38 -0700 Subject: [PATCH 134/135] Hide "Export as CSV" button on Domain Request table page --- .../templates/includes/domain_requests_table.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 8a919e795..56cdc2cec 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -54,11 +54,14 @@ {% if portfolio %}

      {% endif %} From eb4cd2719ee1606fe495d4df6d584828c0b2910f Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 23 Dec 2024 23:56:38 -0700 Subject: [PATCH 135/135] reverted a few suggested commits -- fixed issues --- src/registrar/admin.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4456c9cdc..165f12b42 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1980,8 +1980,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) def queryset(self, request, queryset): - filter_for_portfolio = self.value() == "1" - return queryset.filter(portfolio__isnull=filter_for_portfolio) + if self.value() == "1": + return queryset.filter(Q(portfolio__isnull=False)) + if self.value() == "0": + return queryset.filter(Q(portfolio__isnull=True)) # ------ Custom fields ------ def custom_election_board(self, obj): @@ -1993,7 +1995,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): @admin.display(description=_("Requested Domain")) def custom_requested_domain(self, obj): # Example: Show different icons based on `status` - url = reverse("admin:registrar_domainrequest_changelist", args=[obj.id]) + url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}" text = obj.requested_domain if obj.portfolio: return format_html(' {}', url, text)