From 7cf8b8a82ea0a0d3df4885eaae45b1c4d0082dfc Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 22 Nov 2024 10:51:13 -0600 Subject: [PATCH 001/112] 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 @@ def _remove_client_hold(self): # 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 @@ def revert_client_hold(self, ignoreEPP=False): @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 @@ def deletedInEpp(self): # 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 @@ def _get_or_create_contact(self, contact: PublicContact): ) 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 6891f5c8df34785be4452f81d0cf17a0f37bc754 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 26 Nov 2024 13:56:45 -0600 Subject: [PATCH 002/112] 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 @@ def nameservers(self, hosts: list[tuple[str, list]]): 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 @@ def _delete_contacts(self): 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 @@ def _get_or_create_contact(self, contact: PublicContact): 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 @@ def _delete_hosts_if_not_used(self, hostsToDelete: list[str]): """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 @@ def _delete_hosts_if_not_used(self, hostsToDelete: list[str]): 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 @@ def dummyInfoContactResultData( 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 @@ def mockSend(self, _request, cleaned): 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 @@ def mockUpdateHostCommands(self, _request, cleaned): 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 @@ def mockUpdateDomainCommands(self, _request, cleaned): 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 @@ def mockInfoDomainCommands(self, _request, cleaned): "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 @@ def setUp(self): 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 @@ def test_analyst_deletes_domain(self): 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 @@ def test_deletion_is_unsuccessful(self): self.mockedSendFunction.assert_has_calls( [ call( - commands.DeleteDomain(name="failDelete.gov"), + commands.DeleteHost(name=common.HostObjSet(hosts=['ns1.sharedhost.com'])), cleaned=True, ) ] From b5e4f8b40c0bf062f203d7e6dc05e305d3b05e8b Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 3 Dec 2024 15:04:25 -0600 Subject: [PATCH 003/112] 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 @@ def registered(cls, domain: str) -> bool: 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 @@ def contacts(self) -> dict[str, str]: { 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 @@ def _remove_client_hold(self): 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 @@ def _delete_domain(self): 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 @@ def deletedInEpp(self): 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 @@ def _get_or_create_contact(self, contact: PublicContact): 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 @@ def dummyInfoContactResultData( 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 @@ def mockSend(self, _request, cleaned): 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 @@ def mockInfoDomainCommands(self, _request, cleaned): # 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 @@ def mockCreateContactCommands(self, _request, cleaned): # 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 @@ def setUp(self): def tearDown(self): Host.objects.all().delete() + PublicContact.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -2643,7 +2644,7 @@ def test_deletion_is_unsuccessful(self): call( commands.DeleteHost(name=common.HostObjSet(hosts=['ns1.sharedhost.com'])), cleaned=True, - ) + ), ] ) # Domain itself should not be deleted @@ -2651,6 +2652,49 @@ def test_deletion_is_unsuccessful(self): # 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 27868a0aed8f1fe6fad6566788b281ca761dc163 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 4 Dec 2024 11:24:49 -0600 Subject: [PATCH 004/112] 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 @@ def get_help_text(cls, state) -> str: """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 @@ def _delete_subdomains(self): """ 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 @@ def mockSend(self, _request, cleaned): 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 @@ def mockUpdateHostCommands(self, _request, cleaned): 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 @@ def test_user_removes_too_many_nameservers(self): 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, - ), - 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.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 @@ def test_deletion_is_unsuccessful(self): 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 @@ def test_deletion_with_host_and_contacts(self): # 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 005/112] 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 @@ 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 @@ def setUp(self): 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 006/112] 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 @@ def nameservers(self, hosts: list[tuple[str, list]]): 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 @@ def _remove_client_hold(self): # 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 @@ def _delete_nonregistrant_contacts(self): # 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 @@ def _get_or_create_contact(self, contact: PublicContact): ) 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 @@ def _delete_contact(self, contact_name: str, registry_id: str): 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 @@ def _delete_hosts_if_not_used(self, hostsToDelete: list[str]): 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 @@ def mockUpdateHostCommands(self, _request, cleaned): 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 @@ def mockCreateContactCommands(self, _request, cleaned): # 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 @@ def test_deletion_is_unsuccessful(self): self.mockedSendFunction.assert_has_calls( [ call( - commands.DeleteHost(name='ns1.sharedhost.com'), + commands.DeleteHost(name="ns1.sharedhost.com"), cleaned=True, ), ] @@ -2664,7 +2664,7 @@ def test_deletion_with_host_and_contacts(self): 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 @@ def test_deletion_with_host_and_contacts(self): # 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 007/112] 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 @@ def __delete__(self, obj): """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 @@ def _delete_subdomains(self): 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 @@ def _delete_domain(self): 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 @@ def deletedInEpp(self): 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 @@ def mockUpdateHostCommands(self, _request, cleaned): 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 @@ def setUp(self): 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 @@ def test_deletion_is_unsuccessful(self): 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 @@ def test_deletion_with_host_and_contacts(self): 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 008/112] 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 @@ def test_user_removes_too_many_nameservers(self): 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, + ), + 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, ), - cleaned=True, - ), - call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), 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 @@ def test_analyst_deletes_domain(self): 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 009/112] 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 010/112] 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 011/112] 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 @@ def get_help_text(cls, state) -> str: """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 @@ def test_empty_domain_table(self): 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 012/112] 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 @@ def is_expired(self): 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 @@ def test_empty_domain_table(self): 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 013/112] 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 @@ def __delete__(self, obj): """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 @@ def _delete_subdomains(self): """ 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 @@ def test_deletion_is_unsuccessful(self): 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 @@ def test_member_export(self): "Email,Organization admin,Invited by,Joined date,Last active,Domain requests," "Member management,Domain management,Number of domains,Domains\n" # Content - "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" + "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' "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 @@ def get_model_annotation_dict(cls, request=None, **kwargs): .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 8b473d5e1846d80d4495ff5b375a2136c8b14f53 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 5 Dec 2024 13:55:32 -0600 Subject: [PATCH 014/112] 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 @@ def do_delete_domain(self, request, obj): 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 @@ def _delete_subdomains(self): 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 015/112] 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 @@ def do_delete_domain(self, request, obj): 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 @@ def _delete_subdomains(self): 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 ed4e30c9597a30ef956d6aedf389eb5090bf8d75 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 5 Dec 2024 15:32:27 -0500 Subject: [PATCH 016/112] infrastructure --- .../includes/member_domains_edit_table.html | 115 ++++++++++++++++++ .../includes/member_domains_table.html | 2 +- .../portfolio_member_domains_edit.html | 51 ++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/registrar/templates/includes/member_domains_edit_table.html create mode 100644 src/registrar/templates/portfolio_member_domains_edit.html diff --git a/src/registrar/templates/includes/member_domains_edit_table.html b/src/registrar/templates/includes/member_domains_edit_table.html new file mode 100644 index 000000000..ff87dd999 --- /dev/null +++ b/src/registrar/templates/includes/member_domains_edit_table.html @@ -0,0 +1,115 @@ +{% load static %} + +{% if member %} + +{% else %} + +{% endif %} + +{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} +{% url 'get_member_domains_edit_json' as url %} + +
+ +

+ Domains assigned to + {% if member %} + {{ member.email }} + {% else %} + {{ portfolio_invitation.email }} + {% endif %} +

+ +
+ + +
+ + + + + +
+ diff --git a/src/registrar/templates/includes/member_domains_table.html b/src/registrar/templates/includes/member_domains_table.html index 77d9b9891..c95196c51 100644 --- a/src/registrar/templates/includes/member_domains_table.html +++ b/src/registrar/templates/includes/member_domains_table.html @@ -37,7 +37,7 @@

@@ -95,7 +95,7 @@

Basic member permissions

Organization domain requests

{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.domain_request_permissions_member %} + {% input_with_errors form.domain_request_permission_member %} {% endwith %}
From 41c148b15e2518e29e197a9581b06a88c4234aa0 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 10 Dec 2024 17:59:38 -0500 Subject: [PATCH 044/112] init refactor of portfolio form js --- .../helpers-portfolio-dynamic-fields.js | 70 ++--- .../src/js/getgov-admin/portfolio-form.js | 295 +++++++++--------- 2 files changed, 187 insertions(+), 178 deletions(-) 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 64813df86..96c4290e8 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 @@ -4,41 +4,41 @@ import { hideElement, showElement } from './helpers-admin.js'; * Helper function that handles business logic for the suborganization field. * Can be used anywhere the suborganization dropdown exists */ -export function handleSuborganizationFields( - portfolioDropdownSelector="#id_portfolio", - suborgDropdownSelector="#id_sub_organization", - requestedSuborgFieldSelector=".field-requested_suborganization", - suborgCitySelector=".field-suborganization_city", - suborgStateTerritorySelector=".field-suborganization_state_territory" -) { - // These dropdown are select2 fields so they must be interacted with via jquery - const portfolioDropdown = django.jQuery(portfolioDropdownSelector) - const suborganizationDropdown = django.jQuery(suborgDropdownSelector) - const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); - const suborgCity = document.querySelector(suborgCitySelector); - const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); - if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { - console.error("Requested suborg fields not found."); - return; - } - - function toggleSuborganizationFields() { - if (portfolioDropdown.val() && !suborganizationDropdown.val()) { - if (requestedSuborgField) showElement(requestedSuborgField); - if (suborgCity) showElement(suborgCity); - if (suborgStateTerritory) showElement(suborgStateTerritory); - }else { - if (requestedSuborgField) hideElement(requestedSuborgField); - if (suborgCity) hideElement(suborgCity); - if (suborgStateTerritory) hideElement(suborgStateTerritory); - } - } - - // Run the function once on page startup, then attach an event listener - toggleSuborganizationFields(); - suborganizationDropdown.on("change", toggleSuborganizationFields); - portfolioDropdown.on("change", toggleSuborganizationFields); -} +// export function handleSuborganizationFields( +// portfolioDropdownSelector="#id_portfolio", +// suborgDropdownSelector="#id_sub_organization", +// requestedSuborgFieldSelector=".field-requested_suborganization", +// suborgCitySelector=".field-suborganization_city", +// suborgStateTerritorySelector=".field-suborganization_state_territory" +// ) { +// // These dropdown are select2 fields so they must be interacted with via jquery +// const portfolioDropdown = django.jQuery(portfolioDropdownSelector) +// const suborganizationDropdown = django.jQuery(suborgDropdownSelector) +// const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); +// const suborgCity = document.querySelector(suborgCitySelector); +// const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); +// if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { +// console.error("Requested suborg fields not found."); +// return; +// } + +// function toggleSuborganizationFields() { +// if (portfolioDropdown.val() && !suborganizationDropdown.val()) { +// if (requestedSuborgField) showElement(requestedSuborgField); +// if (suborgCity) showElement(suborgCity); +// if (suborgStateTerritory) showElement(suborgStateTerritory); +// }else { +// if (requestedSuborgField) hideElement(requestedSuborgField); +// if (suborgCity) hideElement(suborgCity); +// if (suborgStateTerritory) hideElement(suborgStateTerritory); +// } +// } + +// // Run the function once on page startup, then attach an event listener +// toggleSuborganizationFields(); +// suborganizationDropdown.on("change", toggleSuborganizationFields); +// portfolioDropdown.on("change", toggleSuborganizationFields); +// } /** diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index f001bf39b..ada162681 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -4,69 +4,87 @@ import { hideElement, showElement } from './helpers-admin.js'; * A function for dynamically changing some fields on the portfolio admin model * IMPORTANT NOTE: The logic in this function is paired handlePortfolioSelection and should be refactored once we solidify our requirements. */ -export function initDynamicPortfolioFields(){ +function handlePortfolioFields(){ - // the federal agency change listener fires on page load, which we don't want. - var isInitialPageLoad = true + let isPageLoading = true + let seniorOfficialContactList = document.querySelector(".field-senior_official .dja-address-contact-list"); + const federalAgency = document.querySelector(".field-federal_agency"); + // $ symbolically denotes that this is using jQuery + let $federalAgency = django.jQuery("#id_federal_agency"); + let organizationType = document.getElementById("id_organization_type"); + let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); + let organizationName = document.querySelector(".field-organization_name"); + let federalType = document.querySelector(".field-federal_type"); + let urbanization = document.querySelector(".field-urbanization"); + let stateTerritory = document.getElementById("id_state_territory"); + let $seniorOfficial = django.jQuery("#id_senior_official"); + let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); - // This is the additional information that exists beneath the SO element. - var contactList = document.querySelector(".field-senior_official .dja-address-contact-list"); - const federalAgencyContainer = document.querySelector(".field-federal_agency"); - document.addEventListener('DOMContentLoaded', function() { - - let isPortfolioPage = document.getElementById("portfolio_form"); - if (!isPortfolioPage) { - return; - } - - // $ symbolically denotes that this is using jQuery - let $federalAgency = django.jQuery("#id_federal_agency"); - let organizationType = document.getElementById("id_organization_type"); - let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); - - let organizationNameContainer = document.querySelector(".field-organization_name"); - let federalType = document.querySelector(".field-federal_type"); - - if ($federalAgency && (organizationType || readonlyOrganizationType)) { - // Attach the change event listener - $federalAgency.on("change", function() { - handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType); + function getFederalTypeFromAgency(agency) { + let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; + return fetch(`${federalPortfolioApi}?&agency_name=${agency}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return; + } + return data.federal_type + }) + .catch(error => { + console.error("Error fetching federal and portfolio types: ", error); + return null }); - } - - // Handle dynamically hiding the urbanization field - let urbanizationField = document.querySelector(".field-urbanization"); - let stateTerritory = document.getElementById("id_state_territory"); - if (urbanizationField && stateTerritory) { - // Execute this function once on load - handleStateTerritoryChange(stateTerritory, urbanizationField); + } - // Attach the change event listener for state/territory - stateTerritory.addEventListener("change", function() { - handleStateTerritoryChange(stateTerritory, urbanizationField); - }); - } + function getSeniorOfficialFromAgency(agency, seniorOfficialAddUrl) { + let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; + return fetch(`${seniorOfficialApi}?agency_name=${agency}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + if (statusCode === 404) { - // Handle hiding the organization name field when the organization_type is federal. - // Run this first one page load, then secondly on a change event. - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - organizationType.addEventListener("change", function() { - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - }); - }); + if ($seniorOfficial && $seniorOfficial.length > 0) { + $seniorOfficial.val("").trigger("change"); + } else { + // Show the "create one now" text if this field is none in readonly mode. + readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + } + console.warn("Record not found: " + data.error); + } else { + console.error("Error in AJAX call: " + data.error); + } + return null; + } else { + return data; + } + }) + .catch(error => { + console.error("Error fetching senior official: ", error) + return null; + }); + } + function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) { if (organizationType && organizationNameContainer) { let selectedValue = organizationType.value; if (selectedValue === "federal") { hideElement(organizationNameContainer); - showElement(federalAgencyContainer); + showElement(federalAgency); if (federalType) { showElement(federalType); } } else { showElement(organizationNameContainer); - hideElement(federalAgencyContainer); + hideElement(federalAgency); if (federalType) { hideElement(federalType); } @@ -75,106 +93,62 @@ export function initDynamicPortfolioFields(){ } function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) { - // Don't do anything on page load - if (isInitialPageLoad) { - isInitialPageLoad = false; - return; - } - - // Set the org type to federal if an agency is selected - let selectedText = federalAgency.find("option:selected").text(); - - // There isn't a federal senior official associated with null records - if (!selectedText) { - return; - } - - let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); - if (selectedText !== "Non-Federal Agency") { - if (organizationTypeValue !== "federal") { - if (organizationType){ - organizationType.value = "federal"; - }else { - readonlyOrganizationType.innerText = "Federal" - } - } - }else { - if (organizationTypeValue === "federal") { - if (organizationType){ - organizationType.value = ""; - }else { - readonlyOrganizationType.innerText = "-" - } - } - } + if (!isPageLoading) { - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - - // Determine if any changes are necessary to the display of portfolio type or federal type - // based on changes to the Federal Agency - let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; - fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); + let selectedFederalAgency = federalAgency.find("option:selected").text(); + // There isn't a federal senior official associated with null records + if (!selectedFederalAgency) { return; } - updateReadOnly(data.federal_type, '.field-federal_type'); - }) - .catch(error => console.error("Error fetching federal and portfolio types: ", error)); - // Hide the contactList initially. - // If we can update the contact information, it'll be shown again. - hideElement(contactList.parentElement); - - let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; - let $seniorOfficial = django.jQuery("#id_senior_official"); - let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); - let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; - fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - // Clear the field if the SO doesn't exist. - if (statusCode === 404) { - if ($seniorOfficial && $seniorOfficial.length > 0) { - $seniorOfficial.val("").trigger("change"); + let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); + if (selectedFederalAgency !== "Non-Federal Agency") { + if (organizationTypeValue !== "federal") { + if (organizationType){ + organizationType.value = "federal"; }else { - // Show the "create one now" text if this field is none in readonly mode. - readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + readonlyOrganizationType.innerText = "Federal" + } + } + } else { + if (organizationTypeValue === "federal") { + if (organizationType){ + organizationType.value = ""; + }else { + readonlyOrganizationType.innerText = "-" } - console.warn("Record not found: " + data.error); - }else { - console.error("Error in AJAX call: " + data.error); } - return; } - // Update the "contact details" blurb beneath senior official - updateContactInfo(data); - showElement(contactList.parentElement); + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + + // Determine if any changes are necessary to the display of portfolio type or federal type + // based on changes to the Federal Agency + getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type')); - // Get the associated senior official with this federal agency - let seniorOfficialId = data.id; - let seniorOfficialName = [data.first_name, data.last_name].join(" "); - if ($seniorOfficial && $seniorOfficial.length > 0) { - // If the senior official is a dropdown field, edit that - updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); - }else { - if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` - readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + hideElement(seniorOfficialContactList.parentElement); + let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; + getSeniorOfficialFromAgency(selectedFederalAgency, seniorOfficialAddUrl).then((data) => { + // Update the "contact details" blurb beneath senior official + updateContactInfo(data); + showElement(seniorOfficialContactList.parentElement); + // Get the associated senior official with this federal agency + let seniorOfficialId = data.id; + let seniorOfficialName = [data.first_name, data.last_name].join(" "); + if ($seniorOfficial && $seniorOfficial.length > 0) { + // If the senior official is a dropdown field, edit that + updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); + }else { + if (readonlySeniorOfficial) { + let seniorOfficialLink = `${seniorOfficialName}` + readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + } } - } - }) - .catch(error => console.error("Error fetching senior official: ", error)); + }); + + } else { + isPageLoading = false; + } } @@ -184,7 +158,6 @@ export function initDynamicPortfolioFields(){ dropdown.val("").trigger("change"); return; } - // Add the senior official to the dropdown. // This format supports select2 - if we decide to convert this field in the future. if (dropdown.find(`option[value='${seniorOfficialId}']`).length) { @@ -227,11 +200,11 @@ export function initDynamicPortfolioFields(){ } function updateContactInfo(data) { - if (!contactList) return; + if (!seniorOfficialContactList) return; - const titleSpan = contactList.querySelector(".contact_info_title"); - const emailSpan = contactList.querySelector(".contact_info_email"); - const phoneSpan = contactList.querySelector(".contact_info_phone"); + const titleSpan = seniorOfficialContactList.querySelector(".contact_info_title"); + const emailSpan = seniorOfficialContactList.querySelector(".contact_info_email"); + const phoneSpan = seniorOfficialContactList.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; @@ -239,10 +212,10 @@ export function initDynamicPortfolioFields(){ // Update the email field and the content for the clipboard if (emailSpan) { - let copyButton = contactList.querySelector(".admin-icon-group"); + let copyButton = seniorOfficialContactList.querySelector(".admin-icon-group"); emailSpan.textContent = data.email || "None"; if (data.email) { - const clipboardInput = contactList.querySelector(".admin-icon-group input"); + const clipboardInput = seniorOfficialContactList.querySelector(".admin-icon-group input"); if (clipboardInput) { clipboardInput.value = data.email; }; @@ -256,4 +229,40 @@ export function initDynamicPortfolioFields(){ phoneSpan.textContent = data.phone || "None"; }; } + + function initializePortfolioSettings() { + if (urbanization && stateTerritory) { + handleStateTerritoryChange(stateTerritory, urbanization); + } + handleOrganizationTypeChange(organizationType, organizationName, federalType); + } + + function setEventListeners() { + if ($federalAgency && (organizationType || readonlyOrganizationType)) { + $federalAgency.on("change", function() { + handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationName, federalType); + }); + } + if (urbanization && stateTerritory) { + stateTerritory.addEventListener("change", function() { + handleStateTerritoryChange(stateTerritory, urbanization); + }); + } + organizationType.addEventListener("change", function() { + handleOrganizationTypeChange(organizationType, organizationName, federalType); + }); + } + + // Run initial setup functions + initializePortfolioSettings(); + setEventListeners(); +} + +export function initPortfolioFields() { + document.addEventListener('DOMContentLoaded', function() { + let isPortfolioPage = document.getElementById("portfolio_form"); + if (isPortfolioPage) { + handlePortfolioFields(); + } + }); } From 1a9b6717584b189a7082f0522e405907ab8b0a4f Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 11 Dec 2024 10:11:05 -0600 Subject: [PATCH 045/112] consolidate delete domain function --- src/epplibwrapper/errors.py | 2 +- src/registrar/models/domain.py | 110 +++++-------- src/registrar/tests/test_admin_domain.py | 2 +- src/registrar/tests/test_models_domain.py | 188 ++++++++++++---------- 4 files changed, 142 insertions(+), 160 deletions(-) diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index 0f6ee2722..78272ff0a 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=None,**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 c768838d5..c1357c83c 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -269,10 +269,7 @@ def registry_contacts(self) -> dict[str, str]: { PublicContact.ContactTypeChoices.REGISTRANT: "jd1234", PublicContact.ContactTypeChoices.ADMINISTRATIVE: "sh8013",...} """ - if self._cache.get("contacts"): - return self._cache.get("contacts") - else: - return self._get_property("contacts") + raise NotImplementedError() @Cache def creation_date(self) -> date: @@ -1036,61 +1033,51 @@ def _remove_client_hold(self): # 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""" - 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.registry_contacts - logger.debug("Contacts to delete for %s inside _delete_contacts -> %s", self.name, contacts) - if contacts: - 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): - """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. - - raises: - 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 + logger.info("Deleting subdomains for %s", self.name) + # check if any subdomains are in use by another domain hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) + logger.debug("Checking if any subdomains are in use by another domain") for host in hosts: if host.domain != self: - logger.error("Host %s in use by another domain: %s", host.name, host.domain) + logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) raise RegistryError( code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, - note=host.domain, + note=f"Host {host.name} is in use by {host.domain}", ) - - nameservers = [n[0] for n in self.nameservers] - hostsToDelete, _ = self.createDeleteHostList(nameservers) - logger.debug("HostsToDelete from %s inside _delete_subdomains -> %s", self.name, hostsToDelete) - - 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 - may raises RegistryError, should be caught or handled correctly by caller""" + logger.debug("No subdomains are in use by another domain") + + nameservers = [host.name for host in hosts] + hosts = self.createDeleteHostList(hostsToDelete=nameservers) + response_code = self.addAndRemoveHostsFromDomain(hostsToAdd=None, hostsToDelete=hosts) + if response_code != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: + raise RegistryError(code=response_code) + + logger.debug("Deleting subordinate hosts for %s", self.name) + self._delete_hosts_if_not_used(nameservers) + + logger.debug("Deleting non-registrant contacts for %s", self.name) + contacts = PublicContact.objects.filter(domain=self) + for contact in contacts: + if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT: + logger.debug("removing contact %s from domain %s", contact.registry_id, self.name) + self._update_domain_with_contact(contact, rem=True) + logger.debug("deleting contact %s from registry", contact.registry_id) + request = commands.DeleteContact(contact.registry_id) + registry.send(request, cleaned=True) + + logger.info("Deleting domain %s", self.name) 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""" - if self.registrant_contact: - registrantID = self.registrant_contact.registry_id - request = commands.DeleteContact(id=registrantID) - registry.send(request, cleaned=True) + logger.debug("Deleting registrant contact for %s", self.name) + registrant_id = self.registrant_contact.registry_id + deleteRegistrant = commands.DeleteContact(id=registrant_id) + registry.send(deleteRegistrant, cleaned=True) def __str__(self) -> str: return self.name @@ -1487,7 +1474,7 @@ def revert_client_hold(self, ignoreEPP=False): 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, State.UNKNOWN], target=State.DELETED) + @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. @@ -1499,14 +1486,11 @@ def deletedInEpp(self): # as doing everything here would reduce reliablity. try: logger.info("deletedInEpp()-> inside _delete_domain") - self._delete_subdomains() - self._delete_nonregistrant_contacts() 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}. Additional context: {err.note}") + logger.error(f"Could not delete domain. Registry returned error: {err}. {err.note}") raise err except TransitionNotAllowed as err: logger.error("Could not delete domain. FSM failure: {err}") @@ -1705,24 +1689,6 @@ def _get_or_create_contact(self, contact: PublicContact): 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 - """ - logger.info("_delete_contact() -> Attempting to delete contact for %s from domain %s", contact_name, self.name) - try: - req = commands.DeleteContact(id=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 %s, error: %s", # noqa - contact_name, - error, - ) - raise error - def is_ipv6(self, ip: str): ip_addr = ipaddress.ip_address(ip) return ip_addr.version == 6 diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index aed4795a6..57961605d 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -228,7 +228,7 @@ def test_deletion_is_unsuccessful(self): """ Scenario: Domain deletion is unsuccessful When the domain is deleted and has shared subdomains - Then a user-friendly error message is returned for displaying on the web + 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) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index e5df19d82..70ef4cdde 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch, call import datetime from django.utils.timezone import make_aware +from api.tests.common import less_console_noise_decorator from registrar.models import Domain, Host, HostIP from unittest import skip @@ -2592,6 +2593,7 @@ def tearDown(self): Domain.objects.all().delete() super().tearDown() + @less_console_noise_decorator def test_analyst_deletes_domain(self): """ Scenario: Analyst permanently deletes a domain @@ -2601,29 +2603,29 @@ def test_analyst_deletes_domain(self): 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, {}) - + # 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, {}) + + # @less_console_noise_decorator def test_deletion_is_unsuccessful(self): """ Scenario: Domain deletion is unsuccessful @@ -2631,23 +2633,23 @@ def test_deletion_is_unsuccessful(self): 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="sharingiscaring.gov", state=Domain.State.ON_HOLD) - # Put the domain in client hold - domain.place_client_hold() - # Delete it - with self.assertRaises(RegistryError) as err: - domain.deletedInEpp() - domain.save() + # Desired domain + 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 + 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 - self.assertNotEqual(domain, None) - # State should not have changed - self.assertEqual(domain.state, Domain.State.ON_HOLD) + 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 + self.assertNotEqual(domain, None) + # State should not have changed + self.assertEqual(domain.state, Domain.State.ON_HOLD) + # @less_console_noise_decorator def test_deletion_with_host_and_contacts(self): """ Scenario: Domain with related Host and Contacts is Deleted @@ -2658,44 +2660,59 @@ def test_deletion_with_host_and_contacts(self): 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="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, + # 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.UpdateDomain( + name="freeman.gov", + add=[ + common.Status( + state=Domain.Status.CLIENT_HOLD, + description="", + lang="en", + ) + ], + rem=[], + nsset=None, + keyset=None, + registrant=None, + auth_info=None, ), - ], - 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) + 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), + ], + 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) + # @less_console_noise_decorator def test_deletion_ready_fsm_failure(self): """ Scenario: Domain deletion is unsuccessful due to FSM rules @@ -2707,15 +2724,14 @@ def test_deletion_ready_fsm_failure(self): The deleted date is still null. """ - with less_console_noise(): - self.assertEqual(self.domain.state, Domain.State.READY) - with self.assertRaises(TransitionNotAllowed) as err: - self.domain.deletedInEpp() - self.domain.save() - self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION) - # Domain should not be deleted - self.assertNotEqual(self.domain, None) - # Domain should have the right state - self.assertEqual(self.domain.state, Domain.State.READY) - # deleted should be null - self.assertEqual(self.domain.deleted, None) + self.assertEqual(self.domain.state, Domain.State.READY) + with self.assertRaises(TransitionNotAllowed) as err: + self.domain.deletedInEpp() + self.domain.save() + self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION) + # Domain should not be deleted + self.assertNotEqual(self.domain, None) + # Domain should have the right state + self.assertEqual(self.domain.state, Domain.State.READY) + # deleted should be null + self.assertEqual(self.domain.deleted, None) From 7f0dc4bfca0bbc98f157d8904c80e930bcefac7f Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 11 Dec 2024 11:29:20 -0500 Subject: [PATCH 046/112] updated handling of error from lookup api, plus some variable naming --- .../src/js/getgov-admin/portfolio-form.js | 81 ++++++++++--------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index ada162681..5c34c8fce 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -7,18 +7,21 @@ import { hideElement, showElement } from './helpers-admin.js'; function handlePortfolioFields(){ let isPageLoading = true - let seniorOfficialContactList = document.querySelector(".field-senior_official .dja-address-contact-list"); - const federalAgency = document.querySelector(".field-federal_agency"); // $ symbolically denotes that this is using jQuery - let $federalAgency = django.jQuery("#id_federal_agency"); + const $seniorOfficial = django.jQuery("#id_senior_official"); + const seniorOfficialField = document.querySelector(".field-senior_official"); + const seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list"); + const seniorOfficialReadonly = seniorOfficialField.querySelector(".readonly"); + const $federalAgency = django.jQuery("#id_federal_agency"); + const federalAgencyField = document.querySelector(".field-federal_agency"); let organizationType = document.getElementById("id_organization_type"); let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); let organizationName = document.querySelector(".field-organization_name"); let federalType = document.querySelector(".field-federal_type"); let urbanization = document.querySelector(".field-urbanization"); let stateTerritory = document.getElementById("id_state_territory"); - let $seniorOfficial = django.jQuery("#id_senior_official"); - let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); + + const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; function getFederalTypeFromAgency(agency) { let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; @@ -40,8 +43,9 @@ function handlePortfolioFields(){ }); } - function getSeniorOfficialFromAgency(agency, seniorOfficialAddUrl) { - let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; + function getSeniorOfficialFromAgency(agency) { + const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; + return fetch(`${seniorOfficialApi}?agency_name=${agency}`) .then(response => { const statusCode = response.status; @@ -49,27 +53,15 @@ function handlePortfolioFields(){ }) .then(({ statusCode, data }) => { if (data.error) { - if (statusCode === 404) { - - if ($seniorOfficial && $seniorOfficial.length > 0) { - $seniorOfficial.val("").trigger("change"); - } else { - // Show the "create one now" text if this field is none in readonly mode. - readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; - } - - console.warn("Record not found: " + data.error); - } else { - console.error("Error in AJAX call: " + data.error); - } - return null; + // Throw an error with status code and message + throw { statusCode, message: data.error }; } else { return data; } }) .catch(error => { - console.error("Error fetching senior official: ", error) - return null; + console.error("Error fetching senior official: ", error); + throw error; // Re-throw for external handling }); } @@ -78,13 +70,13 @@ function handlePortfolioFields(){ let selectedValue = organizationType.value; if (selectedValue === "federal") { hideElement(organizationNameContainer); - showElement(federalAgency); + showElement(federalAgencyField); if (federalType) { showElement(federalType); } } else { showElement(organizationNameContainer); - hideElement(federalAgency); + hideElement(federalAgencyField); if (federalType) { hideElement(federalType); } @@ -126,12 +118,12 @@ function handlePortfolioFields(){ // based on changes to the Federal Agency getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type')); - hideElement(seniorOfficialContactList.parentElement); - let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; - getSeniorOfficialFromAgency(selectedFederalAgency, seniorOfficialAddUrl).then((data) => { + hideElement(seniorOfficialAddress.parentElement); + + getSeniorOfficialFromAgency(selectedFederalAgency).then((data) => { // Update the "contact details" blurb beneath senior official updateContactInfo(data); - showElement(seniorOfficialContactList.parentElement); + showElement(seniorOfficialAddress.parentElement); // Get the associated senior official with this federal agency let seniorOfficialId = data.id; let seniorOfficialName = [data.first_name, data.last_name].join(" "); @@ -139,11 +131,24 @@ function handlePortfolioFields(){ // If the senior official is a dropdown field, edit that updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); }else { - if (readonlySeniorOfficial) { + if (seniorOfficialReadonly) { let seniorOfficialLink = `${seniorOfficialName}` - readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + seniorOfficialReadonly.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; } } + }) + .catch(error => { + if (error.statusCode === 404) { + // Handle "not found" senior official + if ($seniorOfficial && $seniorOfficial.length > 0) { + $seniorOfficial.val("").trigger("change"); + } else { + seniorOfficialReadonly.innerHTML = `No senior official found. Create one now.`; + } + } else { + // Handle other errors + console.error("An error occurred:", error.message); + } }); } else { @@ -200,11 +205,11 @@ function handlePortfolioFields(){ } function updateContactInfo(data) { - if (!seniorOfficialContactList) return; + if (!seniorOfficialAddress) return; - const titleSpan = seniorOfficialContactList.querySelector(".contact_info_title"); - const emailSpan = seniorOfficialContactList.querySelector(".contact_info_email"); - const phoneSpan = seniorOfficialContactList.querySelector(".contact_info_phone"); + const titleSpan = seniorOfficialAddress.querySelector(".contact_info_title"); + const emailSpan = seniorOfficialAddress.querySelector(".contact_info_email"); + const phoneSpan = seniorOfficialAddress.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; @@ -212,10 +217,10 @@ function handlePortfolioFields(){ // Update the email field and the content for the clipboard if (emailSpan) { - let copyButton = seniorOfficialContactList.querySelector(".admin-icon-group"); + let copyButton = seniorOfficialAddress.querySelector(".admin-icon-group"); emailSpan.textContent = data.email || "None"; if (data.email) { - const clipboardInput = seniorOfficialContactList.querySelector(".admin-icon-group input"); + const clipboardInput = seniorOfficialAddress.querySelector(".admin-icon-group input"); if (clipboardInput) { clipboardInput.value = data.email; }; @@ -258,7 +263,7 @@ function handlePortfolioFields(){ setEventListeners(); } -export function initPortfolioFields() { +export function initDynamicPortfolioFields() { document.addEventListener('DOMContentLoaded', function() { let isPortfolioPage = document.getElementById("portfolio_form"); if (isPortfolioPage) { From b85bfd7244f9ac791dc8557ba421e404027d0305 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 11 Dec 2024 12:02:44 -0500 Subject: [PATCH 047/112] wip variable name changes, and removing params from methods, and inheriting from global consts --- .../src/js/getgov-admin/portfolio-form.js | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 5c34c8fce..b1c85a527 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -14,13 +14,14 @@ function handlePortfolioFields(){ const seniorOfficialReadonly = seniorOfficialField.querySelector(".readonly"); const $federalAgency = django.jQuery("#id_federal_agency"); const federalAgencyField = document.querySelector(".field-federal_agency"); - let organizationType = document.getElementById("id_organization_type"); - let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); - let organizationName = document.querySelector(".field-organization_name"); - let federalType = document.querySelector(".field-federal_type"); - let urbanization = document.querySelector(".field-urbanization"); - let stateTerritory = document.getElementById("id_state_territory"); - + const organizationTypeField = document.querySelector(".field-organization_type"); + const organizationTypeReadonly = organizationTypeField.querySelector(".readonly"); + const organizationTypeDropdown = document.getElementById("id_organization_type"); + const organizationNameField = document.querySelector(".field-organization_name"); + const federalTypeField = document.querySelector(".field-federal_type"); + const urbanizationField = document.querySelector(".field-urbanization"); + const stateTerritoryDropdown = document.getElementById("id_state_territory"); + // consts for different urls const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; function getFederalTypeFromAgency(agency) { @@ -65,26 +66,26 @@ function handlePortfolioFields(){ }); } - function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) { - if (organizationType && organizationNameContainer) { - let selectedValue = organizationType.value; + function handleOrganizationTypeChange() { + if (organizationTypeDropdown && organizationNameField) { + let selectedValue = organizationTypeDropdown.value; if (selectedValue === "federal") { - hideElement(organizationNameContainer); + hideElement(organizationNameField); showElement(federalAgencyField); - if (federalType) { - showElement(federalType); + if (federalTypeField) { + showElement(federalTypeField); } } else { - showElement(organizationNameContainer); + showElement(organizationNameField); hideElement(federalAgencyField); - if (federalType) { - hideElement(federalType); + if (federalTypeField) { + hideElement(federalTypeField); } } } } - function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) { + function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType) { if (!isPageLoading) { let selectedFederalAgency = federalAgency.find("option:selected").text(); @@ -112,7 +113,7 @@ function handlePortfolioFields(){ } } - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + handleOrganizationTypeChange(); // Determine if any changes are necessary to the display of portfolio type or federal type // based on changes to the Federal Agency @@ -175,8 +176,8 @@ function handlePortfolioFields(){ } } - function handleStateTerritoryChange(stateTerritory, urbanizationField) { - let selectedValue = stateTerritory.value; + function handleStateTerritoryChange() { + let selectedValue = stateTerritoryDropdown.value; if (selectedValue === "PR") { showElement(urbanizationField) } else { @@ -236,25 +237,25 @@ function handlePortfolioFields(){ } function initializePortfolioSettings() { - if (urbanization && stateTerritory) { - handleStateTerritoryChange(stateTerritory, urbanization); + if (urbanizationField && stateTerritoryDropdown) { + handleStateTerritoryChange(); } - handleOrganizationTypeChange(organizationType, organizationName, federalType); + handleOrganizationTypeChange(); } function setEventListeners() { - if ($federalAgency && (organizationType || readonlyOrganizationType)) { + if ($federalAgency && (organizationTypeDropdown || organizationTypeReadonly)) { $federalAgency.on("change", function() { - handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationName, federalType); + handleFederalAgencyChange($federalAgency, organizationTypeDropdown, organizationTypeReadonly); }); } - if (urbanization && stateTerritory) { - stateTerritory.addEventListener("change", function() { - handleStateTerritoryChange(stateTerritory, urbanization); + if (urbanizationField && stateTerritoryDropdown) { + stateTerritoryDropdown.addEventListener("change", function() { + handleStateTerritoryChange(); }); } - organizationType.addEventListener("change", function() { - handleOrganizationTypeChange(organizationType, organizationName, federalType); + organizationTypeDropdown.addEventListener("change", function() { + handleOrganizationTypeChange(); }); } From ee142a40964df37f460dce0503c93efc4c6d6f1a Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 11 Dec 2024 12:36:15 -0500 Subject: [PATCH 048/112] removed some more params --- .../assets/src/js/getgov-admin/portfolio-form.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index b1c85a527..92f57712b 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -85,7 +85,7 @@ function handlePortfolioFields(){ } } - function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType) { + function handleFederalAgencyChange() { if (!isPageLoading) { let selectedFederalAgency = federalAgency.find("option:selected").text(); @@ -99,7 +99,7 @@ function handlePortfolioFields(){ if (organizationTypeValue !== "federal") { if (organizationType){ organizationType.value = "federal"; - }else { + } else { readonlyOrganizationType.innerText = "Federal" } } @@ -107,7 +107,7 @@ function handlePortfolioFields(){ if (organizationTypeValue === "federal") { if (organizationType){ organizationType.value = ""; - }else { + } else { readonlyOrganizationType.innerText = "-" } } @@ -131,7 +131,7 @@ function handlePortfolioFields(){ if ($seniorOfficial && $seniorOfficial.length > 0) { // If the senior official is a dropdown field, edit that updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); - }else { + } else { if (seniorOfficialReadonly) { let seniorOfficialLink = `${seniorOfficialName}` seniorOfficialReadonly.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; @@ -246,7 +246,7 @@ function handlePortfolioFields(){ function setEventListeners() { if ($federalAgency && (organizationTypeDropdown || organizationTypeReadonly)) { $federalAgency.on("change", function() { - handleFederalAgencyChange($federalAgency, organizationTypeDropdown, organizationTypeReadonly); + handleFederalAgencyChange(); }); } if (urbanizationField && stateTerritoryDropdown) { From 058d42f0140975b8915d5b6f9efe59d6c99cc97c Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 11 Dec 2024 12:51:02 -0500 Subject: [PATCH 049/112] fix misnamed references --- .../src/js/getgov-admin/portfolio-form.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 92f57712b..9422c95f6 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -12,7 +12,7 @@ function handlePortfolioFields(){ const seniorOfficialField = document.querySelector(".field-senior_official"); const seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list"); const seniorOfficialReadonly = seniorOfficialField.querySelector(".readonly"); - const $federalAgency = django.jQuery("#id_federal_agency"); + const $federalAgencyDropdown = django.jQuery("#id_federal_agency"); const federalAgencyField = document.querySelector(".field-federal_agency"); const organizationTypeField = document.querySelector(".field-organization_type"); const organizationTypeReadonly = organizationTypeField.querySelector(".readonly"); @@ -88,39 +88,39 @@ function handlePortfolioFields(){ function handleFederalAgencyChange() { if (!isPageLoading) { - let selectedFederalAgency = federalAgency.find("option:selected").text(); - // There isn't a federal senior official associated with null records + let selectedFederalAgency = $federalAgencyDropdown.find("option:selected").text(); if (!selectedFederalAgency) { return; } - let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); + // 1. Handle organization type + let organizationTypeValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText.toLowerCase(); if (selectedFederalAgency !== "Non-Federal Agency") { if (organizationTypeValue !== "federal") { - if (organizationType){ - organizationType.value = "federal"; + if (organizationTypeDropdown){ + organizationTypeDropdown.value = "federal"; } else { - readonlyOrganizationType.innerText = "Federal" + organizationTypeReadonly.innerText = "Federal" } } } else { if (organizationTypeValue === "federal") { - if (organizationType){ - organizationType.value = ""; + if (organizationTypeDropdown){ + organizationTypeDropdown.value = ""; } else { - readonlyOrganizationType.innerText = "-" + organizationTypeReadonly.innerText = "-" } } } + // 2. Handle organization type change side effects handleOrganizationTypeChange(); - // Determine if any changes are necessary to the display of portfolio type or federal type - // based on changes to the Federal Agency + // 3. Handle federal type getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type')); + // 4. Handle senior official hideElement(seniorOfficialAddress.parentElement); - getSeniorOfficialFromAgency(selectedFederalAgency).then((data) => { // Update the "contact details" blurb beneath senior official updateContactInfo(data); @@ -244,8 +244,8 @@ function handlePortfolioFields(){ } function setEventListeners() { - if ($federalAgency && (organizationTypeDropdown || organizationTypeReadonly)) { - $federalAgency.on("change", function() { + if ($federalAgencyDropdown && (organizationTypeDropdown || organizationTypeReadonly)) { + $federalAgencyDropdown.on("change", function() { handleFederalAgencyChange(); }); } From 591cc0ee7546eddcb6ee3ac76b12f6ad506bdd09 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 11 Dec 2024 13:04:18 -0500 Subject: [PATCH 050/112] clean up JS and add comments --- .../helpers-portfolio-dynamic-fields.js | 43 +------------- .../src/js/getgov-admin/portfolio-form.js | 56 ++++++++++++++++--- 2 files changed, 48 insertions(+), 51 deletions(-) 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 96c4290e8..0e5946c23 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 @@ -1,52 +1,11 @@ import { hideElement, showElement } from './helpers-admin.js'; -/** - * Helper function that handles business logic for the suborganization field. - * Can be used anywhere the suborganization dropdown exists -*/ -// export function handleSuborganizationFields( -// portfolioDropdownSelector="#id_portfolio", -// suborgDropdownSelector="#id_sub_organization", -// requestedSuborgFieldSelector=".field-requested_suborganization", -// suborgCitySelector=".field-suborganization_city", -// suborgStateTerritorySelector=".field-suborganization_state_territory" -// ) { -// // These dropdown are select2 fields so they must be interacted with via jquery -// const portfolioDropdown = django.jQuery(portfolioDropdownSelector) -// const suborganizationDropdown = django.jQuery(suborgDropdownSelector) -// const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); -// const suborgCity = document.querySelector(suborgCitySelector); -// const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); -// if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { -// console.error("Requested suborg fields not found."); -// return; -// } - -// function toggleSuborganizationFields() { -// if (portfolioDropdown.val() && !suborganizationDropdown.val()) { -// if (requestedSuborgField) showElement(requestedSuborgField); -// if (suborgCity) showElement(suborgCity); -// if (suborgStateTerritory) showElement(suborgStateTerritory); -// }else { -// if (requestedSuborgField) hideElement(requestedSuborgField); -// if (suborgCity) hideElement(suborgCity); -// if (suborgStateTerritory) hideElement(suborgStateTerritory); -// } -// } - -// // Run the function once on page startup, then attach an event listener -// toggleSuborganizationFields(); -// suborganizationDropdown.on("change", toggleSuborganizationFields); -// portfolioDropdown.on("change", toggleSuborganizationFields); -// } - - /** * * This function handles the portfolio selection as well as display of * portfolio-related fields in the DomainRequest Form. * - * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields + * IMPORTANT NOTE: The business logic in this method is based on dynamicPortfolioFields */ export function handlePortfolioSelection( portfolioDropdownSelector="#id_portfolio", diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 9422c95f6..1d73e2bd5 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -2,7 +2,7 @@ import { hideElement, showElement } from './helpers-admin.js'; /** * A function for dynamically changing some fields on the portfolio admin model - * IMPORTANT NOTE: The logic in this function is paired handlePortfolioSelection and should be refactored once we solidify our requirements. + * IMPORTANT NOTE: The business logic in this function is related to handlePortfolioSelection */ function handlePortfolioFields(){ @@ -21,9 +21,15 @@ function handlePortfolioFields(){ const federalTypeField = document.querySelector(".field-federal_type"); const urbanizationField = document.querySelector(".field-urbanization"); const stateTerritoryDropdown = document.getElementById("id_state_territory"); - // consts for different urls const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; + /** + * Fetches federal type data based on a selected agency using an AJAX call. + * + * @param {string} agency + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + */ function getFederalTypeFromAgency(agency) { let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; return fetch(`${federalPortfolioApi}?&agency_name=${agency}`) @@ -44,6 +50,13 @@ function handlePortfolioFields(){ }); } + /** + * Fetches senior official contact data based on a selected agency using an AJAX call. + * + * @param {string} agency + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + */ function getSeniorOfficialFromAgency(agency) { const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; @@ -66,6 +79,12 @@ function handlePortfolioFields(){ }); } + /** + * Handles the side effects of change on the organization type field + * + * 1. If selection is federal, hide org name, show federal agency, show federal type if applicable + * 2. else show org name, hide federal agency, hide federal type if applicable + */ function handleOrganizationTypeChange() { if (organizationTypeDropdown && organizationNameField) { let selectedValue = organizationTypeDropdown.value; @@ -85,6 +104,14 @@ function handlePortfolioFields(){ } } + /** + * Handles the side effects of change on the federal agency field + * + * 1. handle org type dropdown or readonly + * 2. call handleOrganizationTypeChange + * 3. call getFederalTypeFromAgency and update federal type + * 4. call getSeniorOfficialFromAgency and update the SO fieldset + */ function handleFederalAgencyChange() { if (!isPageLoading) { @@ -123,7 +150,7 @@ function handlePortfolioFields(){ hideElement(seniorOfficialAddress.parentElement); getSeniorOfficialFromAgency(selectedFederalAgency).then((data) => { // Update the "contact details" blurb beneath senior official - updateContactInfo(data); + updateSeniorOfficialContactInfo(data); showElement(seniorOfficialAddress.parentElement); // Get the associated senior official with this federal agency let seniorOfficialId = data.id; @@ -158,6 +185,9 @@ function handlePortfolioFields(){ } + /** + * Helper for updating federal type field + */ function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) { if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ // Clear the field if the SO doesn't exist @@ -176,6 +206,9 @@ function handlePortfolioFields(){ } } + /** + * Handle urbanization + */ function handleStateTerritoryChange() { let selectedValue = stateTerritoryDropdown.value; if (selectedValue === "PR") { @@ -186,11 +219,7 @@ function handlePortfolioFields(){ } /** - * Utility that selects a div from the DOM using selectorString, - * and updates a div within that div which has class of 'readonly' - * so that the text of the div is updated to updateText - * @param {*} updateText - * @param {*} selectorString + * Helper for updating senior official dropdown */ function updateReadOnly(updateText, selectorString) { // find the div by selectorString @@ -205,7 +234,10 @@ function handlePortfolioFields(){ } } - function updateContactInfo(data) { + /** + * Helper for updating senior official contact info + */ + function updateSeniorOfficialContactInfo(data) { if (!seniorOfficialAddress) return; const titleSpan = seniorOfficialAddress.querySelector(".contact_info_title"); @@ -236,6 +268,9 @@ function handlePortfolioFields(){ }; } + /** + * Initializes necessary data and display configurations for the portfolio fields. + */ function initializePortfolioSettings() { if (urbanizationField && stateTerritoryDropdown) { handleStateTerritoryChange(); @@ -243,6 +278,9 @@ function handlePortfolioFields(){ handleOrganizationTypeChange(); } + /** + * Sets event listeners for key UI elements. + */ function setEventListeners() { if ($federalAgencyDropdown && (organizationTypeDropdown || organizationTypeReadonly)) { $federalAgencyDropdown.on("change", function() { From f599d4fb67d8edf70bf410d1139d8b83df412eb5 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 11 Dec 2024 13:09:17 -0500 Subject: [PATCH 051/112] lint --- src/registrar/admin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 76c935f96..f7fd4991c 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1603,7 +1603,6 @@ def portfolio_urbanization(self, obj): portfolio_urbanization.short_description = "Urbanization" # type: ignore - # Filters list_filter = [GenericOrgFilter] @@ -1635,7 +1634,7 @@ def portfolio_urbanization(self, obj): ), (".gov domain", {"fields": ["domain"]}), ( - "Contacts", + "Contacts", { "fields": [ "senior_official", @@ -1746,7 +1745,7 @@ def portfolio_urbanization(self, obj): "portfolio_zipcode", "portfolio_urbanization", "other_contacts", - "is_election_board" + "is_election_board", ) # Read only that we'll leverage for CISA Analysts From bc6c756aab85f6efcf0de2afaa08091d093fccd1 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 11 Dec 2024 13:36:54 -0500 Subject: [PATCH 052/112] Fix unit tests --- src/registrar/tests/test_admin.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 8307163c6..a259e5bef 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -853,9 +853,9 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - # We expect 3 in the form + 2 from the js module copy-to-clipboard.js + # We expect 4 in the form + 2 from the js module copy-to-clipboard.js # that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder - self.assertContains(response, "copy-to-clipboard", count=5) + self.assertContains(response, "copy-to-clipboard", count=6) # cleanup this test domain_info.delete() @@ -871,6 +871,17 @@ def test_readonly_fields_for_analyst(self): readonly_fields = self.admin.get_readonly_fields(request) expected_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", "federal_agency", From e0eb70abe2e89dac7bdbe69dc389bb8fbaad1374 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 11 Dec 2024 14:12:42 -0500 Subject: [PATCH 053/112] working _delete_domain --- src/registrar/models/domain.py | 58 ++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index c1357c83c..37cc0d2b7 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1038,28 +1038,46 @@ def _delete_domain(self): """This domain should be deleted from the registry may raises RegistryError, should be caught or handled correctly by caller""" - logger.info("Deleting subdomains for %s", self.name) - # check if any subdomains are in use by another domain - hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) - logger.debug("Checking if any subdomains are in use by another domain") - for host in hosts: - if host.domain != self: - logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) - raise RegistryError( - code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, - note=f"Host {host.name} is in use by {host.domain}", - ) - logger.debug("No subdomains are in use by another domain") - - nameservers = [host.name for host in hosts] - hosts = self.createDeleteHostList(hostsToDelete=nameservers) - response_code = self.addAndRemoveHostsFromDomain(hostsToAdd=None, hostsToDelete=hosts) - if response_code != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: - raise RegistryError(code=response_code) + # logger.info("Deleting subdomains for %s", self.name) + # # check if any subdomains are in use by another domain + # hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) + # logger.debug("Checking if any subdomains are in use by another domain") + # for host in hosts: + # if host.domain != self: + # logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) + # raise RegistryError( + # code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, + # note=f"Host {host.name} is in use by {host.domain}", + # ) + # logger.debug("No subdomains are in use by another domain") + + # nameservers = [host.name for host in hosts] + # hosts = self.createDeleteHostList(hostsToDelete=nameservers) + # response_code = self.addAndRemoveHostsFromDomain(hostsToAdd=[], hostsToDelete=hosts) + # if response_code != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: + # raise RegistryError(code=response_code) - logger.debug("Deleting subordinate hosts for %s", self.name) - self._delete_hosts_if_not_used(nameservers) + # logger.debug("Deleting subordinate hosts for %s", self.name) + # self._delete_hosts_if_not_used(nameservers) + ( + deleted_values, + updated_values, + new_values, + oldNameservers, + ) = self.getNameserverChanges(hosts=[]) + + _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors + addToDomainList, addToDomainCount = self.createNewHostList(new_values) + deleteHostList, deleteCount = self.createDeleteHostList(deleted_values) + responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList) + + # if unable to update domain raise error and stop + if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: + raise NameserverError(code=nsErrorCodes.BAD_DATA) + + self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + logger.debug("Deleting non-registrant contacts for %s", self.name) contacts = PublicContact.objects.filter(domain=self) for contact in contacts: From 30c15fa817394d98fb911fd1805f2926827a4bf2 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 11 Dec 2024 14:38:14 -0500 Subject: [PATCH 054/112] js cleanup --- .../assets/src/js/getgov-admin/domain-form.js | 1 - .../src/js/getgov-admin/portfolio-form.js | 53 ++++++++----------- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-form.js b/src/registrar/assets/src/js/getgov-admin/domain-form.js index 8c5ab5b1c..5335bd0d3 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-form.js @@ -37,7 +37,6 @@ export function initDomainFormTargetBlankButtons() { export function initDynamicDomainFields(){ const domainPage = document.getElementById("domain_form"); if (domainPage) { - console.log("handling domain page"); handlePortfolioSelection("#id_domain_info-0-portfolio", "#id_domain_info-0-sub_organization"); } diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 1d73e2bd5..74729c2b2 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -8,7 +8,7 @@ function handlePortfolioFields(){ let isPageLoading = true // $ symbolically denotes that this is using jQuery - const $seniorOfficial = django.jQuery("#id_senior_official"); + const $seniorOfficialDropdown = django.jQuery("#id_senior_official"); const seniorOfficialField = document.querySelector(".field-senior_official"); const seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list"); const seniorOfficialReadonly = seniorOfficialField.querySelector(".readonly"); @@ -22,6 +22,8 @@ function handlePortfolioFields(){ const urbanizationField = document.querySelector(".field-urbanization"); const stateTerritoryDropdown = document.getElementById("id_state_territory"); const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; + const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; + const federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; /** * Fetches federal type data based on a selected agency using an AJAX call. @@ -31,7 +33,6 @@ function handlePortfolioFields(){ * or null if there was an error. */ function getFederalTypeFromAgency(agency) { - let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; return fetch(`${federalPortfolioApi}?&agency_name=${agency}`) .then(response => { const statusCode = response.status; @@ -58,8 +59,6 @@ function handlePortfolioFields(){ * or null if there was an error. */ function getSeniorOfficialFromAgency(agency) { - const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; - return fetch(`${seniorOfficialApi}?agency_name=${agency}`) .then(response => { const statusCode = response.status; @@ -148,16 +147,16 @@ function handlePortfolioFields(){ // 4. Handle senior official hideElement(seniorOfficialAddress.parentElement); - getSeniorOfficialFromAgency(selectedFederalAgency).then((data) => { + getSeniorOfficialFromAgency(selectedFederalAgency).then((senior_official) => { // Update the "contact details" blurb beneath senior official - updateSeniorOfficialContactInfo(data); + updateSeniorOfficialContactInfo(senior_official); showElement(seniorOfficialAddress.parentElement); // Get the associated senior official with this federal agency - let seniorOfficialId = data.id; - let seniorOfficialName = [data.first_name, data.last_name].join(" "); - if ($seniorOfficial && $seniorOfficial.length > 0) { + let seniorOfficialId = senior_official.id; + let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(" "); + if ($seniorOfficialDropdown && $seniorOfficialDropdown.length > 0) { // If the senior official is a dropdown field, edit that - updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); + updateSeniorOfficialDropdown(seniorOfficialId, seniorOfficialName); } else { if (seniorOfficialReadonly) { let seniorOfficialLink = `${seniorOfficialName}` @@ -168,8 +167,8 @@ function handlePortfolioFields(){ .catch(error => { if (error.statusCode === 404) { // Handle "not found" senior official - if ($seniorOfficial && $seniorOfficial.length > 0) { - $seniorOfficial.val("").trigger("change"); + if ($seniorOfficialDropdown && $seniorOfficialDropdown.length > 0) { + $seniorOfficialDropdown.val("").trigger("change"); } else { seniorOfficialReadonly.innerHTML = `No senior official found. Create one now.`; } @@ -177,32 +176,30 @@ function handlePortfolioFields(){ // Handle other errors console.error("An error occurred:", error.message); } - }); - + }); } else { isPageLoading = false; } - } /** * Helper for updating federal type field */ - function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) { + function updateSeniorOfficialDropdown(seniorOfficialId, seniorOfficialName) { if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ // Clear the field if the SO doesn't exist - dropdown.val("").trigger("change"); + $seniorOfficialDropdown.val("").trigger("change"); return; } // Add the senior official to the dropdown. // This format supports select2 - if we decide to convert this field in the future. - if (dropdown.find(`option[value='${seniorOfficialId}']`).length) { + if ($seniorOfficialDropdown.find(`option[value='${seniorOfficialId}']`).length) { // Select the value that is associated with the current Senior Official. - dropdown.val(seniorOfficialId).trigger("change"); + $seniorOfficialDropdown.val(seniorOfficialId).trigger("change"); } else { // Create a DOM Option that matches the desired Senior Official. Then append it and select it. let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true); - dropdown.append(userOption).trigger("change"); + $seniorOfficialDropdown.append(userOption).trigger("change"); } } @@ -237,34 +234,30 @@ function handlePortfolioFields(){ /** * Helper for updating senior official contact info */ - function updateSeniorOfficialContactInfo(data) { + function updateSeniorOfficialContactInfo(senior_official) { if (!seniorOfficialAddress) return; - const titleSpan = seniorOfficialAddress.querySelector(".contact_info_title"); const emailSpan = seniorOfficialAddress.querySelector(".contact_info_email"); const phoneSpan = seniorOfficialAddress.querySelector(".contact_info_phone"); - if (titleSpan) { - titleSpan.textContent = data.title || "None"; + titleSpan.textContent = senior_official.title || "None"; }; - // Update the email field and the content for the clipboard if (emailSpan) { let copyButton = seniorOfficialAddress.querySelector(".admin-icon-group"); - emailSpan.textContent = data.email || "None"; - if (data.email) { + emailSpan.textContent = senior_official.email || "None"; + if (senior_official.email) { const clipboardInput = seniorOfficialAddress.querySelector(".admin-icon-group input"); if (clipboardInput) { - clipboardInput.value = data.email; + clipboardInput.value = senior_official.email; }; showElement(copyButton); }else { hideElement(copyButton); } } - if (phoneSpan) { - phoneSpan.textContent = data.phone || "None"; + phoneSpan.textContent = senior_official.phone || "None"; }; } From 2bc8d38002f17fc37f601ee367aac42ee2c9c1e3 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:57:57 -0700 Subject: [PATCH 055/112] add logic for invitation --- src/registrar/admin.py | 2 +- src/registrar/assets/src/js/getgov/main.js | 15 ++--- .../src/js/getgov/portfolio-member-page.js | 58 ++++++++++++++++++- src/registrar/assets/src/js/getgov/radios.js | 4 +- src/registrar/forms/portfolio.py | 48 +++++---------- .../portfolio_member_permissions.html | 29 ++++++++-- src/registrar/views/portfolios.py | 1 - 7 files changed, 106 insertions(+), 51 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 144d1fcab..44b8d7345 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3791,7 +3791,7 @@ def changelist_view(self, request, extra_context=None): 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") + extra_context["organization_members"] = flag_is_active_for_user(request.user, "organization_members") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 5de02f35a..c379215f1 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -9,7 +9,7 @@ import { initDomainsTable } from './table-domains.js'; import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; -import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; +import { initPortfolioNewMemberPageToggle } from './portfolio-member-page.js'; import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; initDomainValidators(); @@ -20,13 +20,6 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); -hookupRadioTogglerListener( - 'member_access_level', - { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' - } -); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); initializeUrbanizationToggle(); @@ -42,5 +35,9 @@ initDomainRequestsTable(); initMembersTable(); initMemberDomainsTable(); -initPortfolioMemberPageToggle(); +// Init the portfolio new member page +initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); + +// Init all other portfolio member pages +initPortfolioMemberPage(); \ No newline at end of file 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 98bcf7d03..8d1b8e065 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js'; import { getCsrfToken } from './helpers.js'; import { generateKebabHTML } from './table-base.js'; import { MembersTable } from './table-members.js'; +import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal -export function initPortfolioMemberPageToggle() { +export function initPortfolioNewMemberPageToggle() { document.addEventListener("DOMContentLoaded", () => { const wrapperDeleteAction = document.getElementById("wrapper-delete-action") if (wrapperDeleteAction) { @@ -53,6 +54,16 @@ export function initAddNewMemberPageListeners() { if (!add_member_form){ return; } + // Hookup the radio elements + hookupRadioTogglerListener( + 'member_access_level', + { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + } + ); + + // Hookup the submission buttons document.getElementById("confirm_new_member_submit").addEventListener("click", function() { // Upon confirmation, submit the form document.getElementById("add_member_form").submit(); @@ -168,5 +179,50 @@ export function initAddNewMemberPageListeners() { modalTrigger.click(); } } +} +// Export for the rest of the portfolio pages (not add) +export function initPortfolioMemberPage() { + document.addEventListener("DOMContentLoaded", () => { + hookupRadioTogglerListener( + 'role', + { + 'organization_admin': 'new-member-admin-permissions', + 'organization_member': 'new-member-basic-permissions' + } + ); + // let memberForm = document.getElementById("member_form"); + // if (!memberForm) { + // return; + // } + + // let memberAdminContainer = document.getElementById("member-admin-permissions"); + // let memberBasicContainer = document.getElementById("member-basic-permissions"); + // let roleRadios = document.querySelectorAll('input[name="role"]'); + + // function toggleContainers() { + // let selectedRole = document.querySelector('input[name="role"]:checked'); + // if (!selectedRole) { + // hideElement(memberAdminContainer); + // hideElement(memberBasicContainer); + // return; + // } + + // if (selectedRole.value === "organization_admin") { + // showElement(memberAdminContainer); + // hideElement(memberBasicContainer); + // } else if (selectedRole.value === "organization_member") { + // hideElement(memberAdminContainer); + // showElement(memberBasicContainer); + // } + // } + + // // Initial state + // toggleContainers(); + + // // Add change listener to all radio buttons + // roleRadios.forEach(radio => { + // radio.addEventListener("change", toggleContainers); + // }); + }); } \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 248865e8b..207021dff 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -64,8 +64,8 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { } } } - - if (radioButtons.length) { + + if (radioButtons && radioButtons.length) { // Add event listener to each radio button radioButtons.forEach(function (radioButton) { radioButton.addEventListener('change', handleRadioButtonChange); diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 3c45f5df1..520c097b6 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -109,12 +109,13 @@ def clean(self): cleaned_data.pop("full_name", None) return cleaned_data + class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( choices=[ (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), - (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access") + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), ], widget=forms.RadioSelect, required=True, @@ -175,12 +176,18 @@ class BasePortfolioMemberForm(forms.Form): } 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) + self.instance = instance + self.initial = self._map_instance_to_form(self.instance) + # Adds a

description beneath each role option + self.fields["role"].descriptions = { + "organization_admin": UserPortfolioRoleChoices.get_role_description( + UserPortfolioRoleChoices.ORGANIZATION_ADMIN + ), + "organization_member": UserPortfolioRoleChoices.get_role_description( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER + ), + } def _map_instance_to_form(self, instance): """Maps model instance data to form fields""" @@ -235,10 +242,7 @@ def clean(self): raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") if not cleaned_data.get(field_name): - self.add_error( - field_name, - self.fields.get(field_name).error_messages.get("required") - ) + self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) return cleaned_data @@ -259,7 +263,7 @@ def save(self): 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: @@ -285,21 +289,6 @@ class PortfolioMemberForm(BasePortfolioMemberForm): """ Form for updating a portfolio member. """ - 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) class PortfolioInvitedMemberForm(BasePortfolioMemberForm): @@ -307,13 +296,6 @@ class PortfolioInvitedMemberForm(BasePortfolioMemberForm): Form for updating a portfolio invited member. """ - class Meta: - model = PortfolioInvitation - fields = [ - "roles", - "additional_permissions", - ] - class NewMemberForm(forms.ModelForm): member_access_level = forms.ChoiceField( diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 8a012964d..0744e8570 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -18,7 +18,12 @@ Members

  • - Manage member + {% if member %} + {% url 'member' pk=member.pk as back_url %} + {% elif invitation %} + {% url 'invitedmember' pk=invitation.pk as back_url %} + {% endif %} + Manage member
  • {% comment %} Manage members {% endcomment %}
  • @@ -36,13 +41,29 @@

    Member access and permissions

    {% csrf_token %}
    -

    Member email

    + {% if member and member.email or invitation and invitation.email %} +

    Member email

    + {% else %} +

    Member

    + {% endif %}

    + {% comment %} + Show member email if possible, then invitation email. + If neither of these are true, show the name or as a last resort just "None". + {% endcomment %} {% if member %} - {{ member.email }} + {% if member.email %} + {{ member.email }} + {% else %} + {{ member.get_formatted_name }} + {% endif %} {% elif invitation %} - {{ invitation.email }} + {% if invitation.email %} + {{ invitation.email }} + {% else %} + None + {% endif %} {% endif %}

    diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index e2a5530be..5089609ca 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -165,7 +165,6 @@ def post(self, request, pk): user = portfolio_permission.user form = self.form_class(request.POST, instance=portfolio_permission) - print(f"form valid: {form.is_valid()}") if form.is_valid(): form.save() return redirect("member", pk=pk) From 0ab963db1e0a4ecf839b7c6195a4c63fac6edef9 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:21:22 -0700 Subject: [PATCH 056/112] Consolidate --- .../assets/src/js/getgov/portfolio-member-page.js | 1 + src/registrar/forms/__init__.py | 1 - src/registrar/forms/portfolio.py | 12 ------------ src/registrar/views/portfolios.py | 4 ++-- 4 files changed, 3 insertions(+), 15 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 8d1b8e065..9133bb79b 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -184,6 +184,7 @@ export function initAddNewMemberPageListeners() { // Export for the rest of the portfolio pages (not add) export function initPortfolioMemberPage() { document.addEventListener("DOMContentLoaded", () => { + console.log("test") hookupRadioTogglerListener( 'role', { diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 121e2b3f7..033e955ed 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -13,5 +13,4 @@ ) from .portfolio import ( PortfolioOrgAddressForm, - PortfolioMemberForm, ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 520c097b6..f3d6b340b 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -285,18 +285,6 @@ def save(self): return self.instance -class PortfolioMemberForm(BasePortfolioMemberForm): - """ - Form for updating a portfolio member. - """ - - -class PortfolioInvitedMemberForm(BasePortfolioMemberForm): - """ - Form for updating a portfolio invited member. - """ - - class NewMemberForm(forms.ModelForm): member_access_level = forms.ChoiceField( label="Select permission", diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 5089609ca..95d0864ab 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -143,7 +143,7 @@ def post(self, request, pk): class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = portfolioForms.PortfolioMemberForm + form_class = portfolioForms.BasePortfolioMemberForm def get(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) @@ -258,7 +258,7 @@ def post(self, request, pk): class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = portfolioForms.PortfolioInvitedMemberForm + form_class = portfolioForms.BasePortfolioMemberForm def get(self, request, pk): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) From 533e57722fba896d6d6728accb59bb4560cbc6e7 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 11 Dec 2024 14:30:58 -0600 Subject: [PATCH 057/112] refactor delete to use same logic as nameserver setter --- src/registrar/models/domain.py | 37 +++++++++++++--------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 37cc0d2b7..a3b5c3c58 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1038,27 +1038,18 @@ def _delete_domain(self): """This domain should be deleted from the registry may raises RegistryError, should be caught or handled correctly by caller""" - # logger.info("Deleting subdomains for %s", self.name) - # # check if any subdomains are in use by another domain - # hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) - # logger.debug("Checking if any subdomains are in use by another domain") - # for host in hosts: - # if host.domain != self: - # logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) - # raise RegistryError( - # code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, - # note=f"Host {host.name} is in use by {host.domain}", - # ) - # logger.debug("No subdomains are in use by another domain") - - # nameservers = [host.name for host in hosts] - # hosts = self.createDeleteHostList(hostsToDelete=nameservers) - # response_code = self.addAndRemoveHostsFromDomain(hostsToAdd=[], hostsToDelete=hosts) - # if response_code != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: - # raise RegistryError(code=response_code) - - # logger.debug("Deleting subordinate hosts for %s", self.name) - # self._delete_hosts_if_not_used(nameservers) + logger.info("Deleting subdomains for %s", self.name) + # check if any subdomains are in use by another domain + hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) + logger.debug("Checking if any subdomains are in use by another domain") + for host in hosts: + if host.domain != self: + logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) + raise RegistryError( + code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, + note=f"Host {host.name} is in use by {host.domain}", + ) + logger.debug("No subdomains are in use by another domain") ( deleted_values, @@ -1068,8 +1059,8 @@ def _delete_domain(self): ) = self.getNameserverChanges(hosts=[]) _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors - addToDomainList, addToDomainCount = self.createNewHostList(new_values) - deleteHostList, deleteCount = self.createDeleteHostList(deleted_values) + addToDomainList, _ = self.createNewHostList(new_values) + deleteHostList, _ = self.createDeleteHostList(deleted_values) responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList) # if unable to update domain raise error and stop From 4f3663efa0ea318d8786c4fdb069ecdfa589ae9e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:31:33 -0700 Subject: [PATCH 058/112] Fix js --- src/registrar/assets/src/js/getgov/main.js | 3 +- .../src/js/getgov/portfolio-member-page.js | 2 +- .../portfolio_member_permissions.html | 44 ------------------- 3 files changed, 2 insertions(+), 47 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index c379215f1..4eb1534a5 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -9,8 +9,7 @@ import { initDomainsTable } from './table-domains.js'; import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; -import { initPortfolioNewMemberPageToggle } from './portfolio-member-page.js'; -import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; +import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPage } from './portfolio-member-page.js'; initDomainValidators(); 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 9133bb79b..429c0e70e 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -52,7 +52,7 @@ export function initPortfolioNewMemberPageToggle() { export function initAddNewMemberPageListeners() { let add_member_form = document.getElementById("add_member_form") if (!add_member_form){ - return; + return; } // Hookup the radio elements hookupRadioTogglerListener( diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 0744e8570..674dc37bd 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -134,48 +134,4 @@

    Organization d - {% endblock portfolio_content%} - -{% comment %} {% extends 'portfolio_base.html' %} -{% load static field_helpers%} - -{% block title %}Organization member {% endblock %} - -{% load static %} - -{% block portfolio_content %} -
    -
    - - {% block messages %} - {% include "includes/form_messages.html" %} - {% endblock %} - -

    Manage member

    - -

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

    - -
    - -
    - {% csrf_token %} - {% input_with_errors form.roles %} - {% input_with_errors form.additional_permissions %} - -
    - - - -
    -
    -{% endblock %} {% endcomment %} From 9fe87fbba1574aa36270e1c0d41513f468f6a75b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:51:18 -0700 Subject: [PATCH 059/112] wrap up js --- .../src/js/getgov/portfolio-member-page.js | 77 ++++++++++--------- .../models/utility/portfolio_helper.py | 8 +- 2 files changed, 47 insertions(+), 38 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 429c0e70e..a12f68ec1 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -3,6 +3,7 @@ import { getCsrfToken } from './helpers.js'; import { generateKebabHTML } from './table-base.js'; import { MembersTable } from './table-members.js'; import { hookupRadioTogglerListener } from './radios.js'; +import { hideElement, showElement } from './helpers.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal export function initPortfolioNewMemberPageToggle() { @@ -182,48 +183,50 @@ export function initAddNewMemberPageListeners() { } // Export for the rest of the portfolio pages (not add) +// Not using the export function initPortfolioMemberPage() { document.addEventListener("DOMContentLoaded", () => { - console.log("test") - hookupRadioTogglerListener( - 'role', - { - 'organization_admin': 'new-member-admin-permissions', - 'organization_member': 'new-member-basic-permissions' - } - ); - // let memberForm = document.getElementById("member_form"); - // if (!memberForm) { - // return; - // } - - // let memberAdminContainer = document.getElementById("member-admin-permissions"); - // let memberBasicContainer = document.getElementById("member-basic-permissions"); - // let roleRadios = document.querySelectorAll('input[name="role"]'); - - // function toggleContainers() { - // let selectedRole = document.querySelector('input[name="role"]:checked'); - // if (!selectedRole) { - // hideElement(memberAdminContainer); - // hideElement(memberBasicContainer); - // return; - // } + let memberForm = document.getElementById("member_form"); + if (!memberForm) { + return; + } - // if (selectedRole.value === "organization_admin") { - // showElement(memberAdminContainer); - // hideElement(memberBasicContainer); - // } else if (selectedRole.value === "organization_member") { - // hideElement(memberAdminContainer); - // showElement(memberBasicContainer); + // console.log("test") + // hookupRadioTogglerListener( + // 'role', + // { + // 'organization_admin': 'new-member-admin-permissions', + // 'organization_member': 'new-member-basic-permissions' // } - // } + // ) + + let memberAdminContainer = document.getElementById("member-admin-permissions"); + let memberBasicContainer = document.getElementById("member-basic-permissions"); + let roleRadios = document.querySelectorAll('input[name="role"]'); + + function toggleContainers() { + let selectedRole = document.querySelector('input[name="role"]:checked'); + if (!selectedRole) { + hideElement(memberAdminContainer); + hideElement(memberBasicContainer); + return; + } + + if (selectedRole.value === "organization_admin") { + showElement(memberAdminContainer); + hideElement(memberBasicContainer); + } else if (selectedRole.value === "organization_member") { + hideElement(memberAdminContainer); + showElement(memberBasicContainer); + } + } - // // Initial state - // toggleContainers(); + // Initial state + toggleContainers(); - // // Add change listener to all radio buttons - // roleRadios.forEach(radio => { - // radio.addEventListener("change", toggleContainers); - // }); + // Add change listener to all radio buttons + roleRadios.forEach(radio => { + radio.addEventListener("change", toggleContainers); + }); }); } \ No newline at end of file diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 60fa2170a..25073639b 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -4,7 +4,9 @@ from django.forms import ValidationError from registrar.utility.waffle import flag_is_active_for_user from django.contrib.auth import get_user_model +import logging +logger = logging.getLogger(__name__) class UserPortfolioRoleChoices(models.TextChoices): """ @@ -16,7 +18,11 @@ 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 + try: + return cls(user_portfolio_role).label if user_portfolio_role else None + 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): From 6662d82539770f47f7bd7c9a7c9eb2731b4818ad Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:03:38 -0700 Subject: [PATCH 060/112] Consolidate js --- .../src/js/getgov/portfolio-member-page.js | 43 +++---------------- 1 file changed, 6 insertions(+), 37 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 a12f68ec1..3d5cc3609 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -190,43 +190,12 @@ export function initPortfolioMemberPage() { if (!memberForm) { return; } - - // console.log("test") - // hookupRadioTogglerListener( - // 'role', - // { - // 'organization_admin': 'new-member-admin-permissions', - // 'organization_member': 'new-member-basic-permissions' - // } - // ) - - let memberAdminContainer = document.getElementById("member-admin-permissions"); - let memberBasicContainer = document.getElementById("member-basic-permissions"); - let roleRadios = document.querySelectorAll('input[name="role"]'); - - function toggleContainers() { - let selectedRole = document.querySelector('input[name="role"]:checked'); - if (!selectedRole) { - hideElement(memberAdminContainer); - hideElement(memberBasicContainer); - return; - } - - if (selectedRole.value === "organization_admin") { - showElement(memberAdminContainer); - hideElement(memberBasicContainer); - } else if (selectedRole.value === "organization_member") { - hideElement(memberAdminContainer); - showElement(memberBasicContainer); + hookupRadioTogglerListener( + 'role', + { + 'organization_admin': 'member-admin-permissions', + 'organization_member': 'member-basic-permissions' } - } - - // Initial state - toggleContainers(); - - // Add change listener to all radio buttons - roleRadios.forEach(radio => { - radio.addEventListener("change", toggleContainers); - }); + ) }); } \ No newline at end of file From a940fa673d9e90776e11b9dff7863ca555dc72b2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:01:11 -0800 Subject: [PATCH 061/112] Update domain request confirmation page content for org model --- .../templates/domain_request_done.html | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/registrar/templates/domain_request_done.html b/src/registrar/templates/domain_request_done.html index 0d38309d8..9b1b109a1 100644 --- a/src/registrar/templates/domain_request_done.html +++ b/src/registrar/templates/domain_request_done.html @@ -26,13 +26,17 @@

    Thanks for your domain request!

    Next steps in this process

    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:

    -
      -
    • 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.
    • -
    + + {% if has_organization_feature_flag %} +

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

    + {% else %} +

    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.
    • +
    + {% endif %}

    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.

    From 51da456f02ba72f09238808129ebc3186571bd7d Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Dec 2024 13:45:36 -0600 Subject: [PATCH 062/112] fix tests --- src/registrar/models/domain.py | 4 + src/registrar/tests/common.py | 2 +- src/registrar/tests/test_admin_domain.py | 2 +- src/registrar/tests/test_models_domain.py | 127 ++++++++++++++++------ 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index a3b5c3c58..245c82f7f 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1071,6 +1071,7 @@ def _delete_domain(self): logger.debug("Deleting non-registrant contacts for %s", self.name) contacts = PublicContact.objects.filter(domain=self) + logger.debug("contacts %s", contacts) for contact in contacts: if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT: logger.debug("removing contact %s from domain %s", contact.registry_id, self.name) @@ -1085,6 +1086,9 @@ def _delete_domain(self): logger.debug("Deleting registrant contact for %s", self.name) registrant_id = self.registrant_contact.registry_id + logger.debug("setting default registrant contact") + self._add_registrant_to_existing_domain(self.get_default_registrant_contact()) + logger.debug("deleting registrant contact %s from registry", registrant_id) deleteRegistrant = commands.DeleteContact(id=registrant_id) registry.send(deleteRegistrant, cleaned=True) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 0f7923083..392d5b248 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1677,7 +1677,7 @@ 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="otherdomain.gov") + raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note="ns1.sharedhost.com") 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 57961605d..f3ff49abf 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -265,7 +265,7 @@ def test_deletion_is_unsuccessful(self): 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", + "Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", extra_tags="", fail_silently=False, ) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 70ef4cdde..d6a24e2bd 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2584,8 +2584,24 @@ def setUp(self): """ super().setUp() self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + self.domain_with_contacts, _ = Domain.objects.get_or_create(name="freeman.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) + PublicContact.objects.create( + registry_id="regContact", + contact_type=PublicContact.ContactTypeChoices.REGISTRANT, + domain=self.domain_with_contacts, + ) + PublicContact.objects.create( + registry_id="adminContact", + contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE, + domain=self.domain_with_contacts, + ) + PublicContact.objects.create( + registry_id="techContact", + contact_type=PublicContact.ContactTypeChoices.TECHNICAL, + domain=self.domain_with_contacts, + ) def tearDown(self): Host.objects.all().delete() @@ -2642,8 +2658,8 @@ def test_deletion_is_unsuccessful(self): 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") + self.assertTrue(err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) + self.assertEqual(err.msg, "Host ns1.sharingiscaring.gov is in use by: fake-on-hold.gov") # Domain itself should not be deleted self.assertNotEqual(domain, None) # State should not have changed @@ -2654,33 +2670,22 @@ 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 + Then all the needed commands are sent to the registry And `state` is set to `DELETED` """ - # 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() + self.domain_with_contacts.place_client_hold() # Delete it - domain.deletedInEpp() - domain.save() + self.domain_with_contacts.deletedInEpp() + self.domain_with_contacts.save() - # Check that the host and contacts are deleted, order doesn't matter + # Check that the host and contacts are deleted self.mockedSendFunction.assert_has_calls( [ 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, @@ -2689,28 +2694,78 @@ def test_deletion_with_host_and_contacts(self): ), 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.InfoDomain(name='freeman.gov', auth_info=None), + cleaned=True, + ), + call( + commands.InfoHost(name='fake.host.com'), + cleaned=True, + ), + call( + commands.UpdateDomain( + name='freeman.gov', + add=[], + rem=[common.HostObjSet(hosts=['fake.host.com'])], + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + 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, + auth_info=None, + ), + 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'), + cleaned=True, + ), + call( + commands.DeleteContact(id='regContact'), + 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) + self.assertNotEqual(self.domain_with_contacts, None) # State should have changed - self.assertEqual(domain.state, Domain.State.DELETED) + self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED) # @less_console_noise_decorator def test_deletion_ready_fsm_failure(self): From d7ec32d89835bd99aa5cab15c2a39c28adfcb20f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:15:27 -0700 Subject: [PATCH 063/112] Rework NewMemberView (will readd email logic) --- src/registrar/forms/portfolio.py | 237 +++++++++--------- .../portfolio_member_permissions.html | 4 +- .../templates/portfolio_members_add_new.html | 42 ++-- src/registrar/views/portfolios.py | 190 +++----------- src/registrar/views/utility/__init__.py | 1 + src/registrar/views/utility/mixins.py | 17 ++ .../views/utility/permission_views.py | 23 +- 7 files changed, 211 insertions(+), 303 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index f3d6b340b..f159e962f 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -6,13 +6,13 @@ from django.core.validators import MaxLengthValidator from django.utils.safestring import mark_safe from registrar.models import ( - PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, User, ) +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices logger = logging.getLogger(__name__) @@ -177,8 +177,9 @@ class BasePortfolioMemberForm(forms.Form): def __init__(self, *args, instance=None, **kwargs): super().__init__(*args, **kwargs) - self.instance = instance - self.initial = self._map_instance_to_form(self.instance) + if instance: + self.instance = instance + self.initial = self.map_instance_to_form(self.instance) # Adds a

    description beneath each role option self.fields["role"].descriptions = { "organization_admin": UserPortfolioRoleChoices.get_role_description( @@ -189,8 +190,60 @@ def __init__(self, *args, instance=None, **kwargs): ), } - def _map_instance_to_form(self, instance): - """Maps model instance data to form fields""" + 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 + """ + 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: + # Helpful error for if this breaks + if field_name not in self.fields: + raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") + + if not cleaned_data.get(field_name): + self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) + + 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 + + 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", + "member_permission_admin": permission level if admin, + "domain_request_permission_admin": permission level if admin, + "domain_request_permission_member": permission level if member + } + """ if not instance: return {} @@ -200,64 +253,63 @@ def _map_instance_to_form(self, instance): perms = UserPortfolioPermission.get_portfolio_permissions(instance.roles, instance.additional_permissions) # Get role - role = UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value + role = UserPortfolioRoleChoices.ORGANIZATION_MEMBER if is_admin: - role = UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value + role = UserPortfolioRoleChoices.ORGANIZATION_ADMIN # Get domain request permission level domain_request_permission = None - if UserPortfolioPermissionChoices.EDIT_REQUESTS.value in perms: - domain_request_permission = UserPortfolioPermissionChoices.EDIT_REQUESTS.value - elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value in perms: - domain_request_permission = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value - elif not is_admin: - domain_request_permission = "no_access" + 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 # Get member permission level member_permission = None - if UserPortfolioPermissionChoices.EDIT_MEMBERS.value in perms: - member_permission = UserPortfolioPermissionChoices.EDIT_MEMBERS.value - elif UserPortfolioPermissionChoices.VIEW_MEMBERS.value in perms: - member_permission = UserPortfolioPermissionChoices.VIEW_MEMBERS.value + if UserPortfolioPermissionChoices.EDIT_MEMBERS in perms: + member_permission = UserPortfolioPermissionChoices.EDIT_MEMBERS + elif UserPortfolioPermissionChoices.VIEW_MEMBERS in perms: + member_permission = UserPortfolioPermissionChoices.VIEW_MEMBERS - # Build form data based on role + # Build form data based on role. form_data = { "role": role, - "member_permission_admin": member_permission if is_admin else None, - "domain_request_permission_admin": domain_request_permission if is_admin else None, - "domain_request_permission_member": domain_request_permission if not is_admin else None, + "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, } - return form_data - def clean(self): - cleaned_data = super().clean() - role = cleaned_data.get("role") + # 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" - # 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 - if field_name not in self.fields: - raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") - - if not cleaned_data.get(field_name): - self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) - - return cleaned_data + return form_data - def save(self): - """Save the form data to the instance""" - # TODO - we need to add view AND create in some circumstances... - role = self.cleaned_data.get("role") - member_permission_admin = self.cleaned_data.get("member_permission_admin") - domain_request_permission_admin = self.cleaned_data.get("domain_request_permission_admin") - domain_request_permission_member = self.cleaned_data.get("domain_request_permission_member") + 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 + """ + 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 - self.instance.roles = [role] + instance.roles = [role] - # TODO - do we want to be clearing everything or be selective? # Handle additional_permissions additional_permissions = set() if role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN: @@ -270,7 +322,6 @@ def save(self): if domain_request_permission_member and domain_request_permission_member != "no_access": additional_permissions.add(domain_request_permission_member) - # TODO - might need a rework. Maybe just a special perm? # Handle EDIT permissions (should be accompanied with a view permission) if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS) @@ -279,54 +330,12 @@ def save(self): additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) # Only set unique permissions not already defined in the base role - role_permissions = UserPortfolioPermission.get_portfolio_permissions(self.instance.roles, [], get_list=False) - self.instance.additional_permissions = list(additional_permissions - role_permissions) - self.instance.save() - return self.instance + 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", - }, - ) - +class NewMemberForm(BasePortfolioMemberForm): email = forms.EmailField( label="Enter the email of the member you'd like to invite", max_length=None, @@ -343,18 +352,26 @@ class NewMemberForm(forms.ModelForm): required=True, ) - class Meta: - model = User - fields = ["email"] + def __init__(self, *args, **kwargs): + self.portfolio = kwargs.pop('portfolio', None) + super().__init__(*args, **kwargs) 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() + if email_value: + # Check if user exists + requested_user = User.objects.filter(email=email_value, email__isnull=False).first() + if not requested_user: + raise forms.ValidationError("User does not exist.") + + # Check if user is already a member + if UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.portfolio).exists(): + raise forms.ValidationError("User is already a member of this portfolio.") ########################################## # TODO: future ticket # (invite new member) @@ -365,30 +382,4 @@ def clean(self): # 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 diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 674dc37bd..454738460 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -23,7 +23,7 @@ {% elif invitation %} {% url 'invitedmember' pk=invitation.pk as back_url %} {% endif %} - Manage member + Manage member

  • {% comment %} Manage members {% endcomment %}
  • @@ -124,7 +124,7 @@

    Organization d

    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 %} -
    +

    Admin access permissions

    Member permissions available for admin-level acccess.

    @@ -85,7 +71,7 @@

    Admin access permissions

    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_permission_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_permission_admin %} {% 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.basic_org_domain_request_permissions %} - {% endwith %} -
    +

    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 %} +
    diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 95d0864ab..0c4b1ff4a 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,6 +1,4 @@ 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 @@ -19,6 +17,7 @@ PortfolioDomainsPermissionView, PortfolioBasePermissionView, NoPortfolioDomainsPermissionView, + PortfolioInvitationCreatePermissionView, PortfolioMemberDomainsPermissionView, PortfolioMemberEditPermissionView, PortfolioMemberPermissionView, @@ -163,11 +162,21 @@ def get(self, request, pk): 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 + ) form.save() - return redirect("member", pk=pk) + 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 render( request, @@ -278,6 +287,7 @@ def post(self, request, pk): form = self.form_class(request.POST, instance=portfolio_invitation) if form.is_valid(): form.save() + messages.success(self.request, "The member access and permission changes have been saved.") return redirect("invitedmember", pk=pk) return render( @@ -466,162 +476,44 @@ def get(self, request): return render(request, "portfolio_members.html") -class NewMemberView(PortfolioMembersPermissionView, FormMixin): - +class NewMemberView(PortfolioInvitationCreatePermissionView): 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): - """Include the instance in the form kwargs.""" + """Pass request and portfolio to form.""" kwargs = super().get_form_kwargs() - kwargs["instance"] = self.get_object() + kwargs['portfolio'] = self.request.session.get("portfolio") return kwargs - 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 post(self, request, *args, **kwargs): - """Handle POST requests to process form submission.""" - self.object = self.get_object() - form = self.get_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, + def form_valid(self, form): + """Create portfolio invitation from form data.""" + if self.is_ajax(): + return JsonResponse({"is_valid": True}) + + requested_email = form.cleaned_data.get("email") + messages.success(self.request, f"{requested_email} has been invited.") + + # 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") ) - 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) + ) + self.object.save() + + messages.success(self.request, f"{self.object.email} has been invited.") 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()) + def form_invalid(self, form): + if self.is_ajax(): + return JsonResponse({"is_valid": False}) + return super().form_invalid(form) + + def is_ajax(self): + return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 6798eb4ee..fbf44fda1 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -9,5 +9,6 @@ PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, DomainInvitationPermissionCancelView, + PortfolioInvitationCreatePermissionView, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 8a3d53f09..6eeba878f 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -466,6 +466,23 @@ def has_permission(self): 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 115b2754f..2ea8a9d75 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -1,9 +1,10 @@ """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 from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole @@ -15,6 +16,7 @@ DomainRequestWizardPermission, PortfolioDomainRequestsPermission, PortfolioDomainsPermission, + PortfolioInvitationCreatePermission, PortfolioMemberDomainsPermission, PortfolioMemberEditPermission, UserDeleteDomainRolePermission, @@ -224,6 +226,25 @@ def template_name(self): 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 c743648fe0bf109f389d03ab77fa89d3feb15078 Mon Sep 17 00:00:00 2001 From: dave-kennedy-ecs <111779554+dave-kennedy-ecs@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:21:28 -0500 Subject: [PATCH 064/112] Update src/registrar/assets/src/js/getgov/table-edit-member-domains.js Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- .../assets/src/js/getgov/table-edit-member-domains.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js index 097f30aea..95492d46f 100644 --- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -15,8 +15,7 @@ export class EditMemberDomainsTable extends BaseTable { this.displayName = "domain"; this.currentSortBy = 'name'; this.initialDomainAssignments = []; // list of initially assigned domains - this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains - // which are readonly + this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly this.addedDomains = []; // list of domains added to member this.removedDomains = []; // list of domains removed from member this.initializeDomainAssignments(); From 048b558de6b074eb8f63eed5e32b239e423f3211 Mon Sep 17 00:00:00 2001 From: dave-kennedy-ecs <111779554+dave-kennedy-ecs@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:22:23 -0500 Subject: [PATCH 065/112] Update src/registrar/templates/includes/member_domains_edit_table.html Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- .../templates/includes/member_domains_edit_table.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/includes/member_domains_edit_table.html b/src/registrar/templates/includes/member_domains_edit_table.html index c5ae861e9..dec0b2623 100644 --- a/src/registrar/templates/includes/member_domains_edit_table.html +++ b/src/registrar/templates/includes/member_domains_edit_table.html @@ -131,9 +131,9 @@

    aria-describedby="You have unsaved changes that will be lost." > {% if portfolio_permission %} -{% url 'member-domains' pk=portfolio_permission.id as url %} + {% url 'member-domains' pk=portfolio_permission.id as url %} {% else %} -{% url 'invitedmember-domains' pk=portfolio_invitation.id as url %} + {% url 'invitedmember-domains' pk=portfolio_invitation.id as url %} {% endif %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_url=url modal_button_text="Continue without saving" %} From 1fb8b222c496c3f6310eee10b72398aee85aae79 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Dec 2024 15:31:40 -0600 Subject: [PATCH 066/112] pushing changes to pull on another device --- src/registrar/models/domain.py | 8 -------- src/registrar/tests/test_models_domain.py | 4 ---- 2 files changed, 12 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 245c82f7f..b2626bbe1 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1084,14 +1084,6 @@ def _delete_domain(self): request = commands.DeleteDomain(name=self.name) registry.send(request, cleaned=True) - logger.debug("Deleting registrant contact for %s", self.name) - registrant_id = self.registrant_contact.registry_id - logger.debug("setting default registrant contact") - self._add_registrant_to_existing_domain(self.get_default_registrant_contact()) - logger.debug("deleting registrant contact %s from registry", registrant_id) - deleteRegistrant = commands.DeleteContact(id=registrant_id) - registry.send(deleteRegistrant, cleaned=True) - def __str__(self) -> str: return self.name diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index d6a24e2bd..9e54a6e5f 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2754,10 +2754,6 @@ def test_deletion_with_host_and_contacts(self): commands.DeleteDomain(name='freeman.gov'), cleaned=True, ), - call( - commands.DeleteContact(id='regContact'), - cleaned=True, - ), ], any_order=True, ) From 63c2b7907f1a2a7f858eab480644f37ff78329c8 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 12 Dec 2024 15:33:50 -0600 Subject: [PATCH 067/112] clean up --- src/registrar/models/domain.py | 5 ----- src/registrar/tests/test_models_domain.py | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index b2626bbe1..191b6f143 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1041,7 +1041,6 @@ def _delete_domain(self): logger.info("Deleting subdomains for %s", self.name) # check if any subdomains are in use by another domain hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) - logger.debug("Checking if any subdomains are in use by another domain") for host in hosts: if host.domain != self: logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) @@ -1049,7 +1048,6 @@ def _delete_domain(self): code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note=f"Host {host.name} is in use by {host.domain}", ) - logger.debug("No subdomains are in use by another domain") ( deleted_values, @@ -1071,12 +1069,9 @@ def _delete_domain(self): logger.debug("Deleting non-registrant contacts for %s", self.name) contacts = PublicContact.objects.filter(domain=self) - logger.debug("contacts %s", contacts) for contact in contacts: if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT: - logger.debug("removing contact %s from domain %s", contact.registry_id, self.name) self._update_domain_with_contact(contact, rem=True) - logger.debug("deleting contact %s from registry", contact.registry_id) request = commands.DeleteContact(contact.registry_id) registry.send(request, cleaned=True) diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 9e54a6e5f..115351c20 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -2641,7 +2641,7 @@ def test_analyst_deletes_domain(self): # Cache should be invalidated self.assertEqual(self.domain._cache, {}) - # @less_console_noise_decorator + @less_console_noise_decorator def test_deletion_is_unsuccessful(self): """ Scenario: Domain deletion is unsuccessful @@ -2665,7 +2665,7 @@ def test_deletion_is_unsuccessful(self): # State should not have changed self.assertEqual(domain.state, Domain.State.ON_HOLD) - # @less_console_noise_decorator + @less_console_noise_decorator def test_deletion_with_host_and_contacts(self): """ Scenario: Domain with related Host and Contacts is Deleted @@ -2763,7 +2763,7 @@ def test_deletion_with_host_and_contacts(self): # State should have changed self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED) - # @less_console_noise_decorator + @less_console_noise_decorator def test_deletion_ready_fsm_failure(self): """ Scenario: Domain deletion is unsuccessful due to FSM rules From f6e95cc4f8cf811d8c7e0721778c012404a5f20d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:14:05 -0700 Subject: [PATCH 068/112] Add stub for email adding --- src/registrar/assets/src/js/getgov/main.js | 2 +- .../assets/src/js/getgov/portfolio-member-page.js | 15 +++++++++------ src/registrar/forms/portfolio.py | 14 ++++++-------- .../templates/portfolio_member_permissions.html | 4 ++-- src/registrar/views/portfolios.py | 14 +++++++++----- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 4eb1534a5..2e789e965 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -39,4 +39,4 @@ initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); // Init all other portfolio member pages -initPortfolioMemberPage(); \ No newline at end of file +initPortfolioMemberPage(); 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 3d5cc3609..aeca5d1f6 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -57,10 +57,10 @@ export function initAddNewMemberPageListeners() { } // Hookup the radio elements hookupRadioTogglerListener( - 'member_access_level', + 'role', { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' + 'organization_admin': 'new-member-admin-permissions', + 'organization_basic': 'new-member-basic-permissions' } ); @@ -115,6 +115,8 @@ 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 => { // Find the

    element text @@ -122,17 +124,18 @@ export function initAddNewMemberPageListeners() { // 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}`) // 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"); @@ -198,4 +201,4 @@ export function initPortfolioMemberPage() { } ) }); -} \ No newline at end of file +} diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index f159e962f..135ddd838 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -364,16 +364,14 @@ def clean(self): cleaned_data["email"] = email_value.lower() if email_value: - # Check if user exists - requested_user = User.objects.filter(email=email_value, email__isnull=False).first() - if not requested_user: - raise forms.ValidationError("User does not exist.") - # Check if user is already a member - if UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.portfolio).exists(): - raise forms.ValidationError("User is already a member of this portfolio.") + 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.") ########################################## - # TODO: future ticket + # TODO: #3019 # (invite new member) ########################################## # Check for an existing user (if there isn't any, send an invite) diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 454738460..7e3eb3189 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -89,7 +89,7 @@

    Member Access

    -
    +

    Admin access permissions

    Member permissions available for admin-level acccess.

    @@ -110,7 +110,7 @@

    Admin access permissions

    -
    +

    Basic member permissions

    Member permissions available for basic-level acccess.

    diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 0c4b1ff4a..a03ef2b4a 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -493,10 +493,11 @@ def form_valid(self, form): """Create portfolio invitation from form data.""" if self.is_ajax(): return JsonResponse({"is_valid": True}) - - requested_email = form.cleaned_data.get("email") - messages.success(self.request, f"{requested_email} has been invited.") - + + # TODO: #3019 + # requested_email = form.cleaned_data.get("email") + # self.send_portfolio_invitation_email(requested_email) + # Create instance using form's mapping method self.object = form.map_cleaned_data_to_instance( form.cleaned_data, @@ -506,10 +507,13 @@ def form_valid(self, form): ) ) 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}) From 2d0c4e7b1da00e66dd8f208235b60b592a778149 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:40:36 -0700 Subject: [PATCH 069/112] Update portfolios.py --- src/registrar/views/portfolios.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index a03ef2b4a..762de36ca 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -494,9 +494,11 @@ def form_valid(self, form): if self.is_ajax(): return JsonResponse({"is_valid": True}) - # TODO: #3019 + # TODO: #3019 - this will probably have to be a small try/catch. Stub for posterity. # requested_email = form.cleaned_data.get("email") - # self.send_portfolio_invitation_email(requested_email) + # send_success = self.send_portfolio_invitation_email(requested_email) + # if not send_success: + # return # Create instance using form's mapping method self.object = form.map_cleaned_data_to_instance( From d48c140177af5ef043bdb58f99c5272e8ac9e688 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 09:53:44 -0700 Subject: [PATCH 070/112] cleanup --- src/registrar/context_processors.py | 2 -- src/registrar/forms/portfolio.py | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 4d9ccd68a..9f5d0162f 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -107,8 +107,6 @@ def is_widescreen_mode(request): "/no-organization-requests/", "/no-organization-domains/", "/domain-request/", - # "/members/", - # "/member/" ] # widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out. exclude_paths = [ diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 135ddd838..4a7417318 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -6,13 +6,12 @@ from django.core.validators import MaxLengthValidator from django.utils.safestring import mark_safe from registrar.models import ( + PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, - User, ) -from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices logger = logging.getLogger(__name__) From d52a44a7448e8eba5352369bd29035673262ba58 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:44:32 -0700 Subject: [PATCH 071/112] the perpetual struggle --- src/registrar/assets/src/js/getgov/main.js | 6 +-- .../src/js/getgov/portfolio-member-page.js | 46 +++++++------------ src/registrar/assets/src/js/getgov/radios.js | 9 ++-- .../portfolio_member_permissions.html | 4 +- .../templates/portfolio_members_add_new.html | 2 +- src/registrar/tests/test_views_portfolio.py | 6 +-- 6 files changed, 31 insertions(+), 42 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index bfa11da24..f5ebc83a3 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -10,7 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; -import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPage } from './portfolio-member-page.js'; +import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js'; initDomainValidators(); @@ -37,8 +37,6 @@ initMemberDomainsTable(); initEditMemberDomainsTable(); // Init the portfolio new member page +initPortfolioMemberPageRadio(); initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); - -// Init all other portfolio member pages -initPortfolioMemberPage(); 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 69e62bcf1..16017959f 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -3,7 +3,6 @@ import { getCsrfToken } from './helpers.js'; import { generateKebabHTML } from './table-base.js'; import { MembersTable } from './table-members.js'; import { hookupRadioTogglerListener } from './radios.js'; -import { hideElement, showElement } from './helpers.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal export function initPortfolioNewMemberPageToggle() { @@ -55,14 +54,6 @@ export function initAddNewMemberPageListeners() { if (!add_member_form){ return; } - // Hookup the radio elements - hookupRadioTogglerListener( - 'role', - { - 'organization_admin': 'new-member-admin-permissions', - 'organization_basic': 'new-member-basic-permissions' - } - ); // Hookup the submission buttons document.getElementById("confirm_new_member_submit").addEventListener("click", function() { @@ -98,14 +89,6 @@ export function initAddNewMemberPageListeners() { }); }); - /* - 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 */ @@ -119,6 +102,7 @@ export function initAddNewMemberPageListeners() { 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; @@ -128,7 +112,7 @@ export function initAddNewMemberPageListeners() { 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}`) + 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) { @@ -164,19 +148,23 @@ export function initAddNewMemberPageListeners() { document.getElementById('modalEmail').textContent = emailValue; // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); + 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 = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; - document.getElementById('modalAccessLevel').textContent = accessText; + let accessText = "No access level selected"; // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { - populatePermissionDetails('new-member-admin-permissions'); - } else { - populatePermissionDetails('new-member-basic-permissions'); + 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" } + document.getElementById('modalAccessLevel').textContent = accessText; + //------- Show the modal let modalTrigger = document.querySelector("#invite_member_trigger"); if (modalTrigger) { @@ -185,12 +173,12 @@ export function initAddNewMemberPageListeners() { } } -// Export for the rest of the portfolio pages (not add) -// Not using the -export function initPortfolioMemberPage() { +// Initalize the radio for the member pages +export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { let memberForm = document.getElementById("member_form"); - if (!memberForm) { + let newMemberForm = document.getElementById("add_member_form") + if (!memberForm && !newMemberForm) { return; } hookupRadioTogglerListener( diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 207021dff..c81d18fd2 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -38,19 +38,22 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme **/ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + 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); - + function handleRadioButtonChange() { // Find the checked radio button - let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`); 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); } diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 7e3eb3189..454738460 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -89,7 +89,7 @@

    Member Access

    -
    +

    Admin access permissions

    Member permissions available for admin-level acccess.

    @@ -110,7 +110,7 @@

    Admin access permissions

    -
    +

    Basic member permissions

    Member permissions available for basic-level acccess.

    diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 75a564c27..df9200c39 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -63,7 +63,7 @@

    Member Access

    -
    +

    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 @@ def test_member_invite_for_new_users(self): 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 @@ def test_member_invite_for_previously_invited_member(self): 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 @@ def test_member_invite_for_existing_member(self): 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 072/112] 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 @@ def test_member_invite_for_new_users(self): 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 @@ def test_member_invite_for_previously_invited_member(self): 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 @@ def test_member_invite_for_existing_member(self): 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 073/112] 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 @@ def changelist_view(self, request, extra_context=None): 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 @@ def __init__(self, *args, instance=None, **kwargs): 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 @@ def map_instance_to_form(self, instance): 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 @@ def clean(self): # 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 @@ def clean(self): # 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 @@ def get(self, request, pk): 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 @@ def form_valid(self, form): # 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 @@ def has_permission(self): 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 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 074/112] 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 @@ def map_instance_to_form(self, instance): # 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 @@ logger = logging.getLogger(__name__) + class UserPortfolioRoleChoices(models.TextChoices): """ Roles make it easier for admins to look at @@ -23,7 +24,7 @@ def get_user_portfolio_role_label(cls, user_portfolio_role): 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 @@ def get_role_description(cls, user_portfolio_role): "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 @@ def test_member_invite_for_existing_member(self): # 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.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 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 075/112] 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 @@ def map_instance_to_form(self, instance): "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 @@ def tearDown(self): 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 @@ def test_edit_member_permissions_basic_to_admin(self): "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 @@ def test_edit_member_permissions_basic_to_admin(self): UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, - } + }, ) @less_console_noise_decorator @@ -2727,12 +2727,10 @@ def test_edit_member_permissions_basic_to_admin(self): 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 @@ def test_edit_member_permissions_validation(self): { "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 @@ def test_edit_member_permissions_validation(self): 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 @@ def test_edit_invited_member_permissions(self): "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 @@ def test_edit_invited_member_permissions(self): UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, - } + }, ) @less_console_noise_decorator @@ -2792,19 +2789,16 @@ def test_edit_invited_member_permissions(self): 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 076/112] 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 @@ def do_delete_domain(self, request, obj): 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 @@ def nameservers(self, hosts: list[tuple[str, list]]): 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 @@ def _delete_domain(self): 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 @@ def mockUpdateHostCommands(self, _request, cleaned): 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 077/112] review changes and linting --- src/epplibwrapper/errors.py | 2 +- src/registrar/models/domain.py | 4 +-- src/registrar/tests/test_models_domain.py | 42 +++++++++++++++-------- 3 files changed, 30 insertions(+), 18 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 @@ def _remove_client_hold(self): # 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 @@ def _delete_domain(self): 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 @@ def test_deletion_with_host_and_contacts(self): [ 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,23 @@ def test_deletion_with_host_and_contacts(self): ), 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, @@ -2714,15 +2718,19 @@ def test_deletion_with_host_and_contacts(self): ), cleaned=True, ), + ] + ) + self.mockedSendFunction.assert_has_calls( + [ call( - commands.DeleteHost(name='fake.host.com'), + commands.DeleteHost(name="fake.host.com"), cleaned=True, ), call( commands.UpdateDomain( - name='freeman.gov', + name="freeman.gov", add=[], - rem=[common.DomainContact(contact='adminContact', type='admin')], + rem=[common.DomainContact(contact="adminContact", type="admin")], nsset=None, keyset=None, registrant=None, @@ -2731,14 +2739,14 @@ def test_deletion_with_host_and_contacts(self): cleaned=True, ), call( - commands.DeleteContact(id='adminContact'), + commands.DeleteContact(id="adminContact"), cleaned=True, ), call( commands.UpdateDomain( - name='freeman.gov', + name="freeman.gov", add=[], - rem=[common.DomainContact(contact='techContact', type='tech')], + rem=[common.DomainContact(contact="techContact", type="tech")], nsset=None, keyset=None, registrant=None, @@ -2747,15 +2755,19 @@ def test_deletion_with_host_and_contacts(self): cleaned=True, ), call( - commands.DeleteContact(id='techContact'), + commands.DeleteContact(id="techContact"), cleaned=True, ), + ], + any_order=True, + ) + self.mockedSendFunction.assert_has_calls( + [ call( - commands.DeleteDomain(name='freeman.gov'), + commands.DeleteDomain(name="freeman.gov"), cleaned=True, ), ], - any_order=True, ) # Domain itself should not be deleted 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 078/112] 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 @@ 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() - 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 @@ def test_edit_member_permissions_basic_to_admin(self): # 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 @@ def test_edit_invited_member_permissions(self): 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 079/112] 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 @@ def test_edit_invited_member_permissions(self): 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 080/112] 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 @@

    Member Access

    {% 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 081/112] 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 @@ def nameservers(self, hosts: list[tuple[str, list]]): 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 082/112] 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 @@ def dnssecdata(self, _dnssecdata: Optional[extensions.DNSSECExtension]): 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 @@ def nameservers(self, hosts: list[tuple[str, list]]): 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 @@ def _delete_domain(self): 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 @@ def test_deletion_is_unsuccessful(self): 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 083/112] 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 @@ def dnssecdata(self, _dnssecdata: Optional[extensions.DNSSECExtension]): 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 @@ def test_deletion_is_unsuccessful(self): 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 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 084/112] 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 @@

    Next steps in this process

    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 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 085/112] 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 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 086/112] 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 @@ def test_generate_federal_report(self): 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 @@ def test_generate_full_report(self): 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 @@ def test_domain_data_type(self): # 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 @@ def test_domain_data_type_user(self): # 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 @@ def test_domain_data_full(self): # 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 @@ def test_domain_data_federal(self): # 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 @@ def test_domain_growth(self): 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 @@ def test_domain_managed(self): 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 @@ def test_domain_managed(self): # 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 @@ def test_domain_unmanaged(self): # 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 @@ def test_domain_request_growth(self): # 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 @@ def test_domain_request_data_full(self): 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 @@ def test_member_export(self): # 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 @@ def get_model_annotation_dict(cls, request=None, **kwargs): ) .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 @@ def model(cls): # 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 @@ def parse_row(cls, columns, model): 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 @@ def parse_row(cls, columns, model): "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 @@ def parse_row(cls, columns, model): } 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 @@ def get_sliced_domains(cls, filter_condition): 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 @@ def get_columns(cls): """ Overrides the columns for CSV export specific to DomainExport. """ - return [ "Domain name", "Status", @@ -879,13 +723,6 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_prefetch_related(cls): """ 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 @@ def exporting_dr_data_to_csv(cls, response, request=None): 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 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_filter_conditions(cls, **kwargs): ], ) + @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 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_filter_conditions(cls, **kwargs): ], ) + @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 @@ def model(cls): # 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 @@ def parse_row(cls, columns, model): """ # 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 @@ def parse_row(cls, columns, model): "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 @@ def get_computed_fields(cls, delimiter=", ", **kwargs): """ 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 - ), - # 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 { + "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", ), - } - ) - - 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 087/112] Remove changes to newmember --- .../src/js/getgov/portfolio-member-page.js | 324 +++++++++--------- src/registrar/forms/portfolio.py | 154 ++++++--- .../templates/portfolio_members_add_new.html | 43 ++- src/registrar/views/portfolios.py | 175 ++++++++-- src/registrar/views/utility/__init__.py | 1 - .../views/utility/permission_views.py | 20 -- 6 files changed, 446 insertions(+), 271 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; - } - - // Hookup the submission buttons - 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); + + // 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); +} - 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); + } }); +} - /* - 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" +/* + 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(); } +} - document.getElementById('modalAccessLevel').textContent = accessText; - - //------- 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 MaxLengthValidator from django.utils.safestring import mark_safe from registrar.models import ( - PortfolioInvitation, + User, UserPortfolioPermission, DomainInformation, Portfolio, @@ -218,6 +218,10 @@ def clean(self): 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 @@ def map_instance_to_form(self, instance): # 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 - - # 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 + # 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_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 @@ def map_cleaned_data_to_instance(self, cleaned_data, instance): 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 @@ def map_cleaned_data_to_instance(self, cleaned_data, instance): 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 @@ def clean(self): # 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 @@

      Member Access

      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 @@

      Admin access permissions

      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 @@

    Member Access

    {% 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.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 @@ PortfolioDomainsPermissionView, PortfolioBasePermissionView, NoPortfolioDomainsPermissionView, - PortfolioInvitationCreatePermissionView, PortfolioMemberDomainsPermissionView, PortfolioMemberDomainsEditPermissionView, PortfolioMemberEditPermissionView, @@ -506,45 +505,163 @@ def get(self, request): 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 post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_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): - """Create portfolio invitation from form data.""" + if self.is_ajax(): - return JsonResponse({"is_valid": True}) + return JsonResponse({"is_valid": True}) # Return a JSON response + else: + return self.submit_new_member(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 + def get_success_url(self): + """Redirect to members table.""" + return reverse("members") - # 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()) + 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 - # TODO: #3019 - # def send_portfolio_invitation_email(self, email): - # pass + raises EmailSendingError + """ - def form_invalid(self, form): - if self.is_ajax(): - return JsonResponse({"is_valid": False}) - return super().form_invalid(form) + # 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 is_ajax(self): - return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + 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 @@ 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 @@ def template_name(self): 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 088/112] cleanup pt 2 --- .../src/js/getgov/portfolio-member-page.js | 258 +++++++++--------- .../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, 146 insertions(+), 164 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); - - // 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(); - } - }); +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) +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 - - // 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); - } - }); +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. +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'); + //------- 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(); - } } } 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 @@

    Member Access

    {% 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 @@ def test_member_invite_for_new_users(self): 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 @@ def test_member_invite_for_previously_invited_member(self): 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 @@ def test_member_invite_for_existing_member(self): 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 @@ def test_member_invite_for_existing_member(self): 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 @@ def has_permission(self): 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 @@ 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 089/112] Update portfolio-member-page.js --- .../src/js/getgov/portfolio-member-page.js | 296 +++++++++--------- 1 file changed, 148 insertions(+), 148 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(); -}); - -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); -} + 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(); + }); -/* -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 + 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(); + } + }); + }); -// Get all permission sections (divs with h3 and radio inputs) -const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + /* + 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); + } -permissionSections.forEach(section => { - // Find the

    element text - const sectionTitle = section.textContent; + /* + 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 - // Find the associated radio buttons container (next fieldset) - const fieldset = section.nextElementSibling; + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + permissionSections.forEach(section => { + // Find the

    element text + const sectionTitle = section.textContent; - // 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"; - } + // Find the associated radio buttons container (next fieldset) + const fieldset = section.nextElementSibling; - // 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); - } -}); -} + 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"; + } -/* -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'); + // 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); + } + }); } - //------- Show the modal - let modalTrigger = document.querySelector("#invite_member_trigger"); - if (modalTrigger) { - modalTrigger.click(); - } -} + /* + 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 090/112] 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 @@ def get(self, request): 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 091/112] 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.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 @@ def clean(self): 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 @@ def map_cleaned_data_to_instance(self, cleaned_data, instance): 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 092/112] 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 @@ def clean(self): 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 093/112] 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 @@ def changelist_view(self, request, extra_context=None): 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 .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 @@ def clean(self): 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 @@ def __init__(self, *args, instance=None, **kwargs): } 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 @@ def save(self): 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 @@ def map_instance_to_form(self, instance): 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 @@ def _get_portfolio_permissions(self): @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 094/112] 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 095/112] 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 @@ def save(self): 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 096/112] 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 @@ def __init__(self, *args, instance=None, **kwargs): ), } + 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 @@ def clean(self): 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 @@ def map_instance_to_form(self, instance): "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) - - # 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 - ), - } + member_perms = [ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] - # 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 097/112] Revert "revert csv-export" This reverts commit b0d1bc26da85e0e6ac8bae4746d529089a6b61de. --- src/registrar/tests/test_reports.py | 138 ++++---- src/registrar/utility/csv_export.py | 519 ++++++++++++++++++++++------ 2 files changed, 476 insertions(+), 181 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 @@ def test_generate_federal_report(self): 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 @@ def test_generate_full_report(self): 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 @@ def test_domain_data_type(self): # 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),,," + "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)," "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),,,," + "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),,," - "meoward@rocks.com,squeaker@rocks.com\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" + "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 @@ def test_domain_data_type_user(self): # 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 @@ def test_domain_data_full(self): # 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 @@ def test_domain_data_federal(self): # 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 @@ def test_domain_growth(self): 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 @@ def test_domain_managed(self): 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 @@ def test_domain_managed(self): # 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 @@ def test_domain_unmanaged(self): # 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 @@ def test_domain_request_growth(self): # 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 @@ def test_domain_request_data_full(self): 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," - "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," + "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" - '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 @@ def test_member_export(self): # 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 @@ def get_model_annotation_dict(cls, request=None, **kwargs): ) .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 @@ def model(cls): # 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 @@ def parse_row(cls, columns, model): 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 @@ def parse_row(cls, columns, model): "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 @@ def parse_row(cls, columns, model): } 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 @@ def get_sliced_domains(cls, filter_condition): 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() + 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 = ( - domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() + 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 @@ def get_columns(cls): """ Overrides the columns for CSV export specific to DomainExport. """ + return [ "Domain name", "Status", @@ -723,6 +879,13 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_prefetch_related(cls): """ 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 @@ def exporting_dr_data_to_csv(cls, response, request=None): 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 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_filter_conditions(cls, **kwargs): ], ) - @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 @@ def get_columns(cls): "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 @@ def get_sort_fields(cls): """ # 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 @@ def get_filter_conditions(cls, **kwargs): ], ) - @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 @@ def model(cls): # 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() + 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 = ( - requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() + 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 @@ def parse_row(cls, columns, model): """ # 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 @@ def parse_row(cls, columns, model): "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 @@ def get_computed_fields(cls, delimiter=", ", **kwargs): """ 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 098/112] 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 099/112] 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 @@ def parse_row(cls, columns, model): ): 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 100/112] 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 @@ def test_generate_federal_report(self): 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 @@ def test_generate_full_report(self): 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 @@ def test_domain_data_type(self): # 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 @@ def test_domain_data_type_user(self): # 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 @@ def test_domain_data_full(self): # 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 @@ def test_domain_data_federal(self): # 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 @@ def test_domain_growth(self): 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 @@ def test_domain_managed(self): 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 @@ def test_domain_managed(self): # 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 @@ def test_domain_unmanaged(self): # 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 @@ def test_domain_request_growth(self): # 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 @@ def test_domain_request_data_full(self): 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 @@ def test_member_export(self): # 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 101/112] Revert "Update test_reports.py" This reverts commit 26fd19ffe80e15c7381ed52802be42da02075880. --- src/registrar/tests/test_reports.py | 138 +++++++++++++--------------- 1 file changed, 66 insertions(+), 72 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 @@ def test_generate_federal_report(self): 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 @@ def test_generate_full_report(self): 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 @@ def test_domain_data_type(self): # 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),,," + "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)," "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),,,," + "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),,," - "meoward@rocks.com,squeaker@rocks.com\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" + "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 @@ def test_domain_data_type_user(self): # 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 @@ def test_domain_data_full(self): # 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 @@ def test_domain_data_federal(self): # 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 @@ def test_domain_growth(self): 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 @@ def test_domain_managed(self): 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 @@ def test_domain_managed(self): # 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 @@ def test_domain_unmanaged(self): # 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 @@ def test_domain_request_growth(self): # 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 @@ def test_domain_request_data_full(self): 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," - "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," + "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" - '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 @@ def test_member_export(self): # 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 102/112] 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 @@ def test_generate_federal_report(self): 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 @@ def test_generate_full_report(self): 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 @@ def test_domain_data_full(self): # 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 @@ def test_domain_data_federal(self): # 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 103/112] 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 @@ def map_instance_to_initial(self, instance): # 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 104/112] 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 @@ def get_fields(cls, model): "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 @@ def get_fields(cls, model): "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 105/112] 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 @@ def get_sort_fields(cls): """ # 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 @@ def get_sort_fields(cls): """ # 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 106/112] 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 107/112] 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 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 108/112] 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 @@ def parse_row(cls, columns, model): 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 109/112] 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 @@ def parse_row(cls, columns, model): # 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 110/112] 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 3df000418270b95f096278297e2b87c36e529c4d Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Fri, 20 Dec 2024 11:33:14 -0800 Subject: [PATCH 111/112] Update logic --- src/registrar/models/domain.py | 6 +++--- src/registrar/models/user.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 74ff85084..dc51a9cb7 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1112,9 +1112,9 @@ def is_expiring(self): return False now = timezone.now().date() - expiration_window=60 - threshold_date = now + timedelta(days=expiration_window) - return now <= self.expiration_date <= threshold_date + + threshold_date = now + timedelta(days=60) + return now < self.expiration_date <= threshold_date def state_display(self, request=None): """Return the display status of the domain.""" diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 810c35155..c3bc819d6 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -163,6 +163,8 @@ def get_active_requests_count(self): active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count() return active_requests_count + # NOTE: If this is causing performance problems, + # convert the is expiring check to sum or count filter condition def get_num_expiring_domains(self, request): """Return number of expiring domains""" domain_ids = self.get_user_domain_ids(request) From 639267958c9d5e8b222bc0e18bec363d92af784e Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Fri, 20 Dec 2024 11:38:28 -0800 Subject: [PATCH 112/112] Update a comment so the build deploy will run --- src/registrar/models/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index c3bc819d6..585d073b6 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -163,7 +163,7 @@ def get_active_requests_count(self): active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count() return active_requests_count - # NOTE: If this is causing performance problems, + # If this is causing performance problems, # convert the is expiring check to sum or count filter condition def get_num_expiring_domains(self, request): """Return number of expiring domains"""