From 9c090ae5fe640767da48cb297eadf56e640ef0bd Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 10 Sep 2024 15:37:04 -0500 Subject: [PATCH 001/175] send notification on nameserver changes --- src/registrar/fixtures_users.py | 2 ++ src/registrar/views/domain.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 1b8eda9ab..a1ad0ecf7 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -39,6 +39,7 @@ class UserFixture: "username": "be17c826-e200-4999-9389-2ded48c43691", "first_name": "Matthew", "last_name": "Spence", + "email": "mspence1845@gmail.com" }, { "username": "5f283494-31bd-49b5-b024-a7e7cae00848", @@ -155,6 +156,7 @@ class UserFixture: "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", "first_name": "Matthew-Analyst", "last_name": "Spence-Analyst", + "email": "mspence1845+1@gmail.com" }, { "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844", diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 003f8dd0d..1abdb10e5 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -441,6 +441,9 @@ class DomainNameserversView(DomainFormBaseView): # no server information in this field, skip it pass + old_nameservers = self.object.nameservers + should_notify = old_nameservers and old_nameservers != nameservers + try: self.object.nameservers = nameservers except NameserverError as Err: @@ -467,6 +470,30 @@ class DomainNameserversView(DomainFormBaseView): "48 hours to propagate across the internet.", ) + # if the nameservers where changed, send notification to domain managers. + if should_notify: + managers = UserDomainRole.objects.filter(domain=self.object.name, role=UserDomainRole.Roles.MANAGER) + emails = list(managers.values_list("user", flat=True).values_list("email", flat=True)) + to_addresses=', '.join(emails) + + try: + send_templated_email( + "templateName", + "Subject Template Name", + to_address=to_addresses, + context={ + "nameservers": nameservers, + "domain": self.object, + }, + ) + except EmailSendingError as exc: + logger.warn( + "Could not sent notification email to %s for domain %s", + to_addresses, + self.object, + exc_info=True, + ) + # superclass has the redirect return super().form_valid(formset) From 3475a76899b07cee707b355ada8765d3c8289cd3 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Sep 2024 14:39:29 -0500 Subject: [PATCH 002/175] test on nameservers --- src/registrar/utility/email.py | 33 ++++++++++++++++++++++++++++++++- src/registrar/views/domain.py | 24 ++++-------------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 1fe1be596..39bf9df5b 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -10,6 +10,7 @@ from django.template.loader import get_template from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from registrar.models.user_domain_role import UserDomainRole from waffle import flag_is_active @@ -31,7 +32,7 @@ def send_templated_email( attachment_file=None, wrap_email=False, ): - """Send an email built from a template to one email address. + """Send an email built from a template. template_name and subject_template_name are relative to the same template context as Django's HTML templates. context gives additional information @@ -164,3 +165,33 @@ def send_email_with_attachment(sender, recipient, subject, body, attachment_file response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()}) return response + +def email_domain_managers(domain, template: str, subject_template: str, context: any = {}): + """Send a single email built from a template to all managers for a given domain. + + template_name and subject_template_name are relative to the same template + context as Django's HTML templates. context gives additional information + that the template may use. + + context is a dictionary containing any information needed to fill in values + in the provided template, exactly the same as with send_templated_email. + + Will log a warning if the email fails to send for any reason, but will not raise an error. + """ + managers = UserDomainRole.objects.filter(domain=domain, role=UserDomainRole.Roles.MANAGER) + emails = list(managers.values_list("user", flat=True).values_list("email", flat=True)) + + try: + send_templated_email( + template, + subject_template, + to_address=emails, + context=context, + ) + except EmailSendingError as exc: + logger.warn( + "Could not sent notification email to %s for domain %s", + emails, + domain, + exc_info=True, + ) \ No newline at end of file diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1abdb10e5..1ac19ec44 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -57,7 +57,7 @@ from epplibwrapper import ( RegistryError, ) -from ..utility.email import send_templated_email, EmailSendingError +from ..utility.email import send_templated_email, EmailSendingError, email_domain_managers from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from waffle.decorators import waffle_flag @@ -472,27 +472,11 @@ class DomainNameserversView(DomainFormBaseView): # if the nameservers where changed, send notification to domain managers. if should_notify: - managers = UserDomainRole.objects.filter(domain=self.object.name, role=UserDomainRole.Roles.MANAGER) - emails = list(managers.values_list("user", flat=True).values_list("email", flat=True)) - to_addresses=', '.join(emails) - - try: - send_templated_email( - "templateName", - "Subject Template Name", - to_address=to_addresses, - context={ + context={ "nameservers": nameservers, "domain": self.object, - }, - ) - except EmailSendingError as exc: - logger.warn( - "Could not sent notification email to %s for domain %s", - to_addresses, - self.object, - exc_info=True, - ) + } + email_domain_managers(self.object.name, "template", "subject", context) # superclass has the redirect return super().form_valid(formset) From 6c605566a23c5da32fd2168815f6ad7e71d4a9df Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Sep 2024 14:55:44 -0500 Subject: [PATCH 003/175] add temp email template and subject --- src/registrar/templates/emails/domain_change_notification.txt | 1 + .../templates/emails/domain_change_notification_subject.txt | 0 src/registrar/views/domain.py | 3 +-- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 src/registrar/templates/emails/domain_change_notification.txt create mode 100644 src/registrar/templates/emails/domain_change_notification_subject.txt diff --git a/src/registrar/templates/emails/domain_change_notification.txt b/src/registrar/templates/emails/domain_change_notification.txt new file mode 100644 index 000000000..b3c750257 --- /dev/null +++ b/src/registrar/templates/emails/domain_change_notification.txt @@ -0,0 +1 @@ +There has been a change to {{ domain }} \ No newline at end of file diff --git a/src/registrar/templates/emails/domain_change_notification_subject.txt b/src/registrar/templates/emails/domain_change_notification_subject.txt new file mode 100644 index 000000000..e69de29bb diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1ac19ec44..aba504c41 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -473,10 +473,9 @@ class DomainNameserversView(DomainFormBaseView): # if the nameservers where changed, send notification to domain managers. if should_notify: context={ - "nameservers": nameservers, "domain": self.object, } - email_domain_managers(self.object.name, "template", "subject", context) + email_domain_managers(self.object.name, "emails/domain_change_notification.txt", "emails.domain_change_notification_subject.txt", context) # superclass has the redirect return super().form_valid(formset) From 1b7408aebc7c8f1fdcd734c794a5e479c429f415 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Sep 2024 15:12:38 -0500 Subject: [PATCH 004/175] debug logs --- .../templates/emails/domain_change_notification_subject.txt | 1 + src/registrar/utility/email.py | 2 +- src/registrar/views/domain.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/registrar/templates/emails/domain_change_notification_subject.txt b/src/registrar/templates/emails/domain_change_notification_subject.txt index e69de29bb..d3f6fbedb 100644 --- a/src/registrar/templates/emails/domain_change_notification_subject.txt +++ b/src/registrar/templates/emails/domain_change_notification_subject.txt @@ -0,0 +1 @@ +Change Notification \ No newline at end of file diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 39bf9df5b..4a53661cf 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -180,7 +180,7 @@ def email_domain_managers(domain, template: str, subject_template: str, context: """ managers = UserDomainRole.objects.filter(domain=domain, role=UserDomainRole.Roles.MANAGER) emails = list(managers.values_list("user", flat=True).values_list("email", flat=True)) - + logger.debug("attempting to send templated email to domain managers") try: send_templated_email( template, diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index aba504c41..603fbbab5 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -472,6 +472,7 @@ class DomainNameserversView(DomainFormBaseView): # if the nameservers where changed, send notification to domain managers. if should_notify: + logger.debug("Sending email to domain managers") context={ "domain": self.object, } From 9609db5a53cbb8ca37d18815cedd98547f7de764 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Sep 2024 15:32:49 -0500 Subject: [PATCH 005/175] MOAR LOGS --- src/registrar/views/domain.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 603fbbab5..97fa22d88 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -417,6 +417,8 @@ class DomainNameserversView(DomainFormBaseView): def form_valid(self, formset): """The formset is valid, perform something with it.""" + + logger.debug("------ Form is valid -------") self.request.session["nameservers_form_domain"] = self.object @@ -442,8 +444,9 @@ class DomainNameserversView(DomainFormBaseView): pass old_nameservers = self.object.nameservers + logger.debug("nameservers", nameservers) should_notify = old_nameservers and old_nameservers != nameservers - + logger.debug("should_notify", should_notify) try: self.object.nameservers = nameservers except NameserverError as Err: From 0ffff70cb4c52c45df20f5223a406d78bb46b8b8 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 12 Sep 2024 15:53:14 -0500 Subject: [PATCH 006/175] MOAR DEBUG LOGS --- src/registrar/views/domain.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 97fa22d88..6fbb0fe08 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -403,6 +403,9 @@ class DomainNameserversView(DomainFormBaseView): This post method harmonizes using DomainBaseView and FormMixin """ + + logger.debug("Posted to Namservers View") + self._get_domain(request) formset = self.get_form() From d6996bd189ae50ebfe6480ca79a5c5757b3d92d9 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 13 Sep 2024 10:07:53 -0500 Subject: [PATCH 007/175] more debug messages --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6fbb0fe08..1a87f185d 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -421,7 +421,7 @@ class DomainNameserversView(DomainFormBaseView): def form_valid(self, formset): """The formset is valid, perform something with it.""" - logger.debug("------ Form is valid -------") + logger.debug("------ Nameserver Form is valid -------") self.request.session["nameservers_form_domain"] = self.object From 579a8ac8aa7e129fa5eec90de18030f166a87224 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 13 Sep 2024 10:41:32 -0500 Subject: [PATCH 008/175] change to info level --- src/registrar/views/domain.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1a87f185d..cc6816121 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -404,7 +404,7 @@ class DomainNameserversView(DomainFormBaseView): This post method harmonizes using DomainBaseView and FormMixin """ - logger.debug("Posted to Namservers View") + logger.info("Posted to Namservers View") self._get_domain(request) formset = self.get_form() @@ -421,7 +421,7 @@ class DomainNameserversView(DomainFormBaseView): def form_valid(self, formset): """The formset is valid, perform something with it.""" - logger.debug("------ Nameserver Form is valid -------") + logger.info("------ Nameserver Form is valid -------") self.request.session["nameservers_form_domain"] = self.object @@ -447,9 +447,9 @@ class DomainNameserversView(DomainFormBaseView): pass old_nameservers = self.object.nameservers - logger.debug("nameservers", nameservers) + logger.info("nameservers", nameservers) should_notify = old_nameservers and old_nameservers != nameservers - logger.debug("should_notify", should_notify) + logger.info("should_notify", should_notify) try: self.object.nameservers = nameservers except NameserverError as Err: @@ -478,7 +478,7 @@ class DomainNameserversView(DomainFormBaseView): # if the nameservers where changed, send notification to domain managers. if should_notify: - logger.debug("Sending email to domain managers") + logger.info("Sending email to domain managers") context={ "domain": self.object, } From 9bbcb98071f5c6a9878d989c30f84f5bfe7d0f1e Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 17 Sep 2024 14:22:58 -0500 Subject: [PATCH 009/175] tested up to email sending --- src/registrar/utility/email.py | 79 ++++++++++++++++++---------------- src/registrar/views/domain.py | 69 ++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 42 deletions(-) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 4a53661cf..c1082f50d 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -10,7 +10,6 @@ from django.template.loader import get_template from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from registrar.models.user_domain_role import UserDomainRole from waffle import flag_is_active @@ -26,14 +25,20 @@ class EmailSendingError(RuntimeError): def send_templated_email( template_name: str, subject_template_name: str, - to_address: str, - bcc_address="", + to_address: str="", + bcc_address: str="", context={}, attachment_file=None, wrap_email=False, + cc_addresses: list[str]=[], ): """Send an email built from a template. + to can be either a string representing a single address or a + list of strings for multi-recipient emails. + + bcc currently only supports a single address. + template_name and subject_template_name are relative to the same template context as Django's HTML templates. context gives additional information that the template may use. @@ -46,6 +51,8 @@ def send_templated_email( # Raises an error if we cannot send an email (due to restrictions). # Does nothing otherwise. _can_send_email(to_address, bcc_address) + sendable_cc_addresses = get_sendable_addresses(cc_addresses) + template = get_template(template_name) email_body = template.render(context=context) @@ -70,9 +77,18 @@ def send_templated_email( logger.debug("E-mail unable to send! Could not access the SES client.") raise EmailSendingError("Could not access the SES client.") from exc - destination = {"ToAddresses": [to_address]} + destination = {} + if to_address: + destination["ToAddresses"] = [to_address] if bcc_address: destination["BccAddresses"] = [bcc_address] + if cc_addresses: + destination["CcAddresses"] = sendable_cc_addresses + + # make sure we don't try and send an email to nowhere + if not destination: + message = "E-mail unable to send, no valid recipients provided." + raise EmailSendingError(message) try: if not attachment_file: @@ -105,7 +121,6 @@ def send_templated_email( except Exception as exc: raise EmailSendingError("Could not send SES email.") from exc - def _can_send_email(to_address, bcc_address): """Raises an EmailSendingError if we cannot send an email. Does nothing otherwise.""" @@ -123,6 +138,28 @@ def _can_send_email(to_address, bcc_address): if bcc_address and not AllowedEmail.is_allowed_email(bcc_address): raise EmailSendingError(message.format(bcc_address)) +def get_sendable_addresses(addresses: list[str]) -> list[str]: + """Checks whether a list of addresses can be sent to. + + Returns: a lists of all provided addresses that are ok to send to + + Paramaters: + + addresses: a list of strings representing all addresses to be checked. + + raises: + EmailSendingError if email sending is disabled + """ + + if flag_is_active(None, "disable_email_sending"): # type: ignore + message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'." + raise EmailSendingError(message) + else: + AllowedEmail = apps.get_model("registrar", "AllowedEmail") + allowed_emails = [address for address in addresses if (address and AllowedEmail.is_allowed_email(address))] + + return allowed_emails + def wrap_text_and_preserve_paragraphs(text, width): """ @@ -164,34 +201,4 @@ def send_email_with_attachment(sender, recipient, subject, body, attachment_file msg.attach(attachment_part) response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()}) - return response - -def email_domain_managers(domain, template: str, subject_template: str, context: any = {}): - """Send a single email built from a template to all managers for a given domain. - - template_name and subject_template_name are relative to the same template - context as Django's HTML templates. context gives additional information - that the template may use. - - context is a dictionary containing any information needed to fill in values - in the provided template, exactly the same as with send_templated_email. - - Will log a warning if the email fails to send for any reason, but will not raise an error. - """ - managers = UserDomainRole.objects.filter(domain=domain, role=UserDomainRole.Roles.MANAGER) - emails = list(managers.values_list("user", flat=True).values_list("email", flat=True)) - logger.debug("attempting to send templated email to domain managers") - try: - send_templated_email( - template, - subject_template, - to_address=emails, - context=context, - ) - except EmailSendingError as exc: - logger.warn( - "Could not sent notification email to %s for domain %s", - emails, - domain, - exc_info=True, - ) \ No newline at end of file + return response \ No newline at end of file diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index cc6816121..6b3b6095f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -57,7 +57,7 @@ from epplibwrapper import ( RegistryError, ) -from ..utility.email import send_templated_email, EmailSendingError, email_domain_managers +from ..utility.email import send_templated_email, EmailSendingError from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from waffle.decorators import waffle_flag @@ -149,6 +149,43 @@ class DomainFormBaseView(DomainBaseView, FormMixin): logger.error("Could get domain_info. No domain info exists, or duplicates exist.") return current_domain_info + + def email_domain_managers(self, domain_name, template: str, subject_template: str, context: any = {}): + """Send a single email built from a template to all managers for a given domain. + + template_name and subject_template_name are relative to the same template + context as Django's HTML templates. context gives additional information + that the template may use. + + context is a dictionary containing any information needed to fill in values + in the provided template, exactly the same as with send_templated_email. + + Will log a warning if the email fails to send for any reason, but will not raise an error. + """ + try: + domain = Domain.objects.get(name=domain_name) + except Domain.DoesNotExist: + logger.warn( + "Could not send notification email for domain %s, unable to find matching domain object", + domain_name + ) + manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list("user", flat=True) + emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True)) + logger.debug("attempting to send templated email to domain managers") + try: + send_templated_email( + template, + subject_template, + context=context, + cc_addresses=emails + ) + except EmailSendingError as exc: + logger.warn( + "Could not sent notification email to %s for domain %s", + emails, + domain_name, + exc_info=True, + ) class DomainView(DomainBaseView): @@ -225,6 +262,13 @@ class DomainOrgNameAddressView(DomainFormBaseView): def form_valid(self, form): """The form is valid, save the organization name and mailing address.""" + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + form.save() messages.success(self.request, "The organization information for this domain has been updated.") @@ -325,6 +369,14 @@ class DomainSeniorOfficialView(DomainFormBaseView): # Set the domain information in the form so that it can be accessible # to associate a new Contact, if a new Contact is needed # in the save() method + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + + form.set_domain_info(self.object.domain_info) form.save() @@ -447,10 +499,15 @@ class DomainNameserversView(DomainFormBaseView): pass old_nameservers = self.object.nameservers - logger.info("nameservers", nameservers) - should_notify = old_nameservers and old_nameservers != nameservers - logger.info("should_notify", should_notify) + logger.info("nameservers %s", nameservers) + logger.info("old nameservers: %s", old_nameservers) + + logger.info("State: %s", self.object.state) + + # if there are existing + logger.info("has changed? %s", formset.has_changed()) try: + # logger.info("skipping actual assignment of nameservers") self.object.nameservers = nameservers except NameserverError as Err: # NamserverErrors *should* be caught in form; if reached here, @@ -477,12 +534,12 @@ class DomainNameserversView(DomainFormBaseView): ) # if the nameservers where changed, send notification to domain managers. - if should_notify: + if formset.has_changed(): logger.info("Sending email to domain managers") context={ "domain": self.object, } - email_domain_managers(self.object.name, "emails/domain_change_notification.txt", "emails.domain_change_notification_subject.txt", context) + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) # superclass has the redirect return super().form_valid(formset) From b8d697ebf04879030b3c08f41398bfe441d05d09 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 18 Sep 2024 14:33:44 -0500 Subject: [PATCH 010/175] another stab at email sending --- ops/manifests/manifest-ms.yaml | 2 +- src/registrar/tests/test_emails.py | 16 ++++++++ src/registrar/tests/test_views_domain.py | 48 ++++++++++++++++++++++++ src/registrar/utility/email.py | 13 +++++-- 4 files changed, 74 insertions(+), 5 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/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index e699d9b75..e798a0e8f 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -60,6 +60,22 @@ class TestEmails(TestCase): # Assert that an email wasn't sent self.assertFalse(self.mock_client.send_email.called) + + @boto3_mocking.patching + def test_email_with_cc(self): + """Test sending email with cc works""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + send_templated_email( + "test content", + "test subject", + "doesnotexist@igorville.com", + context={"domain_request": self}, + bcc_address=None, + cc=["test_email1@example.com", "test_email2@example.com"] + ) + + # check that an email was sent + self.assertTrue(self.mock_client.send_email.called) @boto3_mocking.patching @less_console_noise_decorator diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index b096527f9..a4a9ecf96 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2,6 +2,7 @@ from unittest import skip from unittest.mock import MagicMock, ANY, patch from django.conf import settings +from django.test import override_settings from django.urls import reverse from django.contrib.auth import get_user_model from waffle.testutils import override_flag @@ -10,6 +11,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore +from django.middleware.csrf import get_token from registrar.utility.errors import ( NameserverError, @@ -1973,3 +1975,49 @@ class TestDomainDNSSEC(TestDomainOverview): self.assertContains( result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200 ) + + +# class TestDomainChangeNotifications(TestDomainOverview): +# """Test email notifications on updates to domain information""" + +# @classmethod +# def setUpClass(cls): +# super().setUpClass() +# allowed_emails = [ +# AllowedEmail(email="info@example.com"), +# ] +# AllowedEmail.objects.bulk_create(allowed_emails) + +# @classmethod +# def tearDownClass(cls): +# super().tearDownClass() +# AllowedEmail.objects.all().delete() + +# def test_notification_email_sent_on_org_name_change(self): +# """Test that an email is sent when the organization name is changed.""" +# with patch('registrar.utility.email.boto3.client') as mock_boto3_client: +# mock_ses_client = mock_boto3_client.return_value + +# self.domain_information.organization_name = "Town of Igorville" +# self.domain_information.save() + +# org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) +# session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + +# org_name_page.form["organization_name"] = "Not igorville" + +# self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) +# success_result_page = org_name_page.form.submit() + +# # Check that the page loads successfully +# self.assertEqual(success_result_page.status_code, 200) +# self.assertContains(success_result_page, "Not igorville") + +# # Check that an email was sent +# mock_ses_client.send_email.assert_called_once() + +# # Check email content +# call_kwargs = mock_ses_client.send_email.call_args[1] +# self.assertEqual(call_kwargs['FromEmailAddress'], settings.DEFAULT_FROM_EMAIL) +# self.assertIn('Domain information updated', call_kwargs['Content']['Simple']['Subject']['Data']) +# self.assertIn('City of Igorville', call_kwargs['Content']['Simple']['Body']['Text']['Data']) \ No newline at end of file diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index c1082f50d..63d347cae 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -51,7 +51,10 @@ def send_templated_email( # Raises an error if we cannot send an email (due to restrictions). # Does nothing otherwise. _can_send_email(to_address, bcc_address) - sendable_cc_addresses = get_sendable_addresses(cc_addresses) + sendable_cc_addresses, blocked_cc_addresses = get_sendable_addresses(cc_addresses) + + if len(sendable_cc_addresses) < len(cc_addresses): + logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses) template = get_template(template_name) @@ -107,6 +110,7 @@ def send_templated_email( }, }, ) + logger.info("Email sent to %s, bcc %s, cc %s", to_address, bcc_address, cc_addresses) else: ses_client = boto3.client( "ses", @@ -138,10 +142,10 @@ def _can_send_email(to_address, bcc_address): if bcc_address and not AllowedEmail.is_allowed_email(bcc_address): raise EmailSendingError(message.format(bcc_address)) -def get_sendable_addresses(addresses: list[str]) -> list[str]: +def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]: """Checks whether a list of addresses can be sent to. - Returns: a lists of all provided addresses that are ok to send to + Returns: a lists of all provided addresses that are ok to send to and a list of addresses that were blocked. Paramaters: @@ -157,8 +161,9 @@ def get_sendable_addresses(addresses: list[str]) -> list[str]: else: AllowedEmail = apps.get_model("registrar", "AllowedEmail") allowed_emails = [address for address in addresses if (address and AllowedEmail.is_allowed_email(address))] + blocked_emails = [address for address in addresses if (address and not AllowedEmail.is_allowed_email(address))] - return allowed_emails + return allowed_emails, blocked_emails def wrap_text_and_preserve_paragraphs(text, width): From f254e0441fdea44207e16441dd48e143436c260e Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 19 Sep 2024 15:10:00 -0500 Subject: [PATCH 011/175] add email sending to all required forms --- src/registrar/views/domain.py | 37 +++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6b3b6095f..1d693d6e6 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -526,6 +526,14 @@ class DomainNameserversView(DomainFormBaseView): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + + messages.success( self.request, "The name servers for this domain have been updated. " @@ -533,14 +541,6 @@ class DomainNameserversView(DomainFormBaseView): "48 hours to propagate across the internet.", ) - # if the nameservers where changed, send notification to domain managers. - if formset.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - # superclass has the redirect return super().form_valid(formset) @@ -586,6 +586,13 @@ class DomainDNSSECView(DomainFormBaseView): errmsg = "Error removing existing DNSSEC record(s)." logger.error(errmsg + ": " + err) messages.error(self.request, errmsg) + else: + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) return self.form_valid(form) @@ -710,6 +717,13 @@ class DomainDsDataView(DomainFormBaseView): logger.error(f"Registry error: {err}") return self.form_invalid(formset) else: + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + messages.success(self.request, "The DS data records for this domain have been updated.") # superclass has the redirect return super().form_valid(formset) @@ -808,6 +822,13 @@ class DomainSecurityEmailView(DomainFormBaseView): messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)) logger.error(f"Generic registry error: {Err}") else: + if form.has_changed(): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + messages.success(self.request, "The security email for this domain has been updated.") # superclass has the redirect From 4ce28fd18baa4c9afe4bfb54d568541b4916240a Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 19 Sep 2024 15:34:50 -0500 Subject: [PATCH 012/175] test simpler way to organize which emails to send --- src/registrar/views/domain.py | 93 ++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1d693d6e6..8973ec8ec 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -110,6 +110,22 @@ class DomainFormBaseView(DomainBaseView, FormMixin): implementations of post, form_valid and form_invalid. """ + # send notification email for changes to any of these forms + notify_on_change = ( + DomainSecurityEmailForm, + DomainDnssecForm, + DomainDsdataFormset, + ) + + # forms of these types should not send notifications if they're part of a portfolio/Organization + notify_unless_portfolio = ( + DomainOrgNameAddressForm, + SeniorOfficialContactForm + ) + + def should_notify(self, form) -> bool: + return isinstance(form, self.notify_on_change) or isinstance(form, self.notify_unless_portfolio) + def post(self, request, *args, **kwargs): """Form submission posts to this view. @@ -126,6 +142,13 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # updates session cache with domain self._update_session_with_domain() + if self.should_notify(form): + logger.info("Sending email to domain managers") + context={ + "domain": self.object, + } + self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + # superclass has the redirect return super().form_valid(form) @@ -262,13 +285,6 @@ class DomainOrgNameAddressView(DomainFormBaseView): def form_valid(self, form): """The form is valid, save the organization name and mailing address.""" - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - form.save() messages.success(self.request, "The organization information for this domain has been updated.") @@ -369,12 +385,12 @@ class DomainSeniorOfficialView(DomainFormBaseView): # Set the domain information in the form so that it can be accessible # to associate a new Contact, if a new Contact is needed # in the save() method - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + # if form.has_changed(): + # logger.info("Sending email to domain managers") + # context={ + # "domain": self.object, + # } + # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) form.set_domain_info(self.object.domain_info) @@ -526,13 +542,12 @@ class DomainNameserversView(DomainFormBaseView): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - + # if form.has_changed(): + # logger.info("Sending email to domain managers") + # context={ + # "domain": self.object, + # } + # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) messages.success( self.request, @@ -586,13 +601,13 @@ class DomainDNSSECView(DomainFormBaseView): errmsg = "Error removing existing DNSSEC record(s)." logger.error(errmsg + ": " + err) messages.error(self.request, errmsg) - else: - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + # else: + # if form.has_changed(): + # logger.info("Sending email to domain managers") + # context={ + # "domain": self.object, + # } + # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) return self.form_valid(form) @@ -717,12 +732,12 @@ class DomainDsDataView(DomainFormBaseView): logger.error(f"Registry error: {err}") return self.form_invalid(formset) else: - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + # if formset.has_changed(): + # logger.info("Sending email to domain managers") + # context={ + # "domain": self.object, + # } + # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) messages.success(self.request, "The DS data records for this domain have been updated.") # superclass has the redirect @@ -822,12 +837,12 @@ class DomainSecurityEmailView(DomainFormBaseView): messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)) logger.error(f"Generic registry error: {Err}") else: - if form.has_changed(): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + # if form.has_changed(): + # logger.info("Sending email to domain managers") + # context={ + # "domain": self.object, + # } + # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) messages.success(self.request, "The security email for this domain has been updated.") From 2f270cded85e3fdef4089e91fc2c1dc8b7536868 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 19 Sep 2024 16:05:15 -0500 Subject: [PATCH 013/175] cleanup and add email content --- ops/manifests/manifest-ms.yaml | 2 +- src/registrar/fixtures_users.py | 2 -- .../emails/domain_change_notification.txt | 1 - .../domain_change_notification_subject.txt | 1 - .../emails/update_to_approved_domain.txt | 23 +++++++++++++++++++ .../update_to_approved_domain_subject.txt | 1 + src/registrar/views/domain.py | 4 ++++ 7 files changed, 29 insertions(+), 5 deletions(-) delete mode 100644 src/registrar/templates/emails/domain_change_notification.txt delete mode 100644 src/registrar/templates/emails/domain_change_notification_subject.txt create mode 100644 src/registrar/templates/emails/update_to_approved_domain.txt create mode 100644 src/registrar/templates/emails/update_to_approved_domain_subject.txt diff --git a/ops/manifests/manifest-ms.yaml b/ops/manifests/manifest-ms.yaml index ac46f5d92..153ee5f08 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: DEBUG + DJANGO_LOG_LEVEL: INFO # default public site location GETGOV_PUBLIC_SITE_URL: https://get.gov # Flag to disable/enable features in prod environments diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index a1ad0ecf7..1b8eda9ab 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -39,7 +39,6 @@ class UserFixture: "username": "be17c826-e200-4999-9389-2ded48c43691", "first_name": "Matthew", "last_name": "Spence", - "email": "mspence1845@gmail.com" }, { "username": "5f283494-31bd-49b5-b024-a7e7cae00848", @@ -156,7 +155,6 @@ class UserFixture: "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", "first_name": "Matthew-Analyst", "last_name": "Spence-Analyst", - "email": "mspence1845+1@gmail.com" }, { "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844", diff --git a/src/registrar/templates/emails/domain_change_notification.txt b/src/registrar/templates/emails/domain_change_notification.txt deleted file mode 100644 index b3c750257..000000000 --- a/src/registrar/templates/emails/domain_change_notification.txt +++ /dev/null @@ -1 +0,0 @@ -There has been a change to {{ domain }} \ No newline at end of file diff --git a/src/registrar/templates/emails/domain_change_notification_subject.txt b/src/registrar/templates/emails/domain_change_notification_subject.txt deleted file mode 100644 index d3f6fbedb..000000000 --- a/src/registrar/templates/emails/domain_change_notification_subject.txt +++ /dev/null @@ -1 +0,0 @@ -Change Notification \ No newline at end of file diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt new file mode 100644 index 000000000..93ab4819f --- /dev/null +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -0,0 +1,23 @@ +Hi, +An update was made to a domain you manage. +DOMAIN: {{domain.gov}} +UPDATED BY: {{user}} +UPDATED ON: {{date}} +INFORMATION UPDATED: {{changes}} +You can view this update in the .gov registrar . + + +Get help with managing your .gov domain . + +---------------------------------------------------------------- +WHY DID YOU RECEIVE THIS EMAIL? +You’re listed as a domain manager for $domain.gov, so you’ll receive a notification whenever changes are made to that domain. +If you have questions or concerns, reach out to the person who made the change or reply to this email. + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. + +---------------------------------------------------------------- +The .gov team +Contact us +Learn about .gov \ No newline at end of file diff --git a/src/registrar/templates/emails/update_to_approved_domain_subject.txt b/src/registrar/templates/emails/update_to_approved_domain_subject.txt new file mode 100644 index 000000000..cf4c9a14c --- /dev/null +++ b/src/registrar/templates/emails/update_to_approved_domain_subject.txt @@ -0,0 +1 @@ +An update was made to {{domain}} \ No newline at end of file diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 8973ec8ec..895d58090 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -5,6 +5,7 @@ authorized users can see information on a domain, every view here should inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView). """ +from datetime import date import logging from django.contrib import messages @@ -146,6 +147,9 @@ class DomainFormBaseView(DomainBaseView, FormMixin): logger.info("Sending email to domain managers") context={ "domain": self.object, + "user": self.request.user, + "date": date.today(), + "changes": form.changed_data } self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) From e0025dfa4a952ca51747606a1656c49c758bafe7 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 19 Sep 2024 16:18:11 -0500 Subject: [PATCH 014/175] more cleanup --- src/registrar/utility/email.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 63d347cae..35d8a2029 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -34,16 +34,18 @@ def send_templated_email( ): """Send an email built from a template. - to can be either a string representing a single address or a - list of strings for multi-recipient emails. + to_address and bcc_address currently only supports a single address. - bcc currently only supports a single address. + cc_address is a list and can contain many addresses. Emails not in the + whitelist (if applicable) will be filtered out before sending. template_name and subject_template_name are relative to the same template context as Django's HTML templates. context gives additional information that the template may use. - Raises EmailSendingError if SES client could not be accessed + Raises EmailSendingError if: + SES client could not be accessed + No valid recipient addresses are provided """ if not settings.IS_PRODUCTION: # type: ignore @@ -160,8 +162,13 @@ def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]: raise EmailSendingError(message) else: AllowedEmail = apps.get_model("registrar", "AllowedEmail") - allowed_emails = [address for address in addresses if (address and AllowedEmail.is_allowed_email(address))] - blocked_emails = [address for address in addresses if (address and not AllowedEmail.is_allowed_email(address))] + allowed_emails = [] + blocked_emails = [] + for address in addresses: + if AllowedEmail.is_allowed_email(address): + allowed_emails.append(address) + else: + blocked_emails.append(address) return allowed_emails, blocked_emails @@ -206,4 +213,4 @@ def send_email_with_attachment(sender, recipient, subject, body, attachment_file msg.attach(attachment_part) response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()}) - return response \ No newline at end of file + return response From 68b8c7de41b7b6c2bed83aa95006afe252162f21 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 19 Sep 2024 16:41:27 -0500 Subject: [PATCH 015/175] fix email template/subject name --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 895d58090..bd5d60756 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -151,7 +151,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): "date": date.today(), "changes": form.changed_data } - self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) # superclass has the redirect return super().form_valid(form) From 58d42156ba1d0ba14951985826e7abf7c9b2e26c Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 19 Sep 2024 16:57:56 -0500 Subject: [PATCH 016/175] add super call for form valid on security email form --- src/registrar/views/domain.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index bd5d60756..fc7be8fa7 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -143,6 +143,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # updates session cache with domain self._update_session_with_domain() + logger.info("Valid form has changed? %s", form.has_changed()) if self.should_notify(form): logger.info("Sending email to domain managers") context={ @@ -849,6 +850,9 @@ class DomainSecurityEmailView(DomainFormBaseView): # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) messages.success(self.request, "The security email for this domain has been updated.") + + # superclass has the redirect + return super().form_valid(form) # superclass has the redirect return redirect(self.get_success_url()) From 63eea77e802a12f9502d17bd4b22b048a370b3b4 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 20 Sep 2024 13:11:37 -0500 Subject: [PATCH 017/175] another refactor --- src/registrar/views/domain.py | 145 ++++++++++++++++------------------ 1 file changed, 69 insertions(+), 76 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1ab8af816..5d91aadb3 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -109,22 +109,6 @@ class DomainFormBaseView(DomainBaseView, FormMixin): implementations of post, form_valid and form_invalid. """ - # send notification email for changes to any of these forms - notify_on_change = ( - DomainSecurityEmailForm, - DomainDnssecForm, - DomainDsdataFormset, - ) - - # forms of these types should not send notifications if they're part of a portfolio/Organization - notify_unless_portfolio = ( - DomainOrgNameAddressForm, - SeniorOfficialContactForm - ) - - def should_notify(self, form) -> bool: - return isinstance(form, self.notify_on_change) or isinstance(form, self.notify_unless_portfolio) - def post(self, request, *args, **kwargs): """Form submission posts to this view. @@ -141,17 +125,6 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # updates session cache with domain self._update_session_with_domain() - logger.info("Valid form has changed? %s", form.has_changed()) - if self.should_notify(form): - logger.info("Sending email to domain managers") - context={ - "domain": self.object, - "user": self.request.user, - "date": date.today(), - "changes": form.changed_data - } - self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) - # superclass has the redirect return super().form_valid(form) @@ -176,6 +149,63 @@ class DomainFormBaseView(DomainBaseView, FormMixin): return current_domain_info + def send_update_notification(self, form, is_formset: bool=False): + """Send a notification to all domain managers that an update has occured + for a single domain. Uses update_to_approved_domain.txt template. + + Checks for changes, and does nothing if the form has not changed. + + Formsets have to be handled in a special way, so use is_formset to indicate + whether the value passed into form is actually a formset. + """ + + # send notification email for changes to any of these forms + notify_on_change = ( + DomainSecurityEmailForm, + DomainDnssecForm, + DomainDsdataFormset, + ) + + # forms of these types should not send notifications if they're part of a portfolio/Organization + notify_unless_in_portfolio = ( + DomainOrgNameAddressForm, + SeniorOfficialContactForm + ) + + if isinstance(form, notify_on_change): + # always notify for these forms + should_notify=True + elif isinstance(form, notify_unless_in_portfolio): + # for these forms, only notify if the domain is not in a portfolio + info: DomainInformation = self.get_domain_info_from_domain() + if not info or info.portfolio: + should_notify = False + else: + should_notify=True + else: + # don't notify for any other types of forms + should_notify=False + + if should_notify and form.has_changed: + logger.info("Sending email to domain managers") + + changes = self._get_changes_from_formset(form) if is_formset else form.changed_data + + context={ + "domain": self.object.name, + "user": self.request.user, + "date": date.today(), + "changes": str(changes).strip("'") # django templates auto-escape quotes + } + self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) + + def _get_changes_from_formset(self, formset): + changes = set() + for form in formset: + changes.update(form.get_changes) + + return list(changes) + def email_domain_managers(self, domain_name, template: str, subject_template: str, context: any = {}): """Send a single email built from a template to all managers for a given domain. @@ -191,7 +221,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): try: domain = Domain.objects.get(name=domain_name) except Domain.DoesNotExist: - logger.warn( + logger.warning( "Could not send notification email for domain %s, unable to find matching domain object", domain_name ) @@ -206,7 +236,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): cc_addresses=emails ) except EmailSendingError as exc: - logger.warn( + logger.warning( "Could not sent notification email to %s for domain %s", emails, domain_name, @@ -290,6 +320,8 @@ class DomainOrgNameAddressView(DomainFormBaseView): """The form is valid, save the organization name and mailing address.""" form.save() + self.send_update_notification(form) + messages.success(self.request, "The organization information for this domain has been updated.") # superclass has the redirect @@ -387,18 +419,12 @@ class DomainSeniorOfficialView(DomainFormBaseView): # Set the domain information in the form so that it can be accessible # to associate a new Contact, if a new Contact is needed - # in the save() method - # if form.has_changed(): - # logger.info("Sending email to domain managers") - # context={ - # "domain": self.object, - # } - # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - - + # in the save() methodS form.set_domain_info(self.object.domain_info) form.save() + self.send_update_notification(form) + messages.success(self.request, "The senior official for this domain has been updated.") # superclass has the redirect @@ -516,17 +542,7 @@ class DomainNameserversView(DomainFormBaseView): except KeyError: # no server information in this field, skip it pass - - old_nameservers = self.object.nameservers - logger.info("nameservers %s", nameservers) - logger.info("old nameservers: %s", old_nameservers) - - logger.info("State: %s", self.object.state) - - # if there are existing - logger.info("has changed? %s", formset.has_changed()) try: - # logger.info("skipping actual assignment of nameservers") self.object.nameservers = nameservers except NameserverError as Err: # NamserverErrors *should* be caught in form; if reached here, @@ -545,13 +561,7 @@ class DomainNameserversView(DomainFormBaseView): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: - # if form.has_changed(): - # logger.info("Sending email to domain managers") - # context={ - # "domain": self.object, - # } - # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - + self.send_update_notification(formset, is_formset=True) messages.success( self.request, "The name servers for this domain have been updated. " @@ -604,14 +614,8 @@ class DomainDNSSECView(DomainFormBaseView): errmsg = "Error removing existing DNSSEC record(s)." logger.error(errmsg + ": " + err) messages.error(self.request, errmsg) - # else: - # if form.has_changed(): - # logger.info("Sending email to domain managers") - # context={ - # "domain": self.object, - # } - # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - + else: + self.send_update_notification(form) return self.form_valid(form) @@ -735,12 +739,7 @@ class DomainDsDataView(DomainFormBaseView): logger.error(f"Registry error: {err}") return self.form_invalid(formset) else: - # if formset.has_changed(): - # logger.info("Sending email to domain managers") - # context={ - # "domain": self.object, - # } - # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) + self.send_update_notification(formset, is_formset=True) messages.success(self.request, "The DS data records for this domain have been updated.") # superclass has the redirect @@ -808,13 +807,7 @@ class DomainSecurityEmailView(DomainFormBaseView): messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)) logger.error(f"Generic registry error: {Err}") else: - # if form.has_changed(): - # logger.info("Sending email to domain managers") - # context={ - # "domain": self.object, - # } - # self.email_domain_managers(self.object, "emails/domain_change_notification.txt", "emails/domain_change_notification_subject.txt", context) - + self.send_update_notification(form) messages.success(self.request, "The security email for this domain has been updated.") # superclass has the redirect From 07c1060f6e98eaa324a42a03530af74011733057 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 20 Sep 2024 13:19:29 -0500 Subject: [PATCH 018/175] fix minor bug in logger formatting --- src/registrar/config/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 03d9e38c6..1b20caf2a 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -475,6 +475,10 @@ class JsonServerFormatter(ServerFormatter): def format(self, record): formatted_record = super().format(record) + + if not hasattr(record, "server_time"): + record.server_time = self.formatTime(record, self.datefmt) + log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record} return json.dumps(log_entry) From cb7005611a344c69dc48f196c230406b6d18a109 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 20 Sep 2024 13:28:45 -0500 Subject: [PATCH 019/175] typo fix --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 5d91aadb3..89a1338e6 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -202,7 +202,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): def _get_changes_from_formset(self, formset): changes = set() for form in formset: - changes.update(form.get_changes) + changes.update(form.changed_data) return list(changes) From 5dbc356d711d1163c9e611b754b24509afcc0211 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 20 Sep 2024 13:53:43 -0500 Subject: [PATCH 020/175] map forms to labels --- src/registrar/views/domain.py | 53 +++++++++++++---------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 89a1338e6..47f72d0f8 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,39 +149,35 @@ class DomainFormBaseView(DomainBaseView, FormMixin): return current_domain_info - def send_update_notification(self, form, is_formset: bool=False): + def send_update_notification(self, form): """Send a notification to all domain managers that an update has occured for a single domain. Uses update_to_approved_domain.txt template. Checks for changes, and does nothing if the form has not changed. - - Formsets have to be handled in a special way, so use is_formset to indicate - whether the value passed into form is actually a formset. """ # send notification email for changes to any of these forms - notify_on_change = ( - DomainSecurityEmailForm, - DomainDnssecForm, - DomainDsdataFormset, - ) + form_label_dict = { + DomainSecurityEmailForm: "Security Email", + DomainDnssecForm: "DNSSec", + DomainDsdataFormset: "DS Data", + DomainOrgNameAddressForm: "Org Name/Address", + SeniorOfficialContactForm: "Senior Official", + } # forms of these types should not send notifications if they're part of a portfolio/Organization - notify_unless_in_portfolio = ( + check_for_portfolio = { DomainOrgNameAddressForm, - SeniorOfficialContactForm - ) + SeniorOfficialContactForm, + } - if isinstance(form, notify_on_change): - # always notify for these forms + if form.__class__ in form_label_dict: should_notify=True - elif isinstance(form, notify_unless_in_portfolio): - # for these forms, only notify if the domain is not in a portfolio - info: DomainInformation = self.get_domain_info_from_domain() - if not info or info.portfolio: - should_notify = False - else: - should_notify=True + if form.__class__ in check_for_portfolio: + # check for portfolio + info = self.get_domain_info_from_domain() + if not info or info.portfolio: + should_notify = False else: # don't notify for any other types of forms should_notify=False @@ -189,22 +185,13 @@ class DomainFormBaseView(DomainBaseView, FormMixin): if should_notify and form.has_changed: logger.info("Sending email to domain managers") - changes = self._get_changes_from_formset(form) if is_formset else form.changed_data - context={ "domain": self.object.name, "user": self.request.user, "date": date.today(), - "changes": str(changes).strip("'") # django templates auto-escape quotes + "changes": form_label_dict[form.__class__] } self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) - - def _get_changes_from_formset(self, formset): - changes = set() - for form in formset: - changes.update(form.changed_data) - - return list(changes) def email_domain_managers(self, domain_name, template: str, subject_template: str, context: any = {}): """Send a single email built from a template to all managers for a given domain. @@ -561,7 +548,7 @@ class DomainNameserversView(DomainFormBaseView): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: - self.send_update_notification(formset, is_formset=True) + self.send_update_notification(formset) messages.success( self.request, "The name servers for this domain have been updated. " @@ -739,7 +726,7 @@ class DomainDsDataView(DomainFormBaseView): logger.error(f"Registry error: {err}") return self.form_invalid(formset) else: - self.send_update_notification(formset, is_formset=True) + self.send_update_notification(formset) messages.success(self.request, "The DS data records for this domain have been updated.") # superclass has the redirect From ccbefe209c408c1dba14179c327921c234315989 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Tue, 24 Sep 2024 10:11:55 -0500 Subject: [PATCH 021/175] Minor refactor fo update notification and formatting changes --- .../emails/update_to_approved_domain.txt | 9 ++++++--- src/registrar/views/domain.py | 16 +++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 93ab4819f..bc0950808 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -1,6 +1,8 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} + Hi, An update was made to a domain you manage. -DOMAIN: {{domain.gov}} +DOMAIN: {{domain}} UPDATED BY: {{user}} UPDATED ON: {{date}} INFORMATION UPDATED: {{changes}} @@ -11,7 +13,7 @@ Get help with managing your .gov domain -Learn about .gov \ No newline at end of file +Learn about .gov +{% endautoescape %} \ No newline at end of file diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 47f72d0f8..04b740f6e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,11 +149,12 @@ class DomainFormBaseView(DomainBaseView, FormMixin): return current_domain_info - def send_update_notification(self, form): + def send_update_notification(self, form, force_send=False): """Send a notification to all domain managers that an update has occured for a single domain. Uses update_to_approved_domain.txt template. - Checks for changes, and does nothing if the form has not changed. + If there are no changes to the form, emails will NOT be sent unless force_send + is set to True. """ # send notification email for changes to any of these forms @@ -172,9 +173,10 @@ class DomainFormBaseView(DomainBaseView, FormMixin): } if form.__class__ in form_label_dict: + # these types of forms can cause notifications should_notify=True if form.__class__ in check_for_portfolio: - # check for portfolio + # some forms shouldn't cause notifications if they are in a portfolio info = self.get_domain_info_from_domain() if not info or info.portfolio: should_notify = False @@ -182,7 +184,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # don't notify for any other types of forms should_notify=False - if should_notify and form.has_changed: + if (should_notify and form.has_changed()) or force_send: logger.info("Sending email to domain managers") context={ @@ -305,10 +307,10 @@ class DomainOrgNameAddressView(DomainFormBaseView): def form_valid(self, form): """The form is valid, save the organization name and mailing address.""" - form.save() - self.send_update_notification(form) + form.save() + messages.success(self.request, "The organization information for this domain has been updated.") # superclass has the redirect @@ -602,7 +604,7 @@ class DomainDNSSECView(DomainFormBaseView): logger.error(errmsg + ": " + err) messages.error(self.request, errmsg) else: - self.send_update_notification(form) + self.send_update_notification(form, force_send=True) return self.form_valid(form) From 89f19bf80718b1f913182edf1c7ad60379f05307 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 24 Sep 2024 10:23:51 -0500 Subject: [PATCH 022/175] uncomment test --- src/registrar/tests/test_views_domain.py | 69 ++++++++++++------------ 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index f0aea8588..109b80913 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1968,47 +1968,46 @@ class TestDomainDNSSEC(TestDomainOverview): ) -# class TestDomainChangeNotifications(TestDomainOverview): -# """Test email notifications on updates to domain information""" +class TestDomainChangeNotifications(TestDomainOverview): + """Test email notifications on updates to domain information""" -# @classmethod -# def setUpClass(cls): -# super().setUpClass() -# allowed_emails = [ -# AllowedEmail(email="info@example.com"), -# ] -# AllowedEmail.objects.bulk_create(allowed_emails) + @classmethod + def setUpClass(cls): + super().setUpClass() + allowed_emails = [ + AllowedEmail(email="info@example.com"), + ] + AllowedEmail.objects.bulk_create(allowed_emails) -# @classmethod -# def tearDownClass(cls): -# super().tearDownClass() -# AllowedEmail.objects.all().delete() + @classmethod + def tearDownClass(cls): + super().tearDownClass() + AllowedEmail.objects.all().delete() -# def test_notification_email_sent_on_org_name_change(self): -# """Test that an email is sent when the organization name is changed.""" -# with patch('registrar.utility.email.boto3.client') as mock_boto3_client: -# mock_ses_client = mock_boto3_client.return_value + def test_notification_email_sent_on_org_name_change(self): + """Test that an email is sent when the organization name is changed.""" + with patch('registrar.utility.email.boto3.client') as mock_boto3_client: + mock_ses_client = mock_boto3_client.return_value -# self.domain_information.organization_name = "Town of Igorville" -# self.domain_information.save() + self.domain_information.organization_name = "Town of Igorville" + self.domain_information.save() -# org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) -# session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] -# org_name_page.form["organization_name"] = "Not igorville" + org_name_page.form["organization_name"] = "Not igorville" -# self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) -# success_result_page = org_name_page.form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result_page = org_name_page.form.submit() + # Check that the page loads successfully + self.assertEqual(success_result_page.status_code, 200) + self.assertContains(success_result_page, "Not igorville") -# # Check that the page loads successfully -# self.assertEqual(success_result_page.status_code, 200) -# self.assertContains(success_result_page, "Not igorville") - -# # Check that an email was sent -# mock_ses_client.send_email.assert_called_once() + # Check that an email was sent + mock_ses_client.send_email.assert_called_once() -# # Check email content -# call_kwargs = mock_ses_client.send_email.call_args[1] -# self.assertEqual(call_kwargs['FromEmailAddress'], settings.DEFAULT_FROM_EMAIL) -# self.assertIn('Domain information updated', call_kwargs['Content']['Simple']['Subject']['Data']) -# self.assertIn('City of Igorville', call_kwargs['Content']['Simple']['Body']['Text']['Data']) \ No newline at end of file + # Check email content + call_kwargs = mock_ses_client.send_email.call_args[1] + self.assertEqual(call_kwargs['FromEmailAddress'], settings.DEFAULT_FROM_EMAIL) + self.assertIn('DOMAIN: Igorville.gov', call_kwargs['Content']['Simple']['Subject']['Data']) + self.assertIn('INFORMATION UPDATED: Org Name/Address', call_kwargs['Content']['Simple']['Body']['Text']['Data']) \ No newline at end of file From 9de6831591f657d80487aeddb54bd2f4cebbda07 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 24 Sep 2024 10:48:23 -0500 Subject: [PATCH 023/175] tests --- src/registrar/tests/test_views_domain.py | 49 +++++++++++++++--------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 109b80913..43ea9bdd4 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1978,36 +1978,47 @@ class TestDomainChangeNotifications(TestDomainOverview): AllowedEmail(email="info@example.com"), ] AllowedEmail.objects.bulk_create(allowed_emails) + + def setUp(self): + super().setUp() + self.mock_client_class = MagicMock() + self.mock_client = self.mock_client_class.return_value @classmethod def tearDownClass(cls): super().tearDownClass() AllowedEmail.objects.all().delete() + @boto3_mocking.patching def test_notification_email_sent_on_org_name_change(self): """Test that an email is sent when the organization name is changed.""" - with patch('registrar.utility.email.boto3.client') as mock_boto3_client: - mock_ses_client = mock_boto3_client.return_value - self.domain_information.organization_name = "Town of Igorville" - self.domain_information.save() - - org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.domain_information.organization_name = "Town of Igorville" + self.domain_information.save() + + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - org_name_page.form["organization_name"] = "Not igorville" + org_name_page.form["organization_name"] = "Not igorville" - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): success_result_page = org_name_page.form.submit() # Check that the page loads successfully - self.assertEqual(success_result_page.status_code, 200) - self.assertContains(success_result_page, "Not igorville") + self.assertEqual(success_result_page.status_code, 200) + self.assertContains(success_result_page, "Not igorville") - # Check that an email was sent - mock_ses_client.send_email.assert_called_once() - - # Check email content - call_kwargs = mock_ses_client.send_email.call_args[1] - self.assertEqual(call_kwargs['FromEmailAddress'], settings.DEFAULT_FROM_EMAIL) - self.assertIn('DOMAIN: Igorville.gov', call_kwargs['Content']['Simple']['Subject']['Data']) - self.assertIn('INFORMATION UPDATED: Org Name/Address', call_kwargs['Content']['Simple']['Body']['Text']['Data']) \ No newline at end of file + # Check that an email was sent + self.assertTrue(self.mock_client.send_email.called) + # Check email content + # check the call sequence for the email + args, kwargs = self.mock_client.send_email.call_args + self.assertIn("Content", kwargs) + self.assertIn("Simple", kwargs["Content"]) + self.assertIn("Subject", kwargs["Content"]["Simple"]) + self.assertIn("Body", kwargs["Content"]["Simple"]) + + # check for things in the email content (not an exhaustive list) + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertIn("DOMAIN:", body) From ab4024bab509b54670f413b3945d18c0cd36cfab Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 25 Sep 2024 15:24:21 -0500 Subject: [PATCH 024/175] all tests passing --- src/registrar/templates/domain_users.html | 6 +- src/registrar/tests/test_emails.py | 2 +- src/registrar/tests/test_views_domain.py | 231 +++++++++++++++++++++- src/registrar/views/domain.py | 18 +- 4 files changed, 237 insertions(+), 20 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 412f4ee73..1b789e590 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,8 +8,7 @@

Domain managers can update all information related to a domain within the - .gov registrar, including contact details, senior official, security - email, and DNS name servers. + .gov registrar, including, security email and DNS name servers.

    @@ -17,7 +16,8 @@
  • After adding a domain manager, an email invitation will be sent to that user with instructions on how to set up an account.
  • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
  • -
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.
  • +
  • All domain managers will be notified when updates are made to this domain.
  • +
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
{% if domain.permissions %} diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index 87e2d551f..21ba06316 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -71,7 +71,7 @@ class TestEmails(TestCase): "doesnotexist@igorville.com", context={"domain_request": self}, bcc_address=None, - cc=["test_email1@example.com", "test_email2@example.com"] + cc_addresses=["test_email1@example.com", "test_email2@example.com"] ) # check that an email was sent diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 43ea9bdd4..d258dc472 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from registrar.utility.email import send_templated_email from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -67,6 +68,10 @@ class TestWithDomainPermissions(TestWithUser): datetime.combine(date.today() + timedelta(days=1), datetime.min.time()) ), ) + self.domain_dns_needed, _ = Domain.objects.get_or_create( + name="dns-needed.gov", + state=Domain.State.DNS_NEEDED, + ) self.domain_deleted, _ = Domain.objects.get_or_create( name="deleted.gov", state=Domain.State.DELETED, @@ -85,6 +90,12 @@ class TestWithDomainPermissions(TestWithUser): self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) + self.security_contact, _ = PublicContact.objects.get_or_create( + domain=self.domain, + contact_type=PublicContact.ContactTypeChoices.SECURITY, + email="security@igorville.gov", + ) + DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_multdsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dnssec_none) @@ -93,6 +104,8 @@ class TestWithDomainPermissions(TestWithUser): DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted) + DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed) + self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER @@ -101,6 +114,9 @@ class TestWithDomainPermissions(TestWithUser): UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.MANAGER ) + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_dns_needed, role=UserDomainRole.Roles.MANAGER + ) UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain_multdsdata, @@ -1976,6 +1992,7 @@ class TestDomainChangeNotifications(TestDomainOverview): super().setUpClass() allowed_emails = [ AllowedEmail(email="info@example.com"), + AllowedEmail(email="doesnotexist@igorville.com"), ] AllowedEmail.objects.bulk_create(allowed_emails) @@ -1990,10 +2007,15 @@ class TestDomainChangeNotifications(TestDomainOverview): AllowedEmail.objects.all().delete() @boto3_mocking.patching - def test_notification_email_sent_on_org_name_change(self): + @less_console_noise_decorator + def test_notification_on_org_name_change(self): """Test that an email is sent when the organization name is changed.""" self.domain_information.organization_name = "Town of Igorville" + self.domain_information.address_line1 = "123 Main St" + self.domain_information.city = "Igorville" + self.domain_information.state_territory = "IL" + self.domain_information.zipcode = "62052" self.domain_information.save() org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) @@ -2003,22 +2025,215 @@ class TestDomainChangeNotifications(TestDomainOverview): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - success_result_page = org_name_page.form.submit() - # Check that the page loads successfully - self.assertEqual(success_result_page.status_code, 200) - self.assertContains(success_result_page, "Not igorville") + org_name_page.form.submit() # Check that an email was sent self.assertTrue(self.mock_client.send_email.called) + # Check email content # check the call sequence for the email - args, kwargs = self.mock_client.send_email.call_args + _, kwargs = self.mock_client.send_email.call_args self.assertIn("Content", kwargs) self.assertIn("Simple", kwargs["Content"]) self.assertIn("Subject", kwargs["Content"]["Simple"]) self.assertIn("Body", kwargs["Content"]["Simple"]) - # check for things in the email content (not an exhaustive list) body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] - self.assertIn("DOMAIN:", body) + self.assertIn("DOMAIN: igorville.gov", body) + self.assertIn("UPDATED BY: First Last info@example.com", body) + self.assertIn("INFORMATION UPDATED: Org Name/Address", body) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_no_notification_on_org_name_change_with_portfolio(self): + """Test that an email is not sent on org name change when the domain is in a portfolio""" + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + + self.domain_information.organization_name = "Town of Igorville" + self.domain_information.address_line1 = "123 Main St" + self.domain_information.city = "Igorville" + self.domain_information.state_territory = "IL" + self.domain_information.zipcode = "62052" + self.domain_information.portfolio = portfolio + self.domain_information.save() + + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + org_name_page.form["organization_name"] = "Not igorville" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + org_name_page.form.submit() + + # Check that an email was not sent + self.assertFalse(self.mock_client.send_email.called) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_notification_on_security_email_change(self): + """Test that an email is sent when the security email is changed.""" + + security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + security_email_page.form["security_email"] = "new_security@example.com" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + security_email_page.form.submit() + + self.assertTrue(self.mock_client.send_email.called) + + _, kwargs = self.mock_client.send_email.call_args + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertIn("DOMAIN: igorville.gov", body) + self.assertIn("UPDATED BY: First Last info@example.com", body) + self.assertIn("INFORMATION UPDATED: Security Email", body) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_notification_on_dnssec_enable(self): + """Test that an email is sent when DNSSEC is enabled.""" + + page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id})) + self.assertContains(page, "Disable DNSSEC") + + # Prepare the data for the POST request + post_data = { + "disable_dnssec": "Disable DNSSEC", + } + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + updated_page = self.client.post( + reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}), + post_data, + follow=True, + ) + + self.assertEqual(updated_page.status_code, 200) + + self.assertContains(updated_page, "Enable DNSSEC") + + self.assertTrue(self.mock_client.send_email.called) + + _, kwargs = self.mock_client.send_email.call_args + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertIn("DOMAIN: igorville.gov", body) + self.assertIn("UPDATED BY: First Last info@example.com", body) + self.assertIn("INFORMATION UPDATED: DNSSec", body) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_notification_on_ds_data_change(self): + """Test that an email is sent when DS data is changed.""" + + ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + # Add DS data + ds_data_page.forms[0]["form-0-key_tag"] = "12345" + ds_data_page.forms[0]["form-0-algorithm"] = "13" + ds_data_page.forms[0]["form-0-digest_type"] = "2" + ds_data_page.forms[0]["form-0-digest"] = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + ds_data_page.forms[0].submit() + + # check that the email was sent + self.assertTrue(self.mock_client.send_email.called) + + # check some stuff about the email + _, kwargs = self.mock_client.send_email.call_args + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertIn("DOMAIN: igorville.gov", body) + self.assertIn("UPDATED BY: First Last info@example.com", body) + self.assertIn("INFORMATION UPDATED: DS Data", body) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_notification_on_senior_official_change(self): + """Test that an email is sent when the senior official information is changed.""" + + self.domain_information.senior_official = Contact.objects.create( + first_name="Old", last_name="Official", title="Manager", email="old_official@example.com" + ) + self.domain_information.save() + + senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + senior_official_page.form["first_name"] = "New" + senior_official_page.form["last_name"] = "Official" + senior_official_page.form["title"] = "Director" + senior_official_page.form["email"] = "new_official@example.com" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + senior_official_page.form.submit() + + self.assertTrue(self.mock_client.send_email.called) + + _, kwargs = self.mock_client.send_email.call_args + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertIn("DOMAIN: igorville.gov", body) + self.assertIn("UPDATED BY: First Last info@example.com", body) + self.assertIn("INFORMATION UPDATED: Senior Official", body) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_no_notification_on_senior_official_when_portfolio(self): + """Test that an email is not sent when the senior official information is changed + and the domain is in a portfolio.""" + + self.domain_information.senior_official = Contact.objects.create( + first_name="Old", last_name="Official", title="Manager", email="old_official@example.com" + ) + portfolio, _ =Portfolio.objects.get_or_create( + organization_name="portfolio", + creator=self.user, + ) + self.domain_information.portfolio = portfolio + self.domain_information.save() + + senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + senior_official_page.form["first_name"] = "New" + senior_official_page.form["last_name"] = "Official" + senior_official_page.form["title"] = "Director" + senior_official_page.form["email"] = "new_official@example.com" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + senior_official_page.form.submit() + + self.assertFalse(self.mock_client.send_email.called) + + @boto3_mocking.patching + @less_console_noise_decorator + def test_no_notification_when_dns_needed(self): + """Test that an email is not sent when nameservers are changed while the state is DNS_NEEDED.""" + + nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain_dns_needed.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + # add nameservers + nameservers_page.form["form-0-server"] = "ns1-new.igorville.gov" + nameservers_page.form["form-0-ip"] = "192.168.1.1" + nameservers_page.form["form-1-server"] = "ns2-new.igorville.gov" + nameservers_page.form["form-1-ip"] = "192.168.1.2" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + nameservers_page.form.submit() + + # Check that an email was not sent + self.assertFalse(self.mock_client.send_email.called) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 04b740f6e..22ece989b 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -119,6 +119,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): if form.is_valid(): return self.form_valid(form) else: + logger.debug(f"Form errors: {form.errors}") return self.form_invalid(form) def form_valid(self, form): @@ -164,6 +165,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): DomainDsdataFormset: "DS Data", DomainOrgNameAddressForm: "Org Name/Address", SeniorOfficialContactForm: "Senior Official", + NameserverFormset: "Nameservers", } # forms of these types should not send notifications if they're part of a portfolio/Organization @@ -179,14 +181,13 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # some forms shouldn't cause notifications if they are in a portfolio info = self.get_domain_info_from_domain() if not info or info.portfolio: + logger.info(f"Not notifying because of portfolio") should_notify = False else: # don't notify for any other types of forms should_notify=False - + logger.info(f"Not notifying for {form.__class__}") if (should_notify and form.has_changed()) or force_send: - logger.info("Sending email to domain managers") - context={ "domain": self.object.name, "user": self.request.user, @@ -194,6 +195,8 @@ class DomainFormBaseView(DomainBaseView, FormMixin): "changes": form_label_dict[form.__class__] } self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) + else: + logger.info(f"Not notifying for {form.__class__}, form changes: {form.has_changed()}, force_send: {force_send}") def email_domain_managers(self, domain_name, template: str, subject_template: str, context: any = {}): """Send a single email built from a template to all managers for a given domain. @@ -489,8 +492,7 @@ class DomainNameserversView(DomainFormBaseView): This post method harmonizes using DomainBaseView and FormMixin """ - - logger.info("Posted to Namservers View") + logger.info(f"POST request to DomainNameserversView") self._get_domain(request) formset = self.get_form() @@ -500,6 +502,7 @@ class DomainNameserversView(DomainFormBaseView): return HttpResponseRedirect(url) if formset.is_valid(): + logger.info(f"Formset is valid") return self.form_valid(formset) else: return self.form_invalid(formset) @@ -507,8 +510,6 @@ class DomainNameserversView(DomainFormBaseView): def form_valid(self, formset): """The formset is valid, perform something with it.""" - logger.info("------ Nameserver Form is valid -------") - self.request.session["nameservers_form_domain"] = self.object # Set the nameservers from the formset @@ -550,7 +551,8 @@ class DomainNameserversView(DomainFormBaseView): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: - self.send_update_notification(formset) + if self.object.state == Domain.State.READY: + self.send_update_notification(formset) messages.success( self.request, "The name servers for this domain have been updated. " From 43b48edf3639c22bd3a29439c1a23636671a8296 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 25 Sep 2024 15:31:27 -0500 Subject: [PATCH 025/175] linter errors --- src/registrar/config/settings.py | 2 +- src/registrar/tests/test_emails.py | 18 +++---- src/registrar/tests/test_views_domain.py | 60 +++++++++++----------- src/registrar/utility/email.py | 17 ++++--- src/registrar/views/domain.py | 63 ++++++++++++------------ 5 files changed, 78 insertions(+), 82 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 1b20caf2a..c5d4fa95d 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -478,7 +478,7 @@ class JsonServerFormatter(ServerFormatter): if not hasattr(record, "server_time"): record.server_time = self.formatTime(record, self.datefmt) - + log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record} return json.dumps(log_entry) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index 21ba06316..abbbb274f 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -60,20 +60,20 @@ class TestEmails(TestCase): # Assert that an email wasn't sent self.assertFalse(self.mock_client.send_email.called) - + @boto3_mocking.patching def test_email_with_cc(self): """Test sending email with cc works""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): send_templated_email( - "test content", - "test subject", - "doesnotexist@igorville.com", - context={"domain_request": self}, - bcc_address=None, - cc_addresses=["test_email1@example.com", "test_email2@example.com"] - ) - + "test content", + "test subject", + "doesnotexist@igorville.com", + context={"domain_request": self}, + bcc_address=None, + cc_addresses=["test_email1@example.com", "test_email2@example.com"], + ) + # check that an email was sent self.assertTrue(self.mock_client.send_email.called) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index d258dc472..6ca96f8c0 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2,17 +2,14 @@ from unittest import skip from unittest.mock import MagicMock, ANY, patch from django.conf import settings -from django.test import override_settings from django.urls import reverse from django.contrib.auth import get_user_model from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices -from registrar.utility.email import send_templated_email from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore -from django.middleware.csrf import get_token from registrar.utility.errors import ( NameserverError, @@ -106,7 +103,6 @@ class TestWithDomainPermissions(TestWithUser): DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed) - self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER ) @@ -1995,7 +1991,7 @@ class TestDomainChangeNotifications(TestDomainOverview): AllowedEmail(email="doesnotexist@igorville.com"), ] AllowedEmail.objects.bulk_create(allowed_emails) - + def setUp(self): super().setUp() self.mock_client_class = MagicMock() @@ -2010,14 +2006,14 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_notification_on_org_name_change(self): """Test that an email is sent when the organization name is changed.""" - + self.domain_information.organization_name = "Town of Igorville" self.domain_information.address_line1 = "123 Main St" self.domain_information.city = "Igorville" self.domain_information.state_territory = "IL" self.domain_information.zipcode = "62052" self.domain_information.save() - + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2028,8 +2024,8 @@ class TestDomainChangeNotifications(TestDomainOverview): org_name_page.form.submit() # Check that an email was sent - self.assertTrue(self.mock_client.send_email.called) - + self.assertTrue(self.mock_client.send_email.called) + # Check email content # check the call sequence for the email _, kwargs = self.mock_client.send_email.call_args @@ -2048,7 +2044,7 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_no_notification_on_org_name_change_with_portfolio(self): """Test that an email is not sent on org name change when the domain is in a portfolio""" - + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) self.domain_information.organization_name = "Town of Igorville" @@ -2058,7 +2054,7 @@ class TestDomainChangeNotifications(TestDomainOverview): self.domain_information.zipcode = "62052" self.domain_information.portfolio = portfolio self.domain_information.save() - + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2069,13 +2065,13 @@ class TestDomainChangeNotifications(TestDomainOverview): org_name_page.form.submit() # Check that an email was not sent - self.assertFalse(self.mock_client.send_email.called) + self.assertFalse(self.mock_client.send_email.called) @boto3_mocking.patching @less_console_noise_decorator def test_notification_on_security_email_change(self): """Test that an email is sent when the security email is changed.""" - + security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2085,8 +2081,8 @@ class TestDomainChangeNotifications(TestDomainOverview): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): security_email_page.form.submit() - self.assertTrue(self.mock_client.send_email.called) - + self.assertTrue(self.mock_client.send_email.called) + _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] @@ -2098,7 +2094,7 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_notification_on_dnssec_enable(self): """Test that an email is sent when DNSSEC is enabled.""" - + page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id})) self.assertContains(page, "Disable DNSSEC") @@ -2118,8 +2114,8 @@ class TestDomainChangeNotifications(TestDomainOverview): self.assertContains(updated_page, "Enable DNSSEC") - self.assertTrue(self.mock_client.send_email.called) - + self.assertTrue(self.mock_client.send_email.called) + _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] @@ -2131,7 +2127,7 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_notification_on_ds_data_change(self): """Test that an email is sent when DS data is changed.""" - + ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2140,14 +2136,14 @@ class TestDomainChangeNotifications(TestDomainOverview): ds_data_page.forms[0]["form-0-algorithm"] = "13" ds_data_page.forms[0]["form-0-digest_type"] = "2" ds_data_page.forms[0]["form-0-digest"] = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF" - + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): ds_data_page.forms[0].submit() # check that the email was sent - self.assertTrue(self.mock_client.send_email.called) - + self.assertTrue(self.mock_client.send_email.called) + # check some stuff about the email _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] @@ -2160,12 +2156,12 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_notification_on_senior_official_change(self): """Test that an email is sent when the senior official information is changed.""" - + self.domain_information.senior_official = Contact.objects.create( first_name="Old", last_name="Official", title="Manager", email="old_official@example.com" ) self.domain_information.save() - + senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2178,8 +2174,8 @@ class TestDomainChangeNotifications(TestDomainOverview): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): senior_official_page.form.submit() - self.assertTrue(self.mock_client.send_email.called) - + self.assertTrue(self.mock_client.send_email.called) + _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] @@ -2192,17 +2188,17 @@ class TestDomainChangeNotifications(TestDomainOverview): def test_no_notification_on_senior_official_when_portfolio(self): """Test that an email is not sent when the senior official information is changed and the domain is in a portfolio.""" - + self.domain_information.senior_official = Contact.objects.create( first_name="Old", last_name="Official", title="Manager", email="old_official@example.com" - ) - portfolio, _ =Portfolio.objects.get_or_create( + ) + portfolio, _ = Portfolio.objects.get_or_create( organization_name="portfolio", creator=self.user, ) self.domain_information.portfolio = portfolio self.domain_information.save() - + senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -2216,12 +2212,12 @@ class TestDomainChangeNotifications(TestDomainOverview): senior_official_page.form.submit() self.assertFalse(self.mock_client.send_email.called) - + @boto3_mocking.patching @less_console_noise_decorator def test_no_notification_when_dns_needed(self): """Test that an email is not sent when nameservers are changed while the state is DNS_NEEDED.""" - + nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain_dns_needed.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 35d8a2029..655c432ac 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -22,15 +22,15 @@ class EmailSendingError(RuntimeError): pass -def send_templated_email( +def send_templated_email( # noqa template_name: str, subject_template_name: str, - to_address: str="", - bcc_address: str="", + to_address: str = "", + bcc_address: str = "", context={}, attachment_file=None, wrap_email=False, - cc_addresses: list[str]=[], + cc_addresses: list[str] = [], ): """Send an email built from a template. @@ -58,7 +58,6 @@ def send_templated_email( if len(sendable_cc_addresses) < len(cc_addresses): logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses) - template = get_template(template_name) email_body = template.render(context=context) @@ -127,6 +126,7 @@ def send_templated_email( except Exception as exc: raise EmailSendingError("Could not send SES email.") from exc + def _can_send_email(to_address, bcc_address): """Raises an EmailSendingError if we cannot send an email. Does nothing otherwise.""" @@ -144,15 +144,16 @@ def _can_send_email(to_address, bcc_address): if bcc_address and not AllowedEmail.is_allowed_email(bcc_address): raise EmailSendingError(message.format(bcc_address)) + def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]: """Checks whether a list of addresses can be sent to. - + Returns: a lists of all provided addresses that are ok to send to and a list of addresses that were blocked. Paramaters: - + addresses: a list of strings representing all addresses to be checked. - + raises: EmailSendingError if email sending is disabled """ diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 22ece989b..a3f9d153c 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,18 +149,18 @@ class DomainFormBaseView(DomainBaseView, FormMixin): logger.error("Could get domain_info. No domain info exists, or duplicates exist.") return current_domain_info - + def send_update_notification(self, form, force_send=False): - """Send a notification to all domain managers that an update has occured + """Send a notification to all domain managers that an update has occured for a single domain. Uses update_to_approved_domain.txt template. - + If there are no changes to the form, emails will NOT be sent unless force_send is set to True. """ # send notification email for changes to any of these forms form_label_dict = { - DomainSecurityEmailForm: "Security Email", + DomainSecurityEmailForm: "Security Email", DomainDnssecForm: "DNSSec", DomainDsdataFormset: "DS Data", DomainOrgNameAddressForm: "Org Name/Address", @@ -176,29 +176,35 @@ class DomainFormBaseView(DomainBaseView, FormMixin): if form.__class__ in form_label_dict: # these types of forms can cause notifications - should_notify=True + should_notify = True if form.__class__ in check_for_portfolio: # some forms shouldn't cause notifications if they are in a portfolio info = self.get_domain_info_from_domain() if not info or info.portfolio: - logger.info(f"Not notifying because of portfolio") - should_notify = False + should_notify = False else: # don't notify for any other types of forms - should_notify=False + should_notify = False logger.info(f"Not notifying for {form.__class__}") if (should_notify and form.has_changed()) or force_send: - context={ - "domain": self.object.name, - "user": self.request.user, - "date": date.today(), - "changes": form_label_dict[form.__class__] - } - self.email_domain_managers(self.object, "emails/update_to_approved_domain.txt", "emails/update_to_approved_domain_subject.txt", context) + context = { + "domain": self.object.name, + "user": self.request.user, + "date": date.today(), + "changes": form_label_dict[form.__class__], + } + self.email_domain_managers( + self.object, + "emails/update_to_approved_domain.txt", + "emails/update_to_approved_domain_subject.txt", + context, + ) else: - logger.info(f"Not notifying for {form.__class__}, form changes: {form.has_changed()}, force_send: {force_send}") + logger.info( + f"Not notifying for {form.__class__}, form changes: {form.has_changed()}, force_send: {force_send}" + ) - def email_domain_managers(self, domain_name, template: str, subject_template: str, context: any = {}): + def email_domain_managers(self, domain_name, template: str, subject_template: str, context={}): """Send a single email built from a template to all managers for a given domain. template_name and subject_template_name are relative to the same template @@ -214,20 +220,16 @@ class DomainFormBaseView(DomainBaseView, FormMixin): domain = Domain.objects.get(name=domain_name) except Domain.DoesNotExist: logger.warning( - "Could not send notification email for domain %s, unable to find matching domain object", - domain_name + "Could not send notification email for domain %s, unable to find matching domain object", domain_name ) - manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list("user", flat=True) + manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list( + "user", flat=True + ) emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True)) logger.debug("attempting to send templated email to domain managers") try: - send_templated_email( - template, - subject_template, - context=context, - cc_addresses=emails - ) - except EmailSendingError as exc: + send_templated_email(template, subject_template, context=context, cc_addresses=emails) + except EmailSendingError: logger.warning( "Could not sent notification email to %s for domain %s", emails, @@ -492,8 +494,6 @@ class DomainNameserversView(DomainFormBaseView): This post method harmonizes using DomainBaseView and FormMixin """ - logger.info(f"POST request to DomainNameserversView") - self._get_domain(request) formset = self.get_form() @@ -502,14 +502,13 @@ class DomainNameserversView(DomainFormBaseView): return HttpResponseRedirect(url) if formset.is_valid(): - logger.info(f"Formset is valid") return self.form_valid(formset) else: return self.form_invalid(formset) def form_valid(self, formset): """The formset is valid, perform something with it.""" - + self.request.session["nameservers_form_domain"] = self.object # Set the nameservers from the formset @@ -800,7 +799,7 @@ class DomainSecurityEmailView(DomainFormBaseView): else: self.send_update_notification(form) messages.success(self.request, "The security email for this domain has been updated.") - + # superclass has the redirect return super().form_valid(form) From 7a4ed7ab059e6ae6688fcdc84617b7852557b46e Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 25 Sep 2024 15:44:49 -0500 Subject: [PATCH 026/175] minor email refactor --- src/registrar/utility/email.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 655c432ac..fc7d4f956 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -76,7 +76,7 @@ def send_templated_email( # noqa aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=settings.BOTO_CONFIG, ) - logger.info(f"An email was sent! Template name: {template_name} to {to_address}") + logger.info(f"Connected to SES client! Template name: {template_name} to {to_address}") except Exception as exc: logger.debug("E-mail unable to send! Could not access the SES client.") raise EmailSendingError("Could not access the SES client.") from exc @@ -153,14 +153,12 @@ def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]: Paramaters: addresses: a list of strings representing all addresses to be checked. - - raises: - EmailSendingError if email sending is disabled """ if flag_is_active(None, "disable_email_sending"): # type: ignore message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'." - raise EmailSendingError(message) + logger.warning(message) + return ([],[]) else: AllowedEmail = apps.get_model("registrar", "AllowedEmail") allowed_emails = [] From 0c9db26a575fcae2ec5f9b239e5bd69ebe66e33f Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 25 Sep 2024 16:05:12 -0500 Subject: [PATCH 027/175] fix email test --- src/registrar/tests/test_emails.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index abbbb274f..c3a84d22f 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -66,10 +66,10 @@ class TestEmails(TestCase): """Test sending email with cc works""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): send_templated_email( - "test content", - "test subject", + "emails/update_to_approved_domain.txt", + "emails/update_to_approved_domain_subject.txt", "doesnotexist@igorville.com", - context={"domain_request": self}, + context={"domain": "test", "user": "test", "date": 1, "changes": "test"}, bcc_address=None, cc_addresses=["test_email1@example.com", "test_email2@example.com"], ) From 2e3ff9e547a11300b8719d8bf5bf442b7620ec87 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 26 Sep 2024 10:58:39 -0500 Subject: [PATCH 028/175] fix tests --- src/registrar/models/domain_request.py | 6 ++-- src/registrar/tests/test_emails.py | 46 +++++++++++++++++------- src/registrar/tests/test_models.py | 8 ++--- src/registrar/tests/test_views_domain.py | 2 ++ src/registrar/utility/email.py | 6 ++-- src/registrar/views/domain.py | 2 +- 6 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 161d85ae5..fdba309f3 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -781,7 +781,7 @@ class DomainRequest(TimeStampedModel): if custom_email_content: context["custom_email_content"] = custom_email_content - + logger.info(f"Sending email to: {recipient.email}") send_templated_email( email_template, email_template_subject, @@ -823,11 +823,12 @@ class DomainRequest(TimeStampedModel): # requested_domain could be None here if not hasattr(self, "requested_domain") or self.requested_domain is None: raise ValueError("Requested domain is missing.") + logger.info(f"Submitting domain request: {self.requested_domain.name}") DraftDomain = apps.get_model("registrar.DraftDomain") if not DraftDomain.string_could_be_domain(self.requested_domain.name): raise ValueError("Requested domain is not a valid domain name.") - + logger.info(f"Draft Domain") # if the domain has not been submitted before this must be the first time if not self.first_submitted_date: self.first_submitted_date = timezone.now().date() @@ -835,6 +836,7 @@ class DomainRequest(TimeStampedModel): # Update last_submitted_date to today self.last_submitted_date = timezone.now().date() self.save() + logger.info(f"updated submission date") # Limit email notifications to transitions from Started and Withdrawn limited_statuses = [self.DomainRequestStatus.STARTED, self.DomainRequestStatus.WITHDRAWN] diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index c3a84d22f..3b1b45e98 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -71,7 +71,7 @@ class TestEmails(TestCase): "doesnotexist@igorville.com", context={"domain": "test", "user": "test", "date": 1, "changes": "test"}, bcc_address=None, - cc_addresses=["test_email1@example.com", "test_email2@example.com"], + cc_addresses=["testy2@town.com", "mayor@igorville.gov"], ) # check that an email was sent @@ -81,7 +81,7 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation(self): """Submission confirmation email works.""" - domain_request = completed_domain_request() + domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com")) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() @@ -118,7 +118,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_no_current_website_spacing(self): """Test line spacing without current_website.""" - domain_request = completed_domain_request(has_current_website=False) + domain_request = completed_domain_request( + has_current_website=False, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -131,7 +133,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_current_website_spacing(self): """Test line spacing with current_website.""" - domain_request = completed_domain_request(has_current_website=True) + domain_request = completed_domain_request( + has_current_website=True, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -148,7 +152,11 @@ class TestEmails(TestCase): # Create fake creator _creator = User.objects.create( - username="MrMeoward", first_name="Meoward", last_name="Jones", phone="(888) 888 8888" + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + phone="(888) 888 8888", + email="testy@town.com", ) # Create a fake domain request @@ -165,7 +173,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_no_other_contacts_spacing(self): """Test line spacing without other contacts.""" - domain_request = completed_domain_request(has_other_contacts=False) + domain_request = completed_domain_request( + has_other_contacts=False, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -177,7 +187,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_alternative_govdomain_spacing(self): """Test line spacing with alternative .gov domain.""" - domain_request = completed_domain_request(has_alternative_gov_domain=True) + domain_request = completed_domain_request( + has_alternative_gov_domain=True, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -190,7 +202,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_no_alternative_govdomain_spacing(self): """Test line spacing without alternative .gov domain.""" - domain_request = completed_domain_request(has_alternative_gov_domain=False) + domain_request = completed_domain_request( + has_alternative_gov_domain=False, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -203,7 +217,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_about_your_organization_spacing(self): """Test line spacing with about your organization.""" - domain_request = completed_domain_request(has_about_your_organization=True) + domain_request = completed_domain_request( + has_about_your_organization=True, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -216,7 +232,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_no_about_your_organization_spacing(self): """Test line spacing without about your organization.""" - domain_request = completed_domain_request(has_about_your_organization=False) + domain_request = completed_domain_request( + has_about_your_organization=False, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -229,7 +247,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_anything_else_spacing(self): """Test line spacing with anything else.""" - domain_request = completed_domain_request(has_anything_else=True) + domain_request = completed_domain_request( + has_anything_else=True, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -241,7 +261,9 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_no_anything_else_spacing(self): """Test line spacing without anything else.""" - domain_request = completed_domain_request(has_anything_else=False) + domain_request = completed_domain_request( + has_anything_else=False, user=User.objects.create(username="test", email="testy@town.com") + ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index a6e889503..8c9a888c2 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -268,7 +268,7 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_submit_from_withdrawn_sends_email(self): msg = "Create a withdrawn domain request and submit it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") + user, _ = User.objects.get_or_create(username="testy", email="testy@town.com") domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user) self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email) @@ -287,14 +287,14 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_approve_sends_email(self): msg = "Create a domain request and approve it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") + user, _ = User.objects.get_or_create(username="testy", email="testy@town.com") domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email) @less_console_noise_decorator def test_withdraw_sends_email(self): msg = "Create a domain request and withdraw it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") + user, _ = User.objects.get_or_create(username="testy", email="testy@town.com") domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) self.check_email_sent( domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email @@ -303,7 +303,7 @@ class TestDomainRequest(TestCase): @less_console_noise_decorator def test_reject_sends_email(self): msg = "Create a domain request and reject it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") + user, _ = User.objects.get_or_create(username="testy", email="testy@town.com") domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 6ca96f8c0..7e9f5b9b8 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -250,6 +250,7 @@ class TestDomainDetail(TestDomainOverview): # At the time of this test's writing, there are 6 UNKNOWN domains inherited # from constructors. Let's reset. with less_console_noise(): + PublicContact.objects.all().delete() Domain.objects.all().delete() UserDomainRole.objects.all().delete() @@ -2002,6 +2003,7 @@ class TestDomainChangeNotifications(TestDomainOverview): super().tearDownClass() AllowedEmail.objects.all().delete() + @boto3_mocking.patching @less_console_noise_decorator def test_notification_on_org_name_change(self): diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index fc7d4f956..412838d10 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -34,7 +34,7 @@ def send_templated_email( # noqa ): """Send an email built from a template. - to_address and bcc_address currently only supports a single address. + to_address and bcc_address currently only support single addresses. cc_address is a list and can contain many addresses. Emails not in the whitelist (if applicable) will be filtered out before sending. @@ -111,7 +111,7 @@ def send_templated_email( # noqa }, }, ) - logger.info("Email sent to %s, bcc %s, cc %s", to_address, bcc_address, cc_addresses) + logger.info("Email sent to %s, bcc %s, cc %s", to_address, bcc_address, sendable_cc_addresses) else: ses_client = boto3.client( "ses", @@ -158,7 +158,7 @@ def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]: if flag_is_active(None, "disable_email_sending"): # type: ignore message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'." logger.warning(message) - return ([],[]) + return ([], []) else: AllowedEmail = apps.get_model("registrar", "AllowedEmail") allowed_emails = [] diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index a3f9d153c..9f6662291 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -413,7 +413,7 @@ class DomainSeniorOfficialView(DomainFormBaseView): # Set the domain information in the form so that it can be accessible # to associate a new Contact, if a new Contact is needed - # in the save() methodS + # in the save() method form.set_domain_info(self.object.domain_info) form.save() From 7d9eabb7271be811b79874e8421859592bb4d03b Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 26 Sep 2024 11:20:18 -0500 Subject: [PATCH 029/175] linter errors --- src/registrar/models/domain_request.py | 2 -- src/registrar/tests/test_views_domain.py | 1 - 2 files changed, 3 deletions(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index fdba309f3..08a431967 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -828,7 +828,6 @@ class DomainRequest(TimeStampedModel): DraftDomain = apps.get_model("registrar.DraftDomain") if not DraftDomain.string_could_be_domain(self.requested_domain.name): raise ValueError("Requested domain is not a valid domain name.") - logger.info(f"Draft Domain") # if the domain has not been submitted before this must be the first time if not self.first_submitted_date: self.first_submitted_date = timezone.now().date() @@ -836,7 +835,6 @@ class DomainRequest(TimeStampedModel): # Update last_submitted_date to today self.last_submitted_date = timezone.now().date() self.save() - logger.info(f"updated submission date") # Limit email notifications to transitions from Started and Withdrawn limited_statuses = [self.DomainRequestStatus.STARTED, self.DomainRequestStatus.WITHDRAWN] diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 7e9f5b9b8..939cdaaf9 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2003,7 +2003,6 @@ class TestDomainChangeNotifications(TestDomainOverview): super().tearDownClass() AllowedEmail.objects.all().delete() - @boto3_mocking.patching @less_console_noise_decorator def test_notification_on_org_name_change(self): From caad00df188983273e8628d7e677e0e162cf1720 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:24:29 -0700 Subject: [PATCH 030/175] Block users invited to other orgs from being domain managers --- src/registrar/views/domain.py | 56 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index db0572bb3..dae3b60cd 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -21,8 +21,10 @@ from registrar.models import ( DomainRequest, DomainInformation, DomainInvitation, + PortfolioInvitation, User, UserDomainRole, + UserPortfolioPermission, PublicContact, ) from registrar.utility.enums import DefaultEmail @@ -38,6 +40,7 @@ from registrar.utility.errors import ( ) from registrar.models.utility.contact_error import ContactError from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView +from registrar.utility.waffle import flag_is_active_for_user from ..forms import ( SeniorOfficialContactForm, @@ -778,7 +781,14 @@ class DomainAddUserView(DomainFormBaseView): """Get an absolute URL for this domain.""" return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) - def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True): + def _is_member_of_different_org(self, email, org): + """Verifies if an email belongs to a different organization as a member or invited member.""" + # Check if user is a member of a different organization + existing_org_permission = UserPortfolioPermission.objects.get(email=email) + print("Existing org permission: ", existing_org_permission) + return True + + def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): """Performs the sending of the domain invitation email, does not make a domain information object email: string- email to send to @@ -803,6 +813,26 @@ class DomainAddUserView(DomainFormBaseView): ) return None + # Check is user is a member or invited member of a different org from this domain's org + print("org feature flag is active: ", flag_is_active_for_user(requestor, "organization_feature")) + if flag_is_active_for_user(requestor, "organization_feature"): + # Check if invited user is a member from a different org from this domain's org + existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() + print("Existing org permission for requested email: ", existing_org_permission) + + existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() + requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio + print("Requestor org: ", requestor_org) + if (existing_org_permission and existing_org_permission.portfolio != requestor_org) or \ + (existing_org_invitation and existing_org_invitation.portfolio != requestor_org): + add_success=False + messages.error( + self.request, + f"That email is already a member of another .gov organization.", + ) + raise Exception + + # Check to see if an invite has already been sent try: invite = DomainInvitation.objects.get(email=email, domain=self.object) @@ -868,7 +898,7 @@ class DomainAddUserView(DomainFormBaseView): else: # if user already exists then just send an email try: - self._send_domain_invitation_email(requested_email, requestor, add_success=False) + self._send_domain_invitation_email(requested_email, requestor, requested_user=requested_user, add_success=False) except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -883,17 +913,17 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - else: - messages.success(self.request, f"Added user {requested_email}.") + else: + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + else: + messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) From 42de7f2bb79e76b9efcb242da853ed036c3959bc Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:40:36 -0700 Subject: [PATCH 031/175] Add domain manager breadcrumb nav --- src/registrar/templates/domain_add_user.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index b2f9fef24..320404fa9 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -4,6 +4,19 @@ {% block title %}Add a domain manager | {% endblock %} {% block domain_content %} + {% block breadcrumb %} + {% url 'domain-users' pk=domain.id as url %} + + {% endblock breadcrumb %}

Add a domain manager

You can add another user to help manage your domain. They will need to sign From 8b61eb1275f2d340c458898d22baccb1a7c53b4a Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:43:32 -0700 Subject: [PATCH 032/175] Update add domain manager page content --- src/registrar/templates/domain_add_user.html | 4 ++-- src/registrar/views/domain.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index 320404fa9..e95bacd76 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -19,8 +19,8 @@ {% endblock breadcrumb %}

Add a domain manager

-

You can add another user to help manage your domain. They will need to sign - in to the .gov registrar with their Login.gov account. +

You can add another user to help manage your domain. If they aren't an organization member they will + need to sign in to the .gov registrar with their Login.gov account.

diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index dae3b60cd..c3bbe037a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -823,15 +823,15 @@ class DomainAddUserView(DomainFormBaseView): existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio print("Requestor org: ", requestor_org) - if (existing_org_permission and existing_org_permission.portfolio != requestor_org) or \ - (existing_org_invitation and existing_org_invitation.portfolio != requestor_org): - add_success=False + if (existing_org_permission and existing_org_permission.portfolio != requestor_org) or ( + existing_org_invitation and existing_org_invitation.portfolio != requestor_org + ): + add_success = False messages.error( self.request, f"That email is already a member of another .gov organization.", ) raise Exception - # Check to see if an invite has already been sent try: @@ -898,7 +898,9 @@ class DomainAddUserView(DomainFormBaseView): else: # if user already exists then just send an email try: - self._send_domain_invitation_email(requested_email, requestor, requested_user=requested_user, add_success=False) + self._send_domain_invitation_email( + requested_email, requestor, requested_user=requested_user, add_success=False + ) except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", From 65d89872f2b49a9ce796a5ec8295342cfce55bac Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 26 Sep 2024 16:43:03 -0500 Subject: [PATCH 033/175] remove extra logging statements --- src/registrar/models/domain_request.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 08a431967..2aecb49f7 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -781,7 +781,6 @@ class DomainRequest(TimeStampedModel): if custom_email_content: context["custom_email_content"] = custom_email_content - logger.info(f"Sending email to: {recipient.email}") send_templated_email( email_template, email_template_subject, @@ -823,7 +822,6 @@ class DomainRequest(TimeStampedModel): # requested_domain could be None here if not hasattr(self, "requested_domain") or self.requested_domain is None: raise ValueError("Requested domain is missing.") - logger.info(f"Submitting domain request: {self.requested_domain.name}") DraftDomain = apps.get_model("registrar.DraftDomain") if not DraftDomain.string_could_be_domain(self.requested_domain.name): From b0fe698af2e1d6ae5bf586ff2345b48f7a75f98c Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:45:10 -0700 Subject: [PATCH 034/175] Add error code for outside org members being added --- src/registrar/utility/errors.py | 8 ++++ src/registrar/views/domain.py | 66 ++++++++++++++++----------------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 8cb83c0ee..6a75091a6 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -23,6 +23,14 @@ class InvalidDomainError(ValueError): pass +class OutsideOrgMemberError(ValueError): + """ + Error raised when an org member tries adding a user from a different .gov org. + To be deleted when users can be members of multiple orgs. + """ + + pass + class ActionNotAllowed(Exception): """User accessed an action that is not allowed by the current state""" diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index c3bbe037a..92da57e06 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -35,6 +35,7 @@ from registrar.utility.errors import ( NameserverErrorCodes as nsErrorCodes, DsDataError, DsDataErrorCodes, + OutsideOrgMemberError, SecurityEmailError, SecurityEmailErrorCodes, ) @@ -781,12 +782,15 @@ class DomainAddUserView(DomainFormBaseView): """Get an absolute URL for this domain.""" return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) - def _is_member_of_different_org(self, email, org): + def _is_member_of_different_org(self, email, requested_user, org): """Verifies if an email belongs to a different organization as a member or invited member.""" - # Check if user is a member of a different organization - existing_org_permission = UserPortfolioPermission.objects.get(email=email) - print("Existing org permission: ", existing_org_permission) - return True + # Check if user is a already member of a different organization than the given org + existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() + existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() + + return (existing_org_permission and existing_org_permission.portfolio != org) or ( + existing_org_invitation and existing_org_invitation.portfolio != org + ) def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): """Performs the sending of the domain invitation email, @@ -814,24 +818,16 @@ class DomainAddUserView(DomainFormBaseView): return None # Check is user is a member or invited member of a different org from this domain's org - print("org feature flag is active: ", flag_is_active_for_user(requestor, "organization_feature")) if flag_is_active_for_user(requestor, "organization_feature"): # Check if invited user is a member from a different org from this domain's org - existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() - print("Existing org permission for requested email: ", existing_org_permission) - - existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio - print("Requestor org: ", requestor_org) - if (existing_org_permission and existing_org_permission.portfolio != requestor_org) or ( - existing_org_invitation and existing_org_invitation.portfolio != requestor_org - ): + if self._is_member_of_different_org(email, requested_user, requestor_org): add_success = False messages.error( self.request, - f"That email is already a member of another .gov organization.", + "That email is already a member of another .gov organization.", ) - raise Exception + raise OutsideOrgMemberError # Check to see if an invite has already been sent try: @@ -847,10 +843,7 @@ class DomainAddUserView(DomainFormBaseView): add_success = False # else if it has been sent but not accepted messages.warning(self.request, f"{email} has already been invited to this domain") - except Exception: - logger.error("An error occured") - try: send_templated_email( "emails/domain_invitation.txt", "emails/domain_invitation_subject.txt", @@ -861,6 +854,11 @@ class DomainAddUserView(DomainFormBaseView): "requestor_email": requestor_email, }, ) + + if add_success: + messages.success(self.request, f"{email} has been invited to this domain.") + except Exception: + logger.error("An error occured") except EmailSendingError as exc: logger.warn( "Could not sent email invitation to %s for domain %s", @@ -869,9 +867,6 @@ class DomainAddUserView(DomainFormBaseView): 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 to this domain.") def _make_invitation(self, email_address: str, requestor: User): """Make a Domain invitation for this email and redirect with a message.""" @@ -901,6 +896,14 @@ class DomainAddUserView(DomainFormBaseView): self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) + + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + + messages.success(self.request, f"Added user {requested_email}.") except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -908,6 +911,12 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + except OutsideOrgMemberError: + logger.warn( + "Could not send email invitation to a user in a different org.", + self.object, + exc_info=True, + ) except Exception: logger.warn( "Could not send email invitation (Other Exception)", @@ -915,17 +924,8 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - else: - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - else: - messages.success(self.request, f"Added user {requested_email}.") + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") return redirect(self.get_success_url()) From d71069606b77bfb5e593d2f01659c89ffb732f7c Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:51:50 -0700 Subject: [PATCH 035/175] Fix linting. Revert to original email sending logic --- src/registrar/utility/errors.py | 1 + src/registrar/views/domain.py | 56 +++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 6a75091a6..f12aba221 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -31,6 +31,7 @@ class OutsideOrgMemberError(ValueError): pass + class ActionNotAllowed(Exception): """User accessed an action that is not allowed by the current state""" diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 92da57e06..77c02d990 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -35,9 +35,9 @@ from registrar.utility.errors import ( NameserverErrorCodes as nsErrorCodes, DsDataError, DsDataErrorCodes, - OutsideOrgMemberError, SecurityEmailError, SecurityEmailErrorCodes, + OutsideOrgMemberError, ) from registrar.models.utility.contact_error import ContactError from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView @@ -859,6 +859,10 @@ class DomainAddUserView(DomainFormBaseView): messages.success(self.request, f"{email} has been invited to this domain.") except Exception: logger.error("An error occured") + except OutsideOrgMemberError: + logger.error( + "Could not send email. Can not invite member of a .gov organization to a different organization." + ) except EmailSendingError as exc: logger.warn( "Could not sent email invitation to %s for domain %s", @@ -868,6 +872,29 @@ class DomainAddUserView(DomainFormBaseView): ) raise EmailSendingError("Could not send email invitation.") from exc + try: + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email, + context={ + "domain_url": self._domain_abs_url(), + "domain": 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 to this domain.") + def _make_invitation(self, email_address: str, requestor: User): """Make a Domain invitation for this email and redirect with a message.""" try: @@ -896,14 +923,6 @@ class DomainAddUserView(DomainFormBaseView): self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) - - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - - messages.success(self.request, f"Added user {requested_email}.") except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -911,12 +930,6 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - except OutsideOrgMemberError: - logger.warn( - "Could not send email invitation to a user in a different org.", - self.object, - exc_info=True, - ) except Exception: logger.warn( "Could not send email invitation (Other Exception)", @@ -924,8 +937,17 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") + else: + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + else: + messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) From d66ff330572280fd7b6f935dcca32317e23ca2db Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:52:59 -0700 Subject: [PATCH 036/175] Readd outside org member error handling --- src/registrar/views/domain.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 77c02d990..02019c601 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -930,6 +930,12 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + except OutsideOrgMemberError: + logger.warn( + "Could not send email. Can not invite member of a .gov organization to a different organization.", + self.object, + exc_info=True, + ) except Exception: logger.warn( "Could not send email invitation (Other Exception)", From 3d1781c4f657aa1152ad88b0df15a1c7a99c34d0 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:07:50 -0700 Subject: [PATCH 037/175] Fix linting --- src/registrar/views/domain.py | 59 ++++++++++------------------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 02019c601..c2ca65bab 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -782,14 +782,15 @@ class DomainAddUserView(DomainFormBaseView): """Get an absolute URL for this domain.""" return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) - def _is_member_of_different_org(self, email, requested_user, org): + def _is_member_of_different_org(self, email, requestor, requested_user): """Verifies if an email belongs to a different organization as a member or invited member.""" - # Check if user is a already member of a different organization than the given org + # Check if user is a already member of a different organization than the requestor's org + requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() - return (existing_org_permission and existing_org_permission.portfolio != org) or ( - existing_org_invitation and existing_org_invitation.portfolio != org + return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or ( + existing_org_invitation and existing_org_invitation.portfolio != requestor_org ) def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): @@ -818,16 +819,15 @@ class DomainAddUserView(DomainFormBaseView): return None # Check is user is a member or invited member of a different org from this domain's org - if flag_is_active_for_user(requestor, "organization_feature"): - # Check if invited user is a member from a different org from this domain's org - requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio - if self._is_member_of_different_org(email, requested_user, requestor_org): - add_success = False - messages.error( - self.request, - "That email is already a member of another .gov organization.", - ) - raise OutsideOrgMemberError + if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org( + email, requestor, requested_user + ): + add_success = False + messages.error( + self.request, + "That email is already a member of another .gov organization.", + ) + raise OutsideOrgMemberError # Check to see if an invite has already been sent try: @@ -843,34 +843,8 @@ class DomainAddUserView(DomainFormBaseView): add_success = False # else if it has been sent but not accepted messages.warning(self.request, f"{email} has already been invited to this domain") - - send_templated_email( - "emails/domain_invitation.txt", - "emails/domain_invitation_subject.txt", - to_address=email, - context={ - "domain_url": self._domain_abs_url(), - "domain": self.object, - "requestor_email": requestor_email, - }, - ) - - if add_success: - messages.success(self.request, f"{email} has been invited to this domain.") except Exception: logger.error("An error occured") - except OutsideOrgMemberError: - logger.error( - "Could not send email. Can not invite member of a .gov organization to a different organization." - ) - 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 try: send_templated_email( @@ -883,6 +857,8 @@ class DomainAddUserView(DomainFormBaseView): "requestor_email": requestor_email, }, ) + if add_success: + messages.success(self.request, f"{email} has been invited to this domain.") except EmailSendingError as exc: logger.warn( "Could not sent email invitation to %s for domain %s", @@ -891,9 +867,6 @@ class DomainAddUserView(DomainFormBaseView): 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 to this domain.") def _make_invitation(self, email_address: str, requestor: User): """Make a Domain invitation for this email and redirect with a message.""" From 117900cfb9f12d4ca65f007d212d5337acc4d2ce Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:24:58 -0700 Subject: [PATCH 038/175] Revert to try else catch --- src/registrar/views/domain.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index c2ca65bab..5d7a840c7 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -857,8 +857,6 @@ class DomainAddUserView(DomainFormBaseView): "requestor_email": requestor_email, }, ) - if add_success: - messages.success(self.request, f"{email} has been invited to this domain.") except EmailSendingError as exc: logger.warn( "Could not sent email invitation to %s for domain %s", @@ -867,6 +865,9 @@ class DomainAddUserView(DomainFormBaseView): 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 to this domain.") def _make_invitation(self, email_address: str, requestor: User): """Make a Domain invitation for this email and redirect with a message.""" From 9940b1657e983828f937255117537ffb9fb3dacb Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Wed, 2 Oct 2024 11:26:12 -0500 Subject: [PATCH 039/175] Update src/registrar/utility/email.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/utility/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 412838d10..ecae7ed93 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -91,7 +91,7 @@ def send_templated_email( # noqa # make sure we don't try and send an email to nowhere if not destination: - message = "E-mail unable to send, no valid recipients provided." + message = "Email unable to send, no valid recipients provided." raise EmailSendingError(message) try: From f26db7185b3a1a01822ccaa5bce684b131ab2c43 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 2 Oct 2024 11:54:29 -0500 Subject: [PATCH 040/175] review changes --- src/registrar/tests/test_emails.py | 31 ++++++++++++++++++++++++ src/registrar/tests/test_views_domain.py | 8 ++---- src/registrar/utility/email.py | 13 ++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index 3b1b45e98..e76a6124f 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -77,6 +77,37 @@ class TestEmails(TestCase): # check that an email was sent self.assertTrue(self.mock_client.send_email.called) + # check the call sequence for the email + args, kwargs = self.mock_client.send_email.call_args + self.assertIn("Destination", kwargs) + self.assertIn("CcAddresses", kwargs["Destination"]) + + self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"]) + + @boto3_mocking.patching + @override_settings(IS_PRODUCTION=True) + def test_email_with_cc_in_prod(self): + """Test sending email with cc works in prod""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + send_templated_email( + "emails/update_to_approved_domain.txt", + "emails/update_to_approved_domain_subject.txt", + "doesnotexist@igorville.com", + context={"domain": "test", "user": "test", "date": 1, "changes": "test"}, + bcc_address=None, + cc_addresses=["testy2@town.com", "mayor@igorville.gov"], + ) + + # check that an email was sent + self.assertTrue(self.mock_client.send_email.called) + + # check the call sequence for the email + args, kwargs = self.mock_client.send_email.call_args + self.assertIn("Destination", kwargs) + self.assertIn("CcAddresses", kwargs["Destination"]) + + self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"]) + @boto3_mocking.patching @less_console_noise_decorator def test_submission_confirmation(self): diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 939cdaaf9..15e21169e 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -87,12 +87,6 @@ class TestWithDomainPermissions(TestWithUser): self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) - self.security_contact, _ = PublicContact.objects.get_or_create( - domain=self.domain, - contact_type=PublicContact.ContactTypeChoices.SECURITY, - email="security@igorville.gov", - ) - DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_multdsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dnssec_none) @@ -2007,6 +2001,8 @@ class TestDomainChangeNotifications(TestDomainOverview): @less_console_noise_decorator def test_notification_on_org_name_change(self): """Test that an email is sent when the organization name is changed.""" + # We may end up sending emails on org name changes later, but it will be addressed + # in the portfolio itself, rather than the individual domain. self.domain_information.organization_name = "Town of Igorville" self.domain_information.address_line1 = "123 Main St" diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 412838d10..6c5f5f172 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -48,14 +48,19 @@ def send_templated_email( # noqa No valid recipient addresses are provided """ + # by default assume we can send to all addresses (prod has no whitelist) + sendable_cc_addresses = cc_addresses + if not settings.IS_PRODUCTION: # type: ignore # Split into a function: C901 'send_templated_email' is too complex. # Raises an error if we cannot send an email (due to restrictions). # Does nothing otherwise. _can_send_email(to_address, bcc_address) + + # if we're not in prod, we need to check the whitelist for CC'ed addresses sendable_cc_addresses, blocked_cc_addresses = get_sendable_addresses(cc_addresses) - if len(sendable_cc_addresses) < len(cc_addresses): + if blocked_cc_addresses: logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses) template = get_template(template_name) @@ -111,7 +116,7 @@ def send_templated_email( # noqa }, }, ) - logger.info("Email sent to %s, bcc %s, cc %s", to_address, bcc_address, sendable_cc_addresses) + logger.info("Email sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses) else: ses_client = boto3.client( "ses", @@ -123,6 +128,10 @@ def send_templated_email( # noqa send_email_with_attachment( settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client ) + logger.info( + "Email with attachment sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses + ) + except Exception as exc: raise EmailSendingError("Could not send SES email.") from exc From a42ea0a19133ed61af7cc146928d4b0d16a9ead9 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Wed, 2 Oct 2024 15:13:31 -0500 Subject: [PATCH 041/175] Update src/registrar/views/domain.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/views/domain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 9f6662291..88da7320a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -226,7 +226,6 @@ class DomainFormBaseView(DomainBaseView, FormMixin): "user", flat=True ) emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True)) - logger.debug("attempting to send templated email to domain managers") try: send_templated_email(template, subject_template, context=context, cc_addresses=emails) except EmailSendingError: From 50ca20fe576001f924d3c617c100733642687772 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 2 Oct 2024 15:15:59 -0500 Subject: [PATCH 042/175] minor fixes --- src/registrar/views/domain.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 9f6662291..206509e07 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -204,7 +204,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): f"Not notifying for {form.__class__}, form changes: {form.has_changed()}, force_send: {force_send}" ) - def email_domain_managers(self, domain_name, template: str, subject_template: str, context={}): + def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}): """Send a single email built from a template to all managers for a given domain. template_name and subject_template_name are relative to the same template @@ -216,12 +216,6 @@ class DomainFormBaseView(DomainBaseView, FormMixin): Will log a warning if the email fails to send for any reason, but will not raise an error. """ - try: - domain = Domain.objects.get(name=domain_name) - except Domain.DoesNotExist: - logger.warning( - "Could not send notification email for domain %s, unable to find matching domain object", domain_name - ) manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list( "user", flat=True ) @@ -233,7 +227,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): logger.warning( "Could not sent notification email to %s for domain %s", emails, - domain_name, + domain.name, exc_info=True, ) From ee59c6a074c67b8686154b2a8d1a58b18c79ecbe Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 3 Oct 2024 02:03:12 -0600 Subject: [PATCH 043/175] Updated view only verbage. Tooltip mods --- src/registrar/assets/js/uswds-edited.js | 24 ++++++++++++++---- .../assets/sass/_theme/_tooltips.scss | 25 +++++++++++++++++++ src/registrar/templates/domain_detail.html | 3 ++- src/registrar/tests/test_views_domain.py | 2 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index e73f3b6c0..c01aabea3 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5938,6 +5938,10 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { return offset; }; + const element_is_fixed_positioned = false + const parentRect = tooltipTrigger.getBoundingClientRect(); + const element_left = element_is_fixed_positioned ? parentRect.left + parentRect.width : `50%` + /** * Positions tooltip at the top * @param {HTMLElement} e - this is the tooltip body @@ -5949,7 +5953,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("top"); - e.style.left = `50%`; // center the element + e.style.left = element_left; // center the element e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element // apply our margins based on the offset e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`; @@ -5963,7 +5967,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { resetPositionStyles(e); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("bottom"); - e.style.left = `50%`; + e.style.left = element_left; e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`; }; @@ -5975,7 +5979,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { resetPositionStyles(e); const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); setPositionClass("right"); - e.style.top = `50%`; + e.style.top = element_left; e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; e.style.margin = `-${topMargin / 2}px 0 0 0`; }; @@ -5991,7 +5995,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { // we have to check for some utility margins const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger); setPositionClass("left"); - e.style.top = `50%`; + e.style.top = element_left; e.style.left = `-${TRIANGLE_SIZE}px`; e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin }; @@ -6017,6 +6021,10 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { if (i < positions.length) { const pos = positions[i]; pos(element); + + const rect = element.getBoundingClientRect(); + console.log("***RECTANGLE**** "+rect.width); + if (!isElementInViewport(element)) { // eslint-disable-next-line no-param-reassign tryPositions(i += 1); @@ -6128,7 +6136,13 @@ const setUpAttributes = tooltipTrigger => { tooltipBody.setAttribute("aria-hidden", "true"); // place the text in the tooltip - tooltipBody.textContent = tooltipContent; + // DOTGOV: nest elements for tooltip to prevent clipping (works around viewport calcs) + tooltipBody.innerHTML = ` +

+ ${tooltipContent} +

` + // tooltipBody.textContent = tooltipContent; + return { tooltipBody, position, diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 3ab630dc0..dc1309ad2 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -28,3 +28,28 @@ #extended-logo .usa-tooltip__body { font-weight: 400 !important; } + +.usa-tooltip__body > p { + margin-top: 0; + width: 40vw; + text-wrap: wrap; + text-align: center; + font-size: .9rem; + margin-block-start: 0em; + margin-block-end: 0em; + max-width: fit-content; + @include at-media('tablet') { + width: 70vw; + } +} + +.usa-tooltip__body { + white-space: inherit; + max-width: fit-content; + // position: fixed; +} + +.usa-tooltip__body--wrap { + min-width: inherit; + width: inherit; +} \ No newline at end of file diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index dca68f6ef..5cb559a5a 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -45,7 +45,8 @@

- To manage information for this domain, you must add yourself as a domain manager. + You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers. + Alternatively, an admin for your organization can assign this domain to you by updating your member permissions.

diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 8fb92df72..aa2fc10c0 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -340,7 +340,7 @@ class TestDomainDetail(TestDomainOverview): detail_page = self.client.get(f"/domain/{domain.id}") # Check that alert message displays properly self.assertContains( - detail_page, "To manage information for this domain, you must add yourself as a domain manager." + detail_page, "You don't have access to manage "+domain.name+". If you need to make updates, contact one of the listed domain managers." ) # Check that user does not have option to Edit domain self.assertNotContains(detail_page, "Edit") From 64b7a5a953a5715ac5dd7ef4a4c261b3c542f7db Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 3 Oct 2024 02:45:52 -0600 Subject: [PATCH 044/175] Fixed position mode (works around overflow constraints with scrollable containers) --- src/registrar/assets/js/uswds-edited.js | 26 ++++++++++++++----- .../assets/sass/_theme/_tooltips.scss | 2 +- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index c01aabea3..928e3fc2b 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5938,9 +5938,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { return offset; }; - const element_is_fixed_positioned = false + const style = window.getComputedStyle(tooltipBody); + // Check if the position property is 'fixed' + if (style.position === 'fixed') { + console.log('The element has a fixed position.'); + } else { + console.log('The element does not have a fixed position.'); + } + const element_is_fixed_positioned = style.position === 'fixed'; const parentRect = tooltipTrigger.getBoundingClientRect(); - const element_left = element_is_fixed_positioned ? parentRect.left + parentRect.width : `50%` + const element_left = element_is_fixed_positioned ? parentRect.left + parentRect.width/2 + 'px': `50%` + const element_top = element_is_fixed_positioned ? parentRect.top + parentRect.height/2 + 'px': `50%` /** * Positions tooltip at the top @@ -5954,7 +5962,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("top"); e.style.left = element_left; // center the element - e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element + e.style.top = element_is_fixed_positioned ?`${parentRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element // apply our margins based on the offset e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`; }; @@ -5967,6 +5975,9 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { resetPositionStyles(e); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("bottom"); + if (element_is_fixed_positioned){ + e.style.top = parentRect.bottom+'px'; + } e.style.left = element_left; e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`; }; @@ -5979,8 +5990,9 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { resetPositionStyles(e); const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); setPositionClass("right"); - e.style.top = element_left; - e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; + e.style.top = element_top; + e.style.left = element_is_fixed_positioned ? `${parentRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; +; e.style.margin = `-${topMargin / 2}px 0 0 0`; }; @@ -5995,8 +6007,8 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { // we have to check for some utility margins const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger); setPositionClass("left"); - e.style.top = element_left; - e.style.left = `-${TRIANGLE_SIZE}px`; + e.style.top = element_top; + e.style.left = element_is_fixed_positioned ? `${parentRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`; e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin }; diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index dc1309ad2..66706ccb4 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -46,7 +46,7 @@ .usa-tooltip__body { white-space: inherit; max-width: fit-content; - // position: fixed; + position: fixed; } .usa-tooltip__body--wrap { From e57cf6e39a33f1a44838b66dcfeb26ade346dbf2 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 3 Oct 2024 02:53:05 -0600 Subject: [PATCH 045/175] some cleanup --- src/registrar/assets/js/uswds-edited.js | 26 +++++++------------ .../assets/sass/_theme/_tooltips.scss | 2 +- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index 928e3fc2b..a1eff9a12 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5938,17 +5938,11 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { return offset; }; - const style = window.getComputedStyle(tooltipBody); - // Check if the position property is 'fixed' - if (style.position === 'fixed') { - console.log('The element has a fixed position.'); - } else { - console.log('The element does not have a fixed position.'); - } - const element_is_fixed_positioned = style.position === 'fixed'; - const parentRect = tooltipTrigger.getBoundingClientRect(); - const element_left = element_is_fixed_positioned ? parentRect.left + parentRect.width/2 + 'px': `50%` - const element_top = element_is_fixed_positioned ? parentRect.top + parentRect.height/2 + 'px': `50%` + const tooltipStyle = window.getComputedStyle(tooltipBody); + const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed'; + const triggerRect = tooltipTrigger.getBoundingClientRect(); + const element_left = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%` + const element_top = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%` /** * Positions tooltip at the top @@ -5962,7 +5956,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("top"); e.style.left = element_left; // center the element - e.style.top = element_is_fixed_positioned ?`${parentRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element + e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element // apply our margins based on the offset e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`; }; @@ -5975,8 +5969,8 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { resetPositionStyles(e); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("bottom"); - if (element_is_fixed_positioned){ - e.style.top = parentRect.bottom+'px'; + if (tooltipIsFixedPositioned){ + e.style.top = triggerRect.bottom+'px'; } e.style.left = element_left; e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`; @@ -5991,7 +5985,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); setPositionClass("right"); e.style.top = element_top; - e.style.left = element_is_fixed_positioned ? `${parentRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; + e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; ; e.style.margin = `-${topMargin / 2}px 0 0 0`; }; @@ -6008,7 +6002,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger); setPositionClass("left"); e.style.top = element_top; - e.style.left = element_is_fixed_positioned ? `${parentRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`; + e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`; e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin }; diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 66706ccb4..20834f2aa 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -31,7 +31,7 @@ .usa-tooltip__body > p { margin-top: 0; - width: 40vw; + width: 50vw; text-wrap: wrap; text-align: center; font-size: .9rem; From f10b4d82855ba961ae7ba773f39ee7945bed669b Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 10:33:45 -0700 Subject: [PATCH 046/175] Create code review guide --- .github/pull_request_template.md | 2 +- docs/dev-practices/code_review.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docs/dev-practices/code_review.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dec0b9fac..4f2349204 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Ticket -Resolves #00 +Resolves #001 ## Changes @@ -45,15 +40,10 @@ All other changes require just a single approving review.--> - [ ] Met the acceptance criteria, or will meet them in a subsequent PR - [ ] Created/modified automated tests -- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve) -- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review -- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. +- [ ] Update documentation in READMEs and/or onboarding guide #### Ensured code standards are met (Original Developer) -- [ ] All new functions and methods are commented using plain language -- [ ] Did dependency updates in Pipfile also get changed in requirements.txt? - [ ] Interactions with external systems are wrapped in try/except - [ ] Error handling exists for unusual or missing values @@ -62,24 +52,16 @@ All other changes require just a single approving review.--> - [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] Checked keyboard navigability - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) -- [ ] Add at least 1 designer as PR reviewer ### As a code reviewer, I have #### Reviewed, tested, and left feedback about the changes - [ ] Pulled this branch locally and tested it -- [ ] Reviewed this code and left comments +- [ ] Verified code meets code standards and comments if any standards above are not satisfied +- [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged. - [ ] Checked that all code is adequately covered by tests -- [ ] Made it clear which comments need to be addressed before this work is merged -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. - -#### Ensured code standards are met (Code reviewer) - -- [ ] All new functions and methods are commented using plain language -- [ ] Interactions with external systems are wrapped in try/except -- [ ] Error handling exists for unusual or missing values -- [ ] (Rarely needed) Did dependency updates in Pipfile also get changed in requirements.txt? +- [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations`. #### Validated user-facing changes as a developer @@ -88,12 +70,6 @@ All other changes require just a single approving review.--> - [ ] Meets all designs and user flows provided by design/product - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) -- [ ] Tested with multiple browsers, the suggestion is to use ones that the developer didn't (check off which ones were used) - - [ ] Chrome - - [ ] Microsoft Edge - - [ ] FireFox - - [ ] Safari - - [ ] (Rarely needed) Tested as both an analyst and applicant user **Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist @@ -103,10 +79,9 @@ All other changes require just a single approving review.--> #### Verified that the changes match the design intention - [ ] Checked that the design translated visually -- [ ] Checked behavior +- [ ] Checked behavior. Comment any found issues or broken flows. - [ ] Checked different states (empty, one, some, error) - [ ] Checked for landmarks, page heading structure, and links -- [ ] Tried to break the intended flow #### Validated user-facing changes as a designer diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 56d4db394..38ed83232 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -1,6 +1,21 @@ -# Code Review +## Code Review After creating a pull request, pull request submitters should: -- Add at least 2 developers as PR reviewers (only 1 will need to approve) -- Message on Slack or in standup to notify the team that a PR is ready for review -- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file. \ No newline at end of file +- Add at least 2 developers as PR reviewers (only 1 will need to approve). +- Message on Slack or in standup to notify the team that a PR is ready for review. +- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file. +- If any updated dependencies on Pipfile, also update dependencies in requirements.txt. + +## Pull Requests for User-facing changes +Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer. + +When making user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. + +## Coding standards +(The Coding standards section may be moved to a new code standards file in a future ticket. +For now we're simply moving PR template content into the code review document for consolidation) + +### Plain language +All functions and methods should use plain language. + +TODO: Description and examples in code standards ticket. \ No newline at end of file From 93ee3b0b8f3901bb14889585ef90906adac83692 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:46:13 -0700 Subject: [PATCH 051/175] Refactor spacing --- .github/pull_request_template.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e493e0a92..e2340bebe 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -43,7 +43,8 @@ Resolves #001 - [ ] Update documentation in READMEs and/or onboarding guide #### Ensured code standards are met (Original Developer) - + +N/A - no external systems or errors, this is just docs refactoring. - [ ] Interactions with external systems are wrapped in try/except - [ ] Error handling exists for unusual or missing values @@ -58,7 +59,7 @@ Resolves #001 #### Reviewed, tested, and left feedback about the changes - [ ] Pulled this branch locally and tested it -- [ ] Verified code meets code standards and comments if any standards above are not satisfied +- [ ] Verified code meets above code standards and user-facing checks. Addresses any checks that are not satisfied - [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged. - [ ] Checked that all code is adequately covered by tests - [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations`. @@ -85,7 +86,7 @@ Resolves #001 #### Validated user-facing changes as a designer -- [ ] Checked keyboard navigability +- [ ] Checked different states (empty, one, some, error) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] Tested with multiple browsers (check off which ones were used) From 2bdef1e01ff802855fcd92ade0a6bd0cd705764f Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:47:13 -0700 Subject: [PATCH 052/175] Fix spacing --- .github/pull_request_template.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e2340bebe..351ce579b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -70,7 +70,6 @@ N/A - no external systems or errors, this is just docs refactoring. - [ ] Checked keyboard navigability - [ ] Meets all designs and user flows provided by design/product - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - - [ ] (Rarely needed) Tested as both an analyst and applicant user **Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist From de4161dde44f3dce9d96329540e82d8be1d14285 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:47:49 -0700 Subject: [PATCH 053/175] Remove unused content --- .github/pull_request_template.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 351ce579b..4d3b76746 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -44,7 +44,6 @@ Resolves #001 #### Ensured code standards are met (Original Developer) -N/A - no external systems or errors, this is just docs refactoring. - [ ] Interactions with external systems are wrapped in try/except - [ ] Error handling exists for unusual or missing values From 3997251eb19c9c77748f1afbe3b875ecfe92329e Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:49:19 -0700 Subject: [PATCH 054/175] Add browser section --- .github/pull_request_template.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4d3b76746..a3646c40a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -87,11 +87,11 @@ Resolves #001 - [ ] Checked different states (empty, one, some, error) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) -- [ ] Tested with multiple browsers (check off which ones were used) - - [ ] Chrome - - [ ] Microsoft Edge - - [ ] FireFox - - [ ] Safari +#### Test support on multiple browsers. Check the browser(s) tested. +- [ ] Chrome +- [ ] Microsoft Edge +- [ ] FireFox +- [ ] Safari - [ ] (Rarely needed) Tested as both an analyst and applicant user From 7ced4b73e0f38fb3ee6a5ec9d1756bef68df130a Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 3 Oct 2024 15:52:26 -0600 Subject: [PATCH 055/175] linted + remove test content from tooltip --- src/registrar/assets/js/uswds-edited.js | 5 +---- src/registrar/tests/test_views_domain.py | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index 49a87c4bd..11a71b0df 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -6161,10 +6161,7 @@ const setUpAttributes = tooltipTrigger => { // DOTGOV: nest the text element to allow us creater control over width and wrapping behavior tooltipBody.innerHTML = `
- ${tooltipContent} - - n oainef aoieiu aw eghr hilabiuyabewisofuha libfasuiybefiae ruhawioeufh aiwfh iahf iuahefailusef aiwsfbali wefbaiue fbaliuefbalieuwfhauiowera jhfasiuf aiuwenail ewfasdn fiausfn iuafia ewfn ia fisfn iuf niuwnf iwenfailuhfiauefn aliefnaifnialsudnf aiufnailufnailefialenf ailefia fa filanf ilaefiunaifalfn ailfnialuefn ialuefnailf lifniasn filsa fnialn fila fi af ai fniaufn ilaufn ial fia fnila fiua fnilaefn ialuefn ial efailf ia fnial fia fniu ialf nailf a fal f Before this domain can be used, you’ll need to add name server addresses. - + ${tooltipContent}
` // tooltipBody.textContent = tooltipContent; diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index aa2fc10c0..928cea43e 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -340,7 +340,10 @@ class TestDomainDetail(TestDomainOverview): detail_page = self.client.get(f"/domain/{domain.id}") # Check that alert message displays properly self.assertContains( - detail_page, "You don't have access to manage "+domain.name+". If you need to make updates, contact one of the listed domain managers." + detail_page, + "You don't have access to manage " + + domain.name + + ". If you need to make updates, contact one of the listed domain managers.", ) # Check that user does not have option to Edit domain self.assertNotContains(detail_page, "Edit") From fc421ce0578621e47bd34f33c38913ddcae7fcd7 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:56:38 -0700 Subject: [PATCH 056/175] Update code review doc --- docs/dev-practices/code_review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 38ed83232..aa3d13404 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -18,4 +18,4 @@ For now we're simply moving PR template content into the code review document fo ### Plain language All functions and methods should use plain language. -TODO: Description and examples in code standards ticket. \ No newline at end of file +TODO: Plain language description and examples in code standards ticket. \ No newline at end of file From c553ebb773df1e1544f406f9004fe89fd6ba9732 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:57:57 -0700 Subject: [PATCH 057/175] Fix punctuation --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a3646c40a..ecf117f15 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -59,9 +59,9 @@ Resolves #001 - [ ] Pulled this branch locally and tested it - [ ] Verified code meets above code standards and user-facing checks. Addresses any checks that are not satisfied -- [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged. +- [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged - [ ] Checked that all code is adequately covered by tests -- [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations`. +- [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations` #### Validated user-facing changes as a developer From d933da326cb30f6c40cc735cbc13b676c0a586f7 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 3 Oct 2024 18:44:34 -0400 Subject: [PATCH 058/175] wip --- src/registrar/forms/__init__.py | 1 + src/registrar/forms/portfolio.py | 31 ++++ .../templates/includes/header_extended.html | 14 +- src/registrar/templates/portfolio_member.html | 25 ++-- src/registrar/views/portfolio_members_json.py | 133 +++++++++++------- src/registrar/views/portfolios.py | 34 ++++- 6 files changed, 165 insertions(+), 73 deletions(-) diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 033e955ed..121e2b3f7 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -13,4 +13,5 @@ from .domain import ( ) from .portfolio import ( PortfolioOrgAddressForm, + PortfolioMemberForm, ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 14a45f6ae..2b669c50c 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,6 +4,9 @@ import logging from django import forms from django.core.validators import RegexValidator +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices + from ..models import DomainInformation, Portfolio, SeniorOfficial logger = logging.getLogger(__name__) @@ -95,3 +98,31 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): cleaned_data = super().clean() cleaned_data.pop("full_name", None) 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", + ] + diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index a3b2364a9..43467602e 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -91,12 +91,14 @@ {% endif %} - {% if has_organization_members_flag and has_view_members_portfolio_permission %} -
  • - - Members - -
  • + {% if has_organization_members_flag %} + {% if has_view_members_portfolio_permission or has_edit_members_portfolio_permission %} +
  • + + Members + +
  • + {% endif %} {% endif %}
  • diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 55435d3b1..3f089ebe1 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -7,14 +7,6 @@ {% block portfolio_content %}
    -
    -

    - Portfolio name: {{ portfolio }} -

    -
    -
    {% block messages %} @@ -23,7 +15,22 @@

    Member

    -

    The name of your organization will be publicly listed as the domain registrant.

    +

    {{ user.first_name }}

    + + +
    + + + {% csrf_token %} + + {% input_with_errors form.roles %} + {% input_with_errors form.additional_permissions %} + + + diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 34cc08ee7..f3a175980 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -8,6 +8,7 @@ from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user import User from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from operator import itemgetter @login_required @@ -15,31 +16,60 @@ def get_portfolio_members_json(request): """Given the current request, get all members that are associated with the given portfolio""" portfolio = request.GET.get("portfolio") - member_ids = get_member_ids_from_request(request, portfolio) - objects = User.objects.filter(id__in=member_ids) + # member_ids = get_member_ids_from_request(request, portfolio) + # members = User.objects.filter(id__in=member_ids) - admin_ids = UserPortfolioPermission.objects.filter( - portfolio=portfolio, - roles__overlap=[ - UserPortfolioRoleChoices.ORGANIZATION_ADMIN, - ], - ).values_list("user__id", flat=True) - portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( - "email", flat=True + permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio).select_related("user").values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") + invitations = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( + 'pk', 'email', 'portfolio_roles', 'portfolio_additional_permissions', 'status' ) - unfiltered_total = objects.count() + # Convert the permissions queryset into a list of dictionaries + permission_list = [ + { + 'id': perm[0], + 'first_name': perm[1], + 'last_name': perm[2], + 'email': perm[3], + 'last_active': perm[4], + 'roles': perm[5], + 'source': 'permission' # Mark the source as permissions + } + for perm in permissions + ] - objects = apply_search(objects, request) - # objects = apply_status_filter(objects, request) - objects = apply_sorting(objects, request) + # Convert the invitations queryset into a list of dictionaries + invitation_list = [ + { + 'id': invite[0], + 'first_name': None, # No first name in invitations + 'last_name': None, # No last name in invitations + 'email': invite[1], + 'roles': invite[2], + 'additional_permissions': invite[3], + 'status': invite[4], + 'last_active': 'Invited', + 'source': 'invitation' # Mark the source as invitations + } + for invite in invitations + ] - paginator = Paginator(objects, 10) + # Combine both lists into one unified list + combined_list = permission_list + invitation_list + + unfiltered_total = len(combined_list) + + combined_list = apply_search(combined_list, request) + combined_list = apply_sorting(combined_list, request) + + paginator = Paginator(combined_list, 10) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) + + members = [ - serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails) - for member in page_obj.object_list + serialize_members(request, portfolio, item, request.user) + for item in page_obj.object_list ] return JsonResponse( @@ -55,44 +85,43 @@ def get_portfolio_members_json(request): ) -def get_member_ids_from_request(request, portfolio): - """Given the current request, - get all members that are associated with the given portfolio""" - member_ids = [] - if portfolio: - member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True) - return member_ids +# def get_member_ids_from_request(request, portfolio): +# """Given the current request, +# get all members that are associated with the given portfolio""" +# member_ids = [] +# if portfolio: +# member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True) +# return member_ids - -def apply_search(queryset, request): - search_term = request.GET.get("search_term") +def apply_search(data_list, request): + search_term = request.GET.get("search_term", "").lower() if search_term: - queryset = queryset.filter( - Q(username__icontains=search_term) - | Q(first_name__icontains=search_term) - | Q(last_name__icontains=search_term) - | Q(email__icontains=search_term) - ) - return queryset + # Filter the list based on the search term (case-insensitive) + data_list = [ + item for item in data_list + if search_term in (item.get('first_name', '') or '').lower() + or search_term in (item.get('last_name', '') or '').lower() + or search_term in (item.get('email', '') or '').lower() + ] + + return data_list -def apply_sorting(queryset, request): +def apply_sorting(data_list, request): sort_by = request.GET.get("sort_by", "id") # Default to 'id' order = request.GET.get("order", "asc") # Default to 'asc' if sort_by == "member": - sort_by = ["email", "first_name", "middle_name", "last_name"] - else: - sort_by = [sort_by] + sort_by = "email" - if order == "desc": - sort_by = [f"-{field}" for field in sort_by] + # Sort the list + data_list = sorted(data_list, key=itemgetter(sort_by), reverse=(order == "desc")) - return queryset.order_by(*sort_by) + return data_list -def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails): +def serialize_members(request, portfolio, item, user): # ------- VIEW ONLY # If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link. # If view_only (the user only has view user permissions), show the "View" link (no gear icon). @@ -106,20 +135,20 @@ def serialize_members(request, portfolio, member, user, admin_ids, portfolio_inv view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users # ------- USER STATUSES - is_invited = member.email in portfolio_invitation_emails - last_active = "Invited" if is_invited else "Unknown" - if member.last_login: - last_active = member.last_login.strftime("%b. %d, %Y") - is_admin = member.id in admin_ids + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item['roles'] + + action_url = '#' + if item['source'] == 'permission': + action_url = reverse("member", kwargs={"pk": item['id']}) # ------- SERIALIZE member_json = { - "id": member.id, - "name": member.get_formatted_name(), - "email": member.email, + "id": item['id'], + "name": (item['first_name'] or '') + ' ' + (item['last_name'] or ''), + "email": item['email'], "is_admin": is_admin, - "last_active": last_active, - "action_url": reverse("member", kwargs={"pk": member.id}), # TODO: Future ticket? + "last_active": item['last_active'], + "action_url": action_url, "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"), } diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index fe15ccd27..c4a19e2cd 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -3,7 +3,7 @@ from django.http import Http404 from django.shortcuts import render from django.urls import reverse from django.contrib import messages -from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm +from registrar.forms.portfolio import PortfolioMemberForm, PortfolioOrgAddressForm, PortfolioSeniorOfficialForm from registrar.models import Portfolio, User from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices @@ -17,7 +17,7 @@ from registrar.views.utility.permission_views import ( ) from django.views.generic import View from django.views.generic.edit import FormMixin - +from django.shortcuts import get_object_or_404, redirect logger = logging.getLogger(__name__) @@ -55,11 +55,33 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): class PortfolioMemberView(PortfolioMemberPermissionView, View): template_name = "portfolio_member.html" - model = User + form_class = PortfolioMemberForm - # def get(self, request): - # """Add additional context data to the template.""" - # return render(request, self.template_name, context=self.get_context_data()) + def get(self, request, pk): + portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + user = portfolio_permission.user + + form = self.form_class(instance=portfolio_permission) + + return render(request, self.template_name, { + 'form': form, + 'user': user, + }) + + def post(self, request, pk): + portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + user = portfolio_permission.user + + form = self.form_class(request.POST, instance=portfolio_permission) + + if form.is_valid(): + form.save() + return redirect('home') + + return render(request, self.template_name, { + 'form': form, + 'user': user, # Pass the user object again to the template + }) From 993ae06b6abc8b76c752aeca1f46b875fd823cea Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 4 Oct 2024 10:56:28 -0400 Subject: [PATCH 059/175] fixed sorting for last_active --- src/registrar/views/portfolio_members_json.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index f3a175980..ed8fb9c3f 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -1,3 +1,4 @@ +from datetime import datetime from django.http import JsonResponse from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required @@ -115,8 +116,25 @@ def apply_sorting(data_list, request): if sort_by == "member": sort_by = "email" - # Sort the list - data_list = sorted(data_list, key=itemgetter(sort_by), reverse=(order == "desc")) + # Custom key function that handles None, 'Invited', and datetime values for last_active + def sort_key(item): + value = item.get(sort_by) + if sort_by == "last_active": + # Return a tuple to ensure consistent data types for comparison + # First element: ordering value (0 for valid datetime, 1 for 'Invited', 2 for None) + # Second element: the actual value to sort by + if value is None: + return (2, value) # Position None last + if value == 'Invited': + return (1, value) # Position 'Invited' before None but after valid datetimes + if isinstance(value, datetime): + return (0, value) # Position valid datetime values first + + # Default case: return the value as is for comparison + return value + + # Sort the list using the custom key function + data_list = sorted(data_list, key=sort_key, reverse=(order == "desc")) return data_list From c1c060cb363df9f2aec9c7b2164fab0142d5dfe3 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 4 Oct 2024 11:40:29 -0400 Subject: [PATCH 060/175] added support for invited member --- src/registrar/config/urls.py | 5 +++ src/registrar/forms/portfolio.py | 28 +++++++++++++++ src/registrar/templates/portfolio_member.html | 15 +++++--- src/registrar/views/portfolio_members_json.py | 10 ++---- src/registrar/views/portfolios.py | 36 ++++++++++++++++--- src/registrar/views/utility/mixins.py | 19 ++++++++++ .../views/utility/permission_views.py | 9 +++++ 7 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 754edca1c..da115d471 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -86,6 +86,11 @@ urlpatterns = [ views.PortfolioMemberView.as_view(), name="member", ), + path( + "invitedmember/", + views.PortfolioInvitedMemberView.as_view(), + name="invitedmember", + ), # path( # "no-organization-members/", # views.PortfolioNoMembersView.as_view(), diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 2b669c50c..cdf00c625 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,6 +4,7 @@ import logging from django import forms from django.core.validators import RegexValidator +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -126,3 +127,30 @@ class PortfolioMemberForm(forms.ModelForm): "additional_permissions", ] + +class PortfolioInvitedMemberForm(forms.ModelForm): + """ + Form for updating a portfolio invited member. + """ + + portfolio_roles = forms.MultipleChoiceField( + choices=UserPortfolioRoleChoices.choices, + widget=forms.SelectMultiple(attrs={'class': 'usa-select'}), + required=False, + label="Roles", + ) + + portfolio_additional_permissions = forms.MultipleChoiceField( + choices=UserPortfolioPermissionChoices.choices, + widget=forms.SelectMultiple(attrs={'class': 'usa-select'}), + required=False, + label="Additional Permissions", + ) + + class Meta: + model = PortfolioInvitation + fields = [ + "portfolio_roles", + "portfolio_additional_permissions", + ] + diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 3f089ebe1..284a795eb 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -15,17 +15,22 @@

    Member

    -

    {{ user.first_name }}

    +

    {{ member.first_name }}


    {% csrf_token %} - - {% input_with_errors form.roles %} - {% input_with_errors form.additional_permissions %} - + {% if form.roles %} + {% comment - handling form fields for member %} + {% input_with_errors form.roles %} + {% input_with_errors form.additional_permissions %} + {% elif form.portfolio_roles %} + {% comment - handling form fields for invited member %} + {% input_with_errors form.portfolio_roles %} + {% input_with_errors form.portfolio_additional_permissions %} + {% endif %} +
    + + + +
    +
    +{% endblock %} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 98402fb95..d33c327f8 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -13,7 +13,9 @@ from registrar.views.utility.permission_views import ( PortfolioDomainsPermissionView, PortfolioBasePermissionView, NoPortfolioDomainsPermissionView, + PortfolioInvitedMemberEditPermissionView, PortfolioInvitedMemberPermissionView, + PortfolioMemberEditPermissionView, PortfolioMemberPermissionView, PortfolioMembersPermissionView, ) @@ -57,6 +59,20 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): class PortfolioMemberView(PortfolioMemberPermissionView, View): template_name = "portfolio_member.html" + + def get(self, request, pk): + portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + user = portfolio_permission.user + + return render(request, self.template_name, { + 'portfolio_permission': portfolio_permission, + 'member': user, + }) + + +class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): + + template_name = "portfolio_member_permissions.html" form_class = PortfolioMemberForm def get(self, request, pk): @@ -78,7 +94,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): if form.is_valid(): form.save() - return redirect('members') + return redirect('member',pk=pk) return render(request, self.template_name, { 'form': form, @@ -86,10 +102,23 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): }) - class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): template_name = "portfolio_member.html" + # form_class = PortfolioInvitedMemberForm + + def get(self, request, pk): + portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) + # form = self.form_class(instance=portfolio_invitation) + + return render(request, self.template_name, { + 'portfolio_invitation': portfolio_invitation, + }) + + +class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View): + + template_name = "portfolio_member_permissions.html" form_class = PortfolioInvitedMemberForm def get(self, request, pk): @@ -106,7 +135,7 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): form = self.form_class(request.POST, instance=portfolio_invitation) if form.is_valid(): form.save() - return redirect('members') + return redirect('invitedmember', pk=pk) return render(request, self.template_name, { 'form': form, diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 10e8fe3c6..94fc0b1c5 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -515,6 +515,23 @@ class PortfolioMemberPermission(PortfolioBasePermission): return False return super().has_permission() + + +class PortfolioMemberEditPermission(PortfolioBasePermission): + """Permission mixin that allows access to portfolio member pages if user + has access to edit, otherwise 403""" + + def has_permission(self): + """Check if this user has access to members for 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"]""" + + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_edit_members_portfolio_permission(portfolio): + return False + + return super().has_permission() class PortfolioInvitedMemberPermission(PortfolioBasePermission): @@ -534,3 +551,20 @@ class PortfolioInvitedMemberPermission(PortfolioBasePermission): return False return super().has_permission() + + +class PortfolioInvitedMemberEditPermission(PortfolioBasePermission): + """Permission mixin that allows access to portfolio invited member pages if user + has access to edit, otherwise 403""" + + def has_permission(self): + """Check if this user has access to members for 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"]""" + + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_edit_members_portfolio_permission(portfolio): + return False + + return super().has_permission() diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 65af99a14..6fb7fee50 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -15,7 +15,9 @@ from .mixins import ( DomainRequestWizardPermission, PortfolioDomainRequestsPermission, PortfolioDomainsPermission, + PortfolioInvitedMemberEditPermission, PortfolioInvitedMemberPermission, + PortfolioMemberEditPermission, UserDeleteDomainRolePermission, UserProfilePermission, PortfolioBasePermission, @@ -270,9 +272,25 @@ class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePerm """ +class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC): + """Abstract base view for portfolio member edit views that enforces permissions. + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ + + class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC): """Abstract base view for portfolio member views that enforces permissions. This abstract view cannot be instantiated. Actual views must specify `template_name`. """ + + +class PortfolioInvitedMemberEditPermissionView(PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC): + """Abstract base view for portfolio member edit views that enforces permissions. + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ From 02839026153c57f7688daf242c4c959a155a2427 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:17:39 -0700 Subject: [PATCH 064/175] Add more copy changes --- .github/pull_request_template.md | 4 ++-- docs/dev-practices/code_review.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ecf117f15..c4e63bccd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -65,14 +65,14 @@ Resolves #001 #### Validated user-facing changes as a developer +**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist + - [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] Checked keyboard navigability - [ ] Meets all designs and user flows provided by design/product - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] (Rarely needed) Tested as both an analyst and applicant user -**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist - ### As a designer reviewer, I have #### Verified that the changes match the design intention diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index aa3d13404..f30eec64e 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -6,9 +6,10 @@ After creating a pull request, pull request submitters should: - If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file. - If any updated dependencies on Pipfile, also update dependencies in requirements.txt. -## Pull Requests for User-facing changes Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer. +All other changes require just a single approving review. +## Pull Requests for User-facing changes When making user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. ## Coding standards From 980f997dbcea3cf32ba4ccf68b9880552db3b83d Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:29:10 -0700 Subject: [PATCH 065/175] Add PR naming conventions --- .github/pull_request_template.md | 13 +++++-------- docs/dev-practices/code_review.md | 7 ++++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c4e63bccd..20571b305 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -64,7 +64,6 @@ Resolves #001 - [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations` #### Validated user-facing changes as a developer - **Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist - [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing @@ -86,13 +85,11 @@ Resolves #001 - [ ] Checked different states (empty, one, some, error) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - -#### Test support on multiple browsers. Check the browser(s) tested. -- [ ] Chrome -- [ ] Microsoft Edge -- [ ] FireFox -- [ ] Safari - +- [ ] Tested with multiple browsers (check off which ones were used) + - [ ] Chrome + - [ ] Microsoft Edge + - [ ] FireFox + - [ ] Safari - [ ] (Rarely needed) Tested as both an analyst and applicant user ## Screenshots diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index f30eec64e..09e6e0c1c 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -1,5 +1,8 @@ ## Code Review +Pull requests should be titled in the format of `#issue_number: Descriptive name ideally matching ticket name - [sandbox]` +Any pull requests including a migration should be suffixed with ` - MIGRATION` + After creating a pull request, pull request submitters should: - Add at least 2 developers as PR reviewers (only 1 will need to approve). - Message on Slack or in standup to notify the team that a PR is ready for review. @@ -7,11 +10,13 @@ After creating a pull request, pull request submitters should: - If any updated dependencies on Pipfile, also update dependencies in requirements.txt. Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer. -All other changes require just a single approving review. +All other changes require a single approving review. ## Pull Requests for User-facing changes When making user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. +Add new pages to the .pa11yci file so they are included in our automated accessibility testing. + ## Coding standards (The Coding standards section may be moved to a new code standards file in a future ticket. For now we're simply moving PR template content into the code review document for consolidation) From c20f7e66791906b88a30e19130efe44642108400 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:49:27 -0700 Subject: [PATCH 066/175] Updating branch naming standards in contributing.md --- CONTRIBUTING.md | 21 +-------------------- docs/dev-practices/code_review.md | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab15c660f..5e1c01be9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,17 +14,6 @@ There are a handful of things we do not commit to the repository: For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable. -## Approvals - -When a code change is made that is not user facing, then the following is required: -- a developer approves the PR - -When a code change is made that is user facing, beyond content updates, then the following are required: -- a developer approves the PR -- a designer approves the PR or checks off all relevant items in this checklist - -Content or document updates require a single person to approve. - ## Project Management We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking. @@ -39,14 +28,6 @@ Every issue in this respository and on the project board should be appropriately We also have labels for each discipline and for research and project management related tasks. While this repository and project board track development work, we try to document all work related to the project here as well. -## Pull request etiquette - -- The submitter is in charge of merging their PRs unless the approver is given explicit permission. -- Do not commit to another person's branch unless given explicit permission. -- Keep pull requests as small as possible. This makes them easier to review and track changes. -- Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review. -- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation. - ## Branch Naming -Our branch naming convention is `name/topic-or-feature`, for example: `lmm/add-contributing-doc`. +Our branch naming convention is `name/issue_no-description`, for example: `lmm/0000-add-contributing-doc`. diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 09e6e0c1c..1cea4aa04 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -1,7 +1,7 @@ ## Code Review Pull requests should be titled in the format of `#issue_number: Descriptive name ideally matching ticket name - [sandbox]` -Any pull requests including a migration should be suffixed with ` - MIGRATION` +Pull requests including a migration should be suffixed with ` - MIGRATION` After creating a pull request, pull request submitters should: - Add at least 2 developers as PR reviewers (only 1 will need to approve). @@ -9,19 +9,27 @@ After creating a pull request, pull request submitters should: - If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file. - If any updated dependencies on Pipfile, also update dependencies in requirements.txt. +## Pull request approvals Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer. All other changes require a single approving review. +The submitter is responsible for merging their PR unless the approver is given explcit permission. Similarly, do not commit to another person's branch unless given explicit permission. + +Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review. + ## Pull Requests for User-facing changes When making user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. Add new pages to the .pa11yci file so they are included in our automated accessibility testing. +## Other Pull request norms +- Keep pull requests as small as possible. This makes them easier to review and track changes. +- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation. + +[comment]: The Coding standards section will be moved to a new code standards file in #2898. For now we're simply moving PR template content into the code review document for consolidation ## Coding standards -(The Coding standards section may be moved to a new code standards file in a future ticket. -For now we're simply moving PR template content into the code review document for consolidation) ### Plain language All functions and methods should use plain language. -TODO: Plain language description and examples in code standards ticket. \ No newline at end of file +TODO: Plain language description and examples in code standards ticket. From a4a83986388a9f15f3690b7eeee6d0052a21faa9 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 4 Oct 2024 13:32:47 -0600 Subject: [PATCH 067/175] Design updated implemented --- src/registrar/templates/domain_detail.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 5cb559a5a..0fb29d2eb 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -46,7 +46,6 @@

    You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers. - Alternatively, an admin for your organization can assign this domain to you by updating your member permissions.

    From 9a28c8c404fdc83fef958b5e09875387f237ee76 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 4 Oct 2024 16:21:59 -0400 Subject: [PATCH 068/175] wip member edit ui --- .../includes/member_permissions.html | 44 ++++++ .../templates/includes/summary_item.html | 8 +- src/registrar/templates/portfolio_member.html | 133 +++++++++--------- src/registrar/views/portfolio_members_json.py | 2 - src/registrar/views/portfolios.py | 4 +- 5 files changed, 119 insertions(+), 72 deletions(-) create mode 100644 src/registrar/templates/includes/member_permissions.html diff --git a/src/registrar/templates/includes/member_permissions.html b/src/registrar/templates/includes/member_permissions.html new file mode 100644 index 000000000..d2e8d5392 --- /dev/null +++ b/src/registrar/templates/includes/member_permissions.html @@ -0,0 +1,44 @@ +

    Member access

    +{% if permissions.roles and 'organization_admin' in permissions.roles %} +

    Admin access

    +{% elif permissions.portfolio_roles and 'organization_admin' in permissions.portfolio_roles %} +

    Admin access

    + +{% elif permissions.roles and 'organization_member' in permissions.roles %} +

    Basic access

    +{% elif permissions.portfolio_roles and 'organization_member' in permissions.portfolio_roles %} +

    Basic access

    + +{% else %} +

    +{% endif %} + +

    Organization domain requests

    +{% if permissions.roles and 'organization_admin' in permissions.roles or 'edit_requests' in permissions.additional_permissions %} +

    View all requests plus create requests

    +{% elif permissions.portfolio_roles and 'organization_admin' in permissions.portfolio_roles or 'edit_requests' in permissions.portfolio_additional_permissions %} +

    View all requests plus create requests

    + +{% elif permissions.additional_permissions and 'view_all_requests' in permissions.additional_permissions %} +

    View all requests

    +{% elif permissions.portfolio_additional_permissions and 'view_all_requests' in permissions.portfolio_additional_permissions %} +

    View all requests

    + +{% else %} +

    No access

    +{% endif %} + +

    Organization members

    +{% if permissions.additional_permissions and 'edit_members' in permissions.additional_permissions %} +

    View all members plus manage members

    +{% elif permissions.portfolio_additional_permissions and 'edit_members' in permissions.portfolio_additional_permissions %} +

    View all members plus manage members

    + +{% elif permissions.additional_permissions and 'view_members' in permissions.additional_permissions %} +

    View all members

    +{% elif permissions.portfolio_additional_permissions and 'view_members' in permissions.portfolio_additional_permissions %} +

    View all members

    + +{% else %} +

    No access

    +{% endif %} \ No newline at end of file diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index d4c68395f..fbe392c4d 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -24,7 +24,9 @@ {% if sub_header_text %}

    {{ sub_header_text }}

    {% endif %} - {% if address %} + {% if permissions %} + {% include "includes/member_permissions.html" with permissions=value %} + {% elif address %} {% include "includes/organization_address.html" with organization=value %} {% elif contact %} {% if list %} @@ -122,9 +124,9 @@ class="usa-link usa-link--icon font-sans-sm line-height-sans-5" > - Edit {{ title }} + {% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %} {{ title }} {% endif %} diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index c68f561d2..718970818 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -6,74 +6,75 @@ {% load static %} {% block portfolio_content %} -
    -
    +
    - {% block messages %} - {% include "includes/form_messages.html" %} - {% endblock %} + {% url 'members' as url %} + + -

    Manage member

    +

    Manage member

    + +

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

    + +
    + Last active: + {% if member and member.last_login %} + {{ member.last_login }} + {% elif portfolio_invitation %} + Invited + {% else %} + -- + {% endif %} +
    + + Full name: + {% if member %} + {% if member.first_name or member.last_name %} + {{ member.get_formatted_name }} + {% else %} + -- + {% endif %} + {% else %} + -- + {% endif %} +
    + + Title or organization role: + {% if member and member.title %} + {{ member.title }} + {% else %} + -- + {% endif %} +
    + + {% if portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% elif portfolio_invitation %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% endif %} + + {% if has_any_domains_portfolio_permission %} + {% if has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Domain management' value="Asdasd" edit_link='#' editable='true' manage_button='true' %} + {% else %} + {% include "includes/summary_item.html" with title='Domain management' value="Asdasd" edit_link='#' editable='true' view_button='true' %} + {% endif %} + {% endif %} -

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

    - -

    Last active: - {% if member and member.last_login %} - {{ member.last_login }} - {% elif portfolio_invitation %} - Invited - {% else %} - -- - {% endif %} -

    - -

    Full name: - {% if member %} - {% if member.first_name or member.last_name %} - {{ member.get_formatted_name }} - {% else %} - -- - {% endif %} - {% else %} - -- - {% endif %} -

    - -

    Title or organization role: - {% if member and member.title %} - {{ member.title }} - {% else %} - -- - {% endif %} -

    - - - -
    - -
    - {% csrf_token %} - {% if form.roles %} - {% input_with_errors form.roles %} - {% input_with_errors form.additional_permissions %} - {% elif form.portfolio_roles %} - {% input_with_errors form.portfolio_roles %} - {% input_with_errors form.portfolio_additional_permissions %} - {% endif %} - -
    - - - -
    {% endblock %} diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 56904988e..ec0004654 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -17,8 +17,6 @@ def get_portfolio_members_json(request): """Given the current request, get all members that are associated with the given portfolio""" portfolio = request.GET.get("portfolio") - # member_ids = get_member_ids_from_request(request, portfolio) - # members = User.objects.filter(id__in=member_ids) permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio).select_related("user").values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") invitations = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index d33c327f8..2b231a0b9 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -65,6 +65,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): user = portfolio_permission.user return render(request, self.template_name, { + 'edit_url': reverse('member-permissions', args=[pk]), 'portfolio_permission': portfolio_permission, 'member': user, }) @@ -94,7 +95,7 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): if form.is_valid(): form.save() - return redirect('member',pk=pk) + return redirect('member', pk=pk) return render(request, self.template_name, { 'form': form, @@ -112,6 +113,7 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): # form = self.form_class(instance=portfolio_invitation) return render(request, self.template_name, { + 'edit_url': reverse('invitedmember-permissions', args=[pk]), 'portfolio_invitation': portfolio_invitation, }) From 96d9fcd25205beaeb7b727b3a97e70ea3d23a0d6 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:44:02 -0500 Subject: [PATCH 069/175] Update src/registrar/templates/domain_users.html Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/domain_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 1b789e590..a2eb3e604 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,7 +8,7 @@

    Domain managers can update all information related to a domain within the - .gov registrar, including, security email and DNS name servers. + .gov registrar, including security email and DNS name servers.

    + + {% endif %} - +
    Last active: @@ -38,7 +84,7 @@ {% elif portfolio_invitation %} Invited {% else %} - -- + ⎯ {% endif %}
    @@ -47,10 +93,10 @@ {% if member.first_name or member.last_name %} {{ member.get_formatted_name }} {% else %} - -- + ⎯ {% endif %} {% else %} - -- + ⎯ {% endif %}
    @@ -58,7 +104,7 @@ {% if member and member.title %} {{ member.title }} {% else %} - -- + ⎯ {% endif %}
    From 15db31b3dcea93f0f5cb22426054cacddbf26c1c Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 7 Oct 2024 15:16:40 -0400 Subject: [PATCH 083/175] assigned domains --- src/registrar/models/user_portfolio_permission.py | 12 +++++++++++- .../templates/includes/member_domain_mgmt.html | 6 ++++++ src/registrar/templates/includes/summary_item.html | 2 ++ src/registrar/templates/portfolio_member.html | 10 ++++------ 4 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/registrar/templates/includes/member_domain_mgmt.html diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 6acd651db..847df7857 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -1,11 +1,11 @@ from django.db import models from django.forms import ValidationError +from registrar.models.user_domain_role import UserDomainRole from registrar.utility.waffle import flag_is_active_for_user from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField - class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -67,6 +67,16 @@ class UserPortfolioPermission(TimeStampedModel): def __str__(self): return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"" if self.roles else "" + def get_managed_domains_count(self): + """Return the count of domains managed by the user for this portfolio.""" + # Filter the UserDomainRole model to get domains where the user has a manager role + managed_domains = UserDomainRole.objects.filter( + user=self.user, + role=UserDomainRole.Roles.MANAGER, + domain__domain_info__portfolio=self.portfolio + ).count() + return managed_domains + def _get_portfolio_permissions(self): """ Retrieve the permissions for the user's portfolio roles. diff --git a/src/registrar/templates/includes/member_domain_mgmt.html b/src/registrar/templates/includes/member_domain_mgmt.html new file mode 100644 index 000000000..34f8bf5e6 --- /dev/null +++ b/src/registrar/templates/includes/member_domain_mgmt.html @@ -0,0 +1,6 @@ +

    Assigned domains

    +{% if domain_count > 0 %} +

    {{domain_count}}

    +{% else %} +

    This member does not manage any domains.{% if editable %} To assign this member a domain, click "Manage".{% endif %}

    +{% endif %} diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index fbe392c4d..aae7b8cf9 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -26,6 +26,8 @@ {% endif %} {% if permissions %} {% include "includes/member_permissions.html" with permissions=value %} + {% elif domain_mgmt %} + {% include "includes/member_domain_mgmt.html" with domain_count=value %} {% elif address %} {% include "includes/organization_address.html" with organization=value %} {% elif contact %} diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 718970818..4cb846b5a 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -68,12 +68,10 @@ {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% endif %} - {% if has_any_domains_portfolio_permission %} - {% if has_edit_members_portfolio_permission %} - {% include "includes/summary_item.html" with title='Domain management' value="Asdasd" edit_link='#' editable='true' manage_button='true' %} - {% else %} - {% include "includes/summary_item.html" with title='Domain management' value="Asdasd" edit_link='#' editable='true' view_button='true' %} - {% endif %} + {% if portfolio_permission %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} + {% else %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=0 edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} {% endif %} From 91355dc7a458ea94bb2d1263833a2e82d84aeaf0 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 7 Oct 2024 16:27:11 -0400 Subject: [PATCH 084/175] Assigned domains for portfolio invitations --- src/registrar/models/portfolio_invitation.py | 10 ++++++++++ src/registrar/templates/portfolio_member.html | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 392d8264b..22c4c881e 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -4,6 +4,7 @@ import logging from django.contrib.auth import get_user_model from django.db import models from django_fsm import FSMField, transition +from registrar.models.domain_invitation import DomainInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore from .utility.time_stamped_model import TimeStampedModel @@ -67,6 +68,15 @@ class PortfolioInvitation(TimeStampedModel): def __str__(self): return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + def get_managed_domains_count(self): + """Return the count of domain invitations managed by the invited user for this portfolio.""" + # Filter the UserDomainRole model to get domains where the user has a manager role + managed_domains = DomainInvitation.objects.filter( + email=self.email, + domain__domain_info__portfolio=self.portfolio + ).count() + return managed_domains + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) def retrieve(self): """When an invitation is retrieved, create the corresponding permission. diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 2ce1872a0..fe3039967 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -116,6 +116,8 @@ {% if portfolio_permission %} {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} + {% elif portfolio_invitation %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} {% else %} {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=0 edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} {% endif %} From 42a25b0d683c2bf67c4d829e1451050b135b6eaa Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 7 Oct 2024 18:16:13 -0400 Subject: [PATCH 085/175] move the logic from templates into the views when testing for permissions --- src/registrar/models/portfolio_invitation.py | 17 +++++++++++ .../includes/member_permissions.html | 8 +++--- src/registrar/templates/portfolio_member.html | 4 +-- src/registrar/views/portfolios.py | 28 ++++++++++++++++--- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 22c4c881e..2c8caaee3 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -76,6 +76,23 @@ class PortfolioInvitation(TimeStampedModel): domain__domain_info__portfolio=self.portfolio ).count() return managed_domains + + def get_portfolio_permissions(self): + """ + Retrieve the permissions for the user's portfolio roles from the invite. + This is similar logic to _get_portfolio_permissions in user_portfolio_permission + """ + # Use a set to avoid duplicate permissions + portfolio_permissions = set() + + if self.roles: + for role in self.roles: + portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) + + if self.additional_permissions: + portfolio_permissions.update(self.additional_permissions) + + return list(portfolio_permissions) @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) def retrieve(self): diff --git a/src/registrar/templates/includes/member_permissions.html b/src/registrar/templates/includes/member_permissions.html index 0d38e2073..8cf75cfbf 100644 --- a/src/registrar/templates/includes/member_permissions.html +++ b/src/registrar/templates/includes/member_permissions.html @@ -8,18 +8,18 @@ {% endif %}

    Organization domain requests

    -{% if permissions.roles and 'organization_admin' in permissions.roles or 'edit_requests' in permissions.additional_permissions %} +{% if member_has_edit_request_portfolio_permission %}

    View all requests plus create requests

    -{% elif permissions.additional_permissions and 'view_all_requests' in permissions.additional_permissions %} +{% elif member_has_view_all_requests_portfolio_permission %}

    View all requests

    {% else %}

    No access

    {% endif %}

    Organization members

    -{% if permissions.additional_permissions and 'edit_members' in permissions.additional_permissions %} +{% if member_has_edit_members_portfolio_permission %}

    View all members plus manage members

    -{% elif permissions.additional_permissions and 'view_members' in permissions.additional_permissions %} +{% elif member_has_view_members_portfolio_permission %}

    View all members

    {% else %}

    No access

    diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index fe3039967..727b14497 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -109,9 +109,9 @@ {% if portfolio_permission %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_permission member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% elif portfolio_invitation %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% endif %} {% if portfolio_permission %} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 2b231a0b9..cd7923668 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -7,7 +7,7 @@ from registrar.forms.portfolio import PortfolioInvitedMemberForm, PortfolioMembe 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 UserPortfolioRoleChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, PortfolioDomainsPermissionView, @@ -62,12 +62,22 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): def get(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) - user = portfolio_permission.user - + member = portfolio_permission.user + + # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors + member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(portfolio_permission.portfolio) + member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(portfolio_permission.portfolio) + member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(portfolio_permission.portfolio) + member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(portfolio_permission.portfolio) + return render(request, self.template_name, { 'edit_url': reverse('member-permissions', args=[pk]), 'portfolio_permission': portfolio_permission, - 'member': user, + 'member': member, + 'member_has_view_all_requests_portfolio_permission': member_has_view_all_requests_portfolio_permission, + 'member_has_edit_request_portfolio_permission': member_has_edit_request_portfolio_permission, + 'member_has_view_members_portfolio_permission': member_has_view_members_portfolio_permission, + 'member_has_edit_members_portfolio_permission': member_has_edit_members_portfolio_permission }) @@ -112,9 +122,19 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) # form = self.form_class(instance=portfolio_invitation) + # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors + member_has_view_all_requests_portfolio_permission = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions() + member_has_edit_request_portfolio_permission = UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions() + member_has_view_members_portfolio_permission = UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions() + member_has_edit_members_portfolio_permission = UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions() + return render(request, self.template_name, { 'edit_url': reverse('invitedmember-permissions', args=[pk]), 'portfolio_invitation': portfolio_invitation, + 'member_has_view_all_requests_portfolio_permission': member_has_view_all_requests_portfolio_permission, + 'member_has_edit_request_portfolio_permission': member_has_edit_request_portfolio_permission, + 'member_has_view_members_portfolio_permission': member_has_view_members_portfolio_permission, + 'member_has_edit_members_portfolio_permission': member_has_edit_members_portfolio_permission }) From 61e7b13df69480509f3d6c4af5b1670a5607ffdc Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 7 Oct 2024 17:41:16 -0600 Subject: [PATCH 086/175] Fixed multiple portfolio error check --- src/registrar/models/user_portfolio_permission.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 241afd328..968ab5de6 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,6 +5,11 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField +# ---Logger +import logging +from venv import logger +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper +logger = logging.getLogger(__name__) class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -98,11 +103,17 @@ class UserPortfolioPermission(TimeStampedModel): def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"**** CLEANING ****") # Check if a user is set without accessing the related object. has_user = bool(self.user_id) - if self.pk is None and has_user: + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"User: {self.user.email}") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"pk: {self.pk}") + if has_user: existing_permissions = UserPortfolioPermission.objects.filter(user=self.user) + has_flag = flag_is_active_for_user(self.user, "multiple_portfolios") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"multiple portfolios enabled: {has_flag}") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"existing permissions detected: {existing_permissions.exists()}") if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " From c3ab816386b46327f95cac809fb4fcc2970c69f5 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 7 Oct 2024 17:41:42 -0600 Subject: [PATCH 087/175] Cleanup --- src/registrar/models/user_portfolio_permission.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 968ab5de6..2e4c8b704 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,12 +5,6 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField -# ---Logger -import logging -from venv import logger -from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper -logger = logging.getLogger(__name__) - class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -107,13 +101,8 @@ class UserPortfolioPermission(TimeStampedModel): # Check if a user is set without accessing the related object. has_user = bool(self.user_id) - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"User: {self.user.email}") - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"pk: {self.pk}") if has_user: existing_permissions = UserPortfolioPermission.objects.filter(user=self.user) - has_flag = flag_is_active_for_user(self.user, "multiple_portfolios") - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"multiple portfolios enabled: {has_flag}") - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"existing permissions detected: {existing_permissions.exists()}") if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " From ae197ab3ed3f3aee7d75cbfb48707f4c70d405e5 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 7 Oct 2024 21:40:58 -0600 Subject: [PATCH 088/175] Cleanup & slight fix for other permission checks --- .../models/user_portfolio_permission.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 2e4c8b704..1ea54acc7 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -97,8 +97,15 @@ class UserPortfolioPermission(TimeStampedModel): def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"**** CLEANING ****") + # Check if portfolio is set without accessing the related object. + has_portfolio = bool(self.portfolio_id) + if not has_portfolio and self._get_portfolio_permissions(): + raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.") + + if has_portfolio and not self._get_portfolio_permissions(): + raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") + # Check if a user is set without accessing the related object. has_user = bool(self.user_id) if has_user: @@ -108,11 +115,3 @@ class UserPortfolioPermission(TimeStampedModel): "This user is already assigned to a portfolio. " "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." ) - - # Check if portfolio is set without accessing the related object. - has_portfolio = bool(self.portfolio_id) - if not has_portfolio and self._get_portfolio_permissions(): - raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.") - - if has_portfolio and not self._get_portfolio_permissions(): - raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") From d9a79cabad6959b3ac6920492df438d72f4c2141 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 12:52:14 -0400 Subject: [PATCH 089/175] test models --- src/registrar/tests/test_models.py | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index b5fb381ac..905c0a06b 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1182,6 +1182,9 @@ class TestPortfolioInvitations(TestCase): def tearDown(self): super().tearDown() + DomainInvitation.objects.all().delete() + DomainInformation.objects.all().delete() + Domain.objects.all().delete() UserPortfolioPermission.objects.all().delete() Portfolio.objects.all().delete() PortfolioInvitation.objects.all().delete() @@ -1269,6 +1272,44 @@ class TestPortfolioInvitations(TestCase): updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2) self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + @less_console_noise_decorator + def test_get_managed_domains_count(self): + """Test that the correct number of domains, which are associated with the portfolio and + have invited the email of the portfolio invitation, are returned.""" + # Add three domains, one which is in the portfolio and email is invited to, + # one which is in the portfolio and email is not invited to, + # and one which is email is invited to and not in the portfolio. + # Arrange + # domain_in_portfolio should not be included in the count + domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio) + # domain_in_portfolio_and_invited should be included in the count + domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio) + DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited) + # domain_invited should not be included in the count + domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited) + DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited) + + # Assert + self.assertEqual(self.invitation.get_managed_domains_count(), 1) + + @less_console_noise_decorator + def test_get_portfolio_permissions(self): + """Test that get_portfolio_permissions returns the expected list of permissions, + based on the roles and permissions assigned to the invitation.""" + # Arrange + test_permission_list = set() + # add the arrays that are defined in UserPortfolioPermission for member and admin + test_permission_list.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])) + test_permission_list.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])) + # add the permissions that are added to the invitation as additional_permissions + test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2]) + perm_list = list(test_permission_list) + # Verify + self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list) + class TestUserPortfolioPermission(TestCase): @less_console_noise_decorator @@ -1335,6 +1376,34 @@ class TestUserPortfolioPermission(TestCase): "Only one portfolio permission is allowed per user when multiple portfolios are disabled.", ) + @less_console_noise_decorator + def test_get_managed_domains_count(self): + """Test that the correct number of managed domains associated with the portfolio + are returned.""" + # Add three domains, one which is in the portfolio and managed by the user, + # one which is in the portfolio and not managed by the user, + # and one which is managed by the user and not in the portfolio. + # Arrange + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + test_user = create_test_user() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # domain_in_portfolio should not be included in the count + domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio) + # domain_in_portfolio_and_managed should be included in the count + domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio) + UserDomainRole.objects.get_or_create(user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER) + # domain_managed should not be included in the count + domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed) + UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER) + + # Assert + self.assertEqual(portfolio_permission.get_managed_domains_count(), 1) + class TestUser(TestCase): """Test actions that occur on user login, From dc76782a4327b3c86898dc47da40c43ccbebfbf9 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 8 Oct 2024 13:36:05 -0400 Subject: [PATCH 090/175] remove checks on members nav link --- .../templates/includes/header_extended.html | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 43467602e..79c6aacb7 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -92,13 +92,11 @@ {% endif %} {% if has_organization_members_flag %} - {% if has_view_members_portfolio_permission or has_edit_members_portfolio_permission %} -
  • - - Members - -
  • - {% endif %} +
  • + + Members + +
  • {% endif %}
  • From de9736ec9541cb3139f8f3f173392c729a935c64 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 8 Oct 2024 13:49:30 -0400 Subject: [PATCH 091/175] acc cancel invitation button on mobile view --- src/registrar/templates/portfolio_member.html | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 727b14497..dedeffc80 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -32,13 +32,23 @@ {% endif %} {% if has_edit_members_portfolio_permission %} - - Remove member - + {% if member %} + + Remove member + + {% else %} + + Cancel invitation + + {% endif %}
    From 09944e4ce00155104f0214ba14f122bad3c83e47 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:10:51 -0700 Subject: [PATCH 092/175] Fix form valid logic --- .../fixtures_user_portfolio_permissions.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py index 3c64eb6b5..6b6e137cd 100644 --- a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py +++ b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py @@ -1,4 +1,5 @@ import logging +import random from faker import Faker from django.db import transaction @@ -51,22 +52,23 @@ class UserPortfolioPermissionFixture: user_portfolio_permissions_to_create = [] for user in users: - for portfolio in portfolios: - try: - if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists(): - user_portfolio_permission = UserPortfolioPermission( - user=user, - portfolio=portfolio, - roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - ) - user_portfolio_permissions_to_create.append(user_portfolio_permission) - else: - logger.info( - f"Permission exists for user '{user.username}' " - f"on portfolio '{portfolio.organization_name}'." - ) - except Exception as e: - logger.warning(e) + # Assign a random portfolio to a user + portfolio = random.choice(portfolios) # nosec + try: + if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists(): + user_portfolio_permission = UserPortfolioPermission( + user=user, + portfolio=portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + user_portfolio_permissions_to_create.append(user_portfolio_permission) + else: + logger.info( + f"Permission exists for user '{user.username}' " + f"on portfolio '{portfolio.organization_name}'." + ) + except Exception as e: + logger.warning(e) # Bulk create permissions cls._bulk_create_permissions(user_portfolio_permissions_to_create) From 69a98a2f7db4617efcced27e8b1d23ae557dd4ef Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 14:44:20 -0400 Subject: [PATCH 093/175] fixed tests and view on domains --- .../includes/member_domain_mgmt.html | 2 +- src/registrar/templates/portfolio_member.html | 6 +- src/registrar/tests/test_views_portfolio.py | 221 ++++++++++++++++++ 3 files changed, 225 insertions(+), 4 deletions(-) diff --git a/src/registrar/templates/includes/member_domain_mgmt.html b/src/registrar/templates/includes/member_domain_mgmt.html index 34f8bf5e6..6bf3f1320 100644 --- a/src/registrar/templates/includes/member_domain_mgmt.html +++ b/src/registrar/templates/includes/member_domain_mgmt.html @@ -2,5 +2,5 @@ {% if domain_count > 0 %}

    {{domain_count}}

    {% else %} -

    This member does not manage any domains.{% if editable %} To assign this member a domain, click "Manage".{% endif %}

    +

    This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}

    {% endif %} diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 727b14497..4e104e618 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -115,11 +115,11 @@ {% endif %} {% if portfolio_permission %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} {% elif portfolio_invitation %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_invitation.get_managed_domains_count edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} {% else %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=0 edit_link='#' editable=has_edit_members_portfolio_permission manage_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=0 edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} {% endif %}
    diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 13a189a8e..f2dc784f7 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -10,6 +10,7 @@ from registrar.models import ( UserDomainRole, User, ) +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_group import UserGroup from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -855,6 +856,226 @@ class TestPortfolio(WebTest): # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}") self.assertContains(response, '"is_admin": true') + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_cannot_view_member_page_when_flag_is_off(self): + """Test that user cannot access the member page when waffle flag is off""" + + # Verify that the user cannot access the member page + self.client.force_login(self.user) + response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True) + # Make sure the page is denied + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_cannot_view_member_page_when_user_has_no_permission(self): + """Test that user cannot access the member page without proper permission""" + + # give user base permissions + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Verify that the user cannot access the member page + self.client.force_login(self.user) + response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True) + # Make sure the page is denied + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_view_member_page_when_user_has_view_members(self): + """Test that user can access the member page with view_members permission""" + + # Arrange + # give user permissions to view members + permission_obj, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Verify the page can be accessed + self.client.force_login(self.user) + response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True) + self.assertEqual(response.status_code, 200) + + # Assert text within the page is correct + self.assertContains(response, "First Last") + self.assertContains(response, self.user.email) + self.assertContains(response, "Basic access") + self.assertContains(response, "No access") + self.assertContains(response, "View all members") + self.assertContains(response, "This member does not manage any domains.") + + # Assert buttons and links within the page are correct + self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present + self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present + self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present + self.assertContains(response, "sprite.svg#visibility") # test that View link is present + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_view_member_page_when_user_has_edit_members(self): + """Test that user can access the member page with edit_members permission""" + + # Arrange + # give user permissions to view AND manage members + permission_obj, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Verify the page can be accessed + self.client.force_login(self.user) + response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True) + self.assertEqual(response.status_code, 200) + + # Assert text within the page is correct + self.assertContains(response, "First Last") + self.assertContains(response, self.user.email) + self.assertContains(response, "Admin access") + self.assertContains(response, "View all requests plus create requests") + self.assertContains(response, "View all members plus manage members") + self.assertContains(response, "This member does not manage any domains. To assign this member a domain, click \"Manage\"") + + # Assert buttons and links within the page are correct + self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "sprite.svg#edit") # test that Edit link is present + self.assertContains(response, "sprite.svg#settings") # test that Manage link is present + self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_cannot_view_invitedmember_page_when_flag_is_off(self): + """Test that user cannot access the invitedmember page when waffle flag is off""" + + # Verify that the user cannot access the member page + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True) + # Make sure the page is denied + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_cannot_view_invitedmember_page_when_user_has_no_permission(self): + """Test that user cannot access the invitedmember page without proper permission""" + + # give user base permissions + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Verify that the user cannot access the member page + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True) + # Make sure the page is denied + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_view_invitedmember_page_when_user_has_view_members(self): + """Test that user can access the invitedmember page with view_members permission""" + + # Arrange + # give user permissions to view members + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + email="info@example.com", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Verify the page can be accessed + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True) + self.assertEqual(response.status_code, 200) + + # Assert text within the page is correct + self.assertContains(response, "Invited") + self.assertContains(response, portfolio_invitation.email) + self.assertContains(response, "Basic access") + self.assertContains(response, "No access") + self.assertContains(response, "View all members") + self.assertContains(response, "This member does not manage any domains.") + + # Assert buttons and links within the page are correct + self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present + self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present + self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present + self.assertContains(response, "sprite.svg#visibility") # test that View link is present + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_view_invitedmember_page_when_user_has_edit_members(self): + """Test that user can access the invitedmember page with edit_members permission""" + + # Arrange + # give user permissions to view AND manage members + permission_obj, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + email="info@example.com", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Verify the page can be accessed + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True) + self.assertEqual(response.status_code, 200) + + # Assert text within the page is correct + self.assertContains(response, "Invited") + self.assertContains(response, portfolio_invitation.email) + self.assertContains(response, "Admin access") + self.assertContains(response, "View all requests plus create requests") + self.assertContains(response, "View all members plus manage members") + self.assertContains(response, "This member does not manage any domains. To assign this member a domain, click \"Manage\"") + + # Assert buttons and links within the page are correct + self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "sprite.svg#edit") # test that Edit link is present + self.assertContains(response, "sprite.svg#settings") # test that Manage link is present + self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present + @less_console_noise_decorator @override_flag("organization_feature", active=True) def test_portfolio_domain_requests_page_when_user_has_no_permissions(self): From 08a273c296c85f907022c55dee2a573f7e6f4c61 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 14:48:11 -0400 Subject: [PATCH 094/175] linted --- src/registrar/forms/portfolio.py | 15 +- src/registrar/models/portfolio_invitation.py | 5 +- .../models/user_portfolio_permission.py | 7 +- src/registrar/tests/test_models.py | 28 +++- .../tests/test_views_members_json.py | 9 +- src/registrar/tests/test_views_portfolio.py | 44 ++--- src/registrar/views/portfolio_members_json.py | 88 +++++----- src/registrar/views/portfolios.py | 150 +++++++++++------- src/registrar/views/utility/mixins.py | 4 +- .../views/utility/permission_views.py | 4 +- 10 files changed, 210 insertions(+), 144 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 51f53340c..b8984023a 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -99,7 +99,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): cleaned_data = super().clean() cleaned_data.pop("full_name", None) return cleaned_data - + class PortfolioMemberForm(forms.ModelForm): """ @@ -108,18 +108,18 @@ class PortfolioMemberForm(forms.ModelForm): roles = forms.MultipleChoiceField( choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={'class': 'usa-select'}), + 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'}), + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), required=False, label="Additional Permissions", ) - + class Meta: model = UserPortfolioPermission fields = [ @@ -135,22 +135,21 @@ class PortfolioInvitedMemberForm(forms.ModelForm): roles = forms.MultipleChoiceField( choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={'class': 'usa-select'}), + 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'}), + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), required=False, label="Additional Permissions", ) - + class Meta: model = PortfolioInvitation fields = [ "roles", "additional_permissions", ] - diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 2c8caaee3..b1f22ae83 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -72,11 +72,10 @@ class PortfolioInvitation(TimeStampedModel): """Return the count of domain invitations managed by the invited user for this portfolio.""" # Filter the UserDomainRole model to get domains where the user has a manager role managed_domains = DomainInvitation.objects.filter( - email=self.email, - domain__domain_info__portfolio=self.portfolio + email=self.email, domain__domain_info__portfolio=self.portfolio ).count() return managed_domains - + def get_portfolio_permissions(self): """ Retrieve the permissions for the user's portfolio roles from the invite. diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 847df7857..80771ca4b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -6,6 +6,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField + class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -71,12 +72,10 @@ class UserPortfolioPermission(TimeStampedModel): """Return the count of domains managed by the user for this portfolio.""" # Filter the UserDomainRole model to get domains where the user has a manager role managed_domains = UserDomainRole.objects.filter( - user=self.user, - role=UserDomainRole.Roles.MANAGER, - domain__domain_info__portfolio=self.portfolio + user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio ).count() return managed_domains - + def _get_portfolio_permissions(self): """ Retrieve the permissions for the user's portfolio roles. diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 905c0a06b..f565c001e 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1284,8 +1284,12 @@ class TestPortfolioInvitations(TestCase): domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY) DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio) # domain_in_portfolio_and_invited should be included in the count - domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY) - DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio) + domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create( + name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY + ) + DomainInformation.objects.get_or_create( + creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio + ) DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited) # domain_invited should not be included in the count domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY) @@ -1302,8 +1306,12 @@ class TestPortfolioInvitations(TestCase): # Arrange test_permission_list = set() # add the arrays that are defined in UserPortfolioPermission for member and admin - test_permission_list.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])) - test_permission_list.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])) + test_permission_list.update( + UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, []) + ) + test_permission_list.update( + UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, []) + ) # add the permissions that are added to the invitation as additional_permissions test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2]) perm_list = list(test_permission_list) @@ -1393,9 +1401,15 @@ class TestUserPortfolioPermission(TestCase): domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY) DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio) # domain_in_portfolio_and_managed should be included in the count - domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY) - DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio) - UserDomainRole.objects.get_or_create(user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER) + domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create( + name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY + ) + DomainInformation.objects.get_or_create( + creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio + ) + UserDomainRole.objects.get_or_create( + user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER + ) # domain_managed should not be included in the count domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY) DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed) diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py index a79d81274..9cd4e823c 100644 --- a/src/registrar/tests/test_views_members_json.py +++ b/src/registrar/tests/test_views_members_json.py @@ -109,7 +109,14 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest): self.assertEqual(len(data["members"]), 5) # Check member fields - expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email, self.user4.email, self.email5} + expected_emails = { + self.user.email, + self.user2.email, + self.user3.email, + self.user4.email, + self.user4.email, + self.email5, + } actual_emails = {member["email"] for member in data["members"]} self.assertEqual(expected_emails, actual_emails) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index f2dc784f7..13173565c 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -885,7 +885,7 @@ class TestPortfolio(WebTest): response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True) # Make sure the page is denied self.assertEqual(response.status_code, 403) - + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -917,10 +917,10 @@ class TestPortfolio(WebTest): self.assertContains(response, "This member does not manage any domains.") # Assert buttons and links within the page are correct - self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present - self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present - self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present - self.assertContains(response, "sprite.svg#visibility") # test that View link is present + self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present + self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present + self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present + self.assertContains(response, "sprite.svg#visibility") # test that View link is present @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -950,13 +950,15 @@ class TestPortfolio(WebTest): self.assertContains(response, "Admin access") self.assertContains(response, "View all requests plus create requests") self.assertContains(response, "View all members plus manage members") - self.assertContains(response, "This member does not manage any domains. To assign this member a domain, click \"Manage\"") + self.assertContains( + response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' + ) # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present - self.assertContains(response, "sprite.svg#edit") # test that Edit link is present - self.assertContains(response, "sprite.svg#settings") # test that Manage link is present - self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present + self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "sprite.svg#edit") # test that Edit link is present + self.assertContains(response, "sprite.svg#settings") # test that Manage link is present + self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -987,7 +989,7 @@ class TestPortfolio(WebTest): response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True) # Make sure the page is denied self.assertEqual(response.status_code, 403) - + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -1027,10 +1029,10 @@ class TestPortfolio(WebTest): self.assertContains(response, "This member does not manage any domains.") # Assert buttons and links within the page are correct - self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present - self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present - self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present - self.assertContains(response, "sprite.svg#visibility") # test that View link is present + self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present + self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present + self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present + self.assertContains(response, "sprite.svg#visibility") # test that View link is present @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -1068,13 +1070,15 @@ class TestPortfolio(WebTest): self.assertContains(response, "Admin access") self.assertContains(response, "View all requests plus create requests") self.assertContains(response, "View all members plus manage members") - self.assertContains(response, "This member does not manage any domains. To assign this member a domain, click \"Manage\"") + self.assertContains( + response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' + ) # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present - self.assertContains(response, "sprite.svg#edit") # test that Edit link is present - self.assertContains(response, "sprite.svg#settings") # test that Manage link is present - self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present + self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "sprite.svg#edit") # test that Edit link is present + self.assertContains(response, "sprite.svg#settings") # test that Manage link is present + self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @less_console_noise_decorator @override_flag("organization_feature", active=True) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 8b81a26a9..948baa07a 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -2,14 +2,11 @@ from datetime import datetime from django.http import JsonResponse from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required -from django.db.models import Q from django.urls import reverse from registrar.models.portfolio_invitation import PortfolioInvitation -from registrar.models.user import User from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices -from operator import itemgetter @login_required @@ -18,21 +15,25 @@ def get_portfolio_members_json(request): get all members that are associated with the given portfolio""" portfolio = request.GET.get("portfolio") - permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio).select_related("user").values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") + permissions = ( + UserPortfolioPermission.objects.filter(portfolio=portfolio) + .select_related("user") + .values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") + ) invitations = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( - 'pk', 'email', 'roles', 'additional_permissions', 'status' + "pk", "email", "roles", "additional_permissions", "status" ) # Convert the permissions queryset into a list of dictionaries permission_list = [ { - 'id': perm[0], - 'first_name': perm[1], - 'last_name': perm[2], - 'email': perm[3], - 'last_active': perm[4], - 'roles': perm[5], - 'source': 'permission' # Mark the source as permissions + "id": perm[0], + "first_name": perm[1], + "last_name": perm[2], + "email": perm[3], + "last_active": perm[4], + "roles": perm[5], + "source": "permission", # Mark the source as permissions } for perm in permissions ] @@ -40,15 +41,15 @@ def get_portfolio_members_json(request): # Convert the invitations queryset into a list of dictionaries invitation_list = [ { - 'id': invite[0], - 'first_name': None, # No first name in invitations - 'last_name': None, # No last name in invitations - 'email': invite[1], - 'roles': invite[2], - 'additional_permissions': invite[3], - 'status': invite[4], - 'last_active': 'Invited', - 'source': 'invitation' # Mark the source as invitations + "id": invite[0], + "first_name": None, # No first name in invitations + "last_name": None, # No last name in invitations + "email": invite[1], + "roles": invite[2], + "additional_permissions": invite[3], + "status": invite[4], + "last_active": "Invited", + "source": "invitation", # Mark the source as invitations } for invite in invitations ] @@ -64,12 +65,8 @@ def get_portfolio_members_json(request): paginator = Paginator(combined_list, 10) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) - - - members = [ - serialize_members(request, portfolio, item, request.user) - for item in page_obj.object_list - ] + + members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list] return JsonResponse( { @@ -90,12 +87,13 @@ def apply_search(data_list, request): if search_term: # Filter the list based on the search term (case-insensitive) data_list = [ - item for item in data_list - if search_term in (item.get('first_name', '') or '').lower() - or search_term in (item.get('last_name', '') or '').lower() - or search_term in (item.get('email', '') or '').lower() + item + for item in data_list + if search_term in (item.get("first_name", "") or "").lower() + or search_term in (item.get("last_name", "") or "").lower() + or search_term in (item.get("email", "") or "").lower() ] - + return data_list @@ -115,11 +113,11 @@ def apply_sorting(data_list, request): # Second element: the actual value to sort by if value is None: return (2, value) # Position None last - if value == 'Invited': + if value == "Invited": return (1, value) # Position 'Invited' before None but after valid datetimes if isinstance(value, datetime): return (0, value) # Position valid datetime values first - + # Default case: return the value as is for comparison return value @@ -144,22 +142,22 @@ def serialize_members(request, portfolio, item, user): # ------- USER STATUSES is_admin = False - if item['roles']: - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item['roles'] + if item["roles"]: + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item["roles"] - action_url = '#' - if item['source'] == 'permission': - action_url = reverse("member", kwargs={"pk": item['id']}) - elif item['source'] == 'invitation': - action_url = reverse("invitedmember", kwargs={"pk": item['id']}) + action_url = "#" + if item["source"] == "permission": + action_url = reverse("member", kwargs={"pk": item["id"]}) + elif item["source"] == "invitation": + action_url = reverse("invitedmember", kwargs={"pk": item["id"]}) # ------- SERIALIZE member_json = { - "id": item['id'], - "name": (item['first_name'] or '') + ' ' + (item['last_name'] or ''), - "email": item['email'], + "id": item["id"], + "name": (item["first_name"] or "") + " " + (item["last_name"] or ""), + "email": item["email"], "is_admin": is_admin, - "last_active": item['last_active'], + "last_active": item["last_active"], "action_url": action_url, "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"), diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index cd7923668..cc1a09b25 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -3,7 +3,12 @@ from django.http import Http404 from django.shortcuts import render from django.urls import reverse from django.contrib import messages -from registrar.forms.portfolio import PortfolioInvitedMemberForm, PortfolioMemberForm, PortfolioOrgAddressForm, PortfolioSeniorOfficialForm +from registrar.forms.portfolio import ( + PortfolioInvitedMemberForm, + PortfolioMemberForm, + PortfolioOrgAddressForm, + PortfolioSeniorOfficialForm, +) from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission @@ -65,22 +70,34 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): member = portfolio_permission.user # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors - member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(portfolio_permission.portfolio) - member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(portfolio_permission.portfolio) - member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(portfolio_permission.portfolio) - member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(portfolio_permission.portfolio) + member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission( + portfolio_permission.portfolio + ) + member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission( + portfolio_permission.portfolio + ) + member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission( + portfolio_permission.portfolio + ) + member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission( + portfolio_permission.portfolio + ) + + return render( + request, + self.template_name, + { + "edit_url": reverse("member-permissions", args=[pk]), + "portfolio_permission": portfolio_permission, + "member": member, + "member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission, + "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission, + "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission, + "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, + }, + ) - return render(request, self.template_name, { - 'edit_url': reverse('member-permissions', args=[pk]), - 'portfolio_permission': portfolio_permission, - 'member': member, - 'member_has_view_all_requests_portfolio_permission': member_has_view_all_requests_portfolio_permission, - 'member_has_edit_request_portfolio_permission': member_has_edit_request_portfolio_permission, - 'member_has_view_members_portfolio_permission': member_has_view_members_portfolio_permission, - 'member_has_edit_members_portfolio_permission': member_has_edit_members_portfolio_permission - }) - class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" @@ -89,29 +106,37 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): def get(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) user = portfolio_permission.user - + form = self.form_class(instance=portfolio_permission) - - return render(request, self.template_name, { - 'form': form, - 'member': user, - }) + + return render( + request, + self.template_name, + { + "form": form, + "member": user, + }, + ) def post(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) user = portfolio_permission.user - + form = self.form_class(request.POST, instance=portfolio_permission) - + if form.is_valid(): form.save() - return redirect('member', pk=pk) - - return render(request, self.template_name, { - 'form': form, - 'member': user, # Pass the user object again to the template - }) - + return redirect("member", pk=pk) + + return render( + request, + self.template_name, + { + "form": form, + "member": user, # Pass the user object again to the template + }, + ) + class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): @@ -123,19 +148,31 @@ class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View): # form = self.form_class(instance=portfolio_invitation) # We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors - member_has_view_all_requests_portfolio_permission = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions() - member_has_edit_request_portfolio_permission = UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions() - member_has_view_members_portfolio_permission = UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions() - member_has_edit_members_portfolio_permission = UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions() + member_has_view_all_requests_portfolio_permission = ( + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions() + ) + member_has_edit_request_portfolio_permission = ( + UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions() + ) + member_has_view_members_portfolio_permission = ( + UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions() + ) + member_has_edit_members_portfolio_permission = ( + UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions() + ) - return render(request, self.template_name, { - 'edit_url': reverse('invitedmember-permissions', args=[pk]), - 'portfolio_invitation': portfolio_invitation, - 'member_has_view_all_requests_portfolio_permission': member_has_view_all_requests_portfolio_permission, - 'member_has_edit_request_portfolio_permission': member_has_edit_request_portfolio_permission, - 'member_has_view_members_portfolio_permission': member_has_view_members_portfolio_permission, - 'member_has_edit_members_portfolio_permission': member_has_edit_members_portfolio_permission - }) + return render( + request, + self.template_name, + { + "edit_url": reverse("invitedmember-permissions", args=[pk]), + "portfolio_invitation": portfolio_invitation, + "member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission, + "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission, + "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission, + "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, + }, + ) class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View): @@ -147,23 +184,30 @@ class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, V portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) form = self.form_class(instance=portfolio_invitation) - return render(request, self.template_name, { - 'form': form, - 'invitation': portfolio_invitation, - }) + return render( + request, + self.template_name, + { + "form": form, + "invitation": portfolio_invitation, + }, + ) def post(self, request, pk): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) form = self.form_class(request.POST, instance=portfolio_invitation) if form.is_valid(): form.save() - return redirect('invitedmember', pk=pk) - - return render(request, self.template_name, { - 'form': form, - 'invitation': portfolio_invitation, # Pass the user object again to the template - }) - + return redirect("invitedmember", pk=pk) + + return render( + request, + self.template_name, + { + "form": form, + "invitation": portfolio_invitation, # Pass the user object again to the template + }, + ) class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 94fc0b1c5..70b7d64a7 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -515,7 +515,7 @@ class PortfolioMemberPermission(PortfolioBasePermission): return False return super().has_permission() - + class PortfolioMemberEditPermission(PortfolioBasePermission): """Permission mixin that allows access to portfolio member pages if user @@ -551,7 +551,7 @@ class PortfolioInvitedMemberPermission(PortfolioBasePermission): return False return super().has_permission() - + class PortfolioInvitedMemberEditPermission(PortfolioBasePermission): """Permission mixin that allows access to portfolio invited member pages if user diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 6fb7fee50..c1d25d691 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -288,7 +288,9 @@ class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, Por """ -class PortfolioInvitedMemberEditPermissionView(PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC): +class PortfolioInvitedMemberEditPermissionView( + PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC +): """Abstract base view for portfolio member edit views that enforces permissions. This abstract view cannot be instantiated. Actual views must specify From acd09d3122dc8d8fe5608d379b8ba8b21425e565 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:05:45 -0400 Subject: [PATCH 095/175] fixed migrations --- ...ns_portfolioinvitation_additional_permissions_and_more.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/registrar/migrations/{0130_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py => 0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py} (78%) diff --git a/src/registrar/migrations/0130_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py b/src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py similarity index 78% rename from src/registrar/migrations/0130_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py rename to src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py index 338a20493..14b7ac22a 100644 --- a/src/registrar/migrations/0130_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py +++ b/src/registrar/migrations/0133_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-10-07 17:12 +# Generated by Django 4.2.10 on 2024-10-08 19:05 from django.db import migrations @@ -6,7 +6,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ("registrar", "0129_alter_portfolioinvitation_portfolio_roles_and_more"), + ("registrar", "0132_alter_domaininformation_portfolio_and_more"), ] operations = [ From 0ee78e9102c32947b29c5633148da4b7bfa3d37a Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:09:06 -0400 Subject: [PATCH 096/175] comment --- src/registrar/templates/portfolio_member.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 46885174d..5f18442d6 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -124,6 +124,7 @@ {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% endif %} + {% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %} {% if portfolio_permission %} {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} {% elif portfolio_invitation %} From 467b7a90f5f398c693ded053427b92d4e631a73e Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:14:15 -0700 Subject: [PATCH 097/175] Readd try block --- src/registrar/views/domain.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 5d7a840c7..1bc537891 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,17 +917,16 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") else: - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - else: - messages.success(self.request, f"Added user {requested_email}.") + messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) From 17b5f36fbc3e85492b82d995c2c8e12806eff622 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:14:54 -0700 Subject: [PATCH 098/175] Fix indent --- src/registrar/views/domain.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1bc537891..a30db761c 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,16 +917,16 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - else: - messages.success(self.request, f"Added user {requested_email}.") + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + else: + messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) From e396534b3805c65b2a96d687e61cd9ef91cf6ebd Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:15:15 -0700 Subject: [PATCH 099/175] Fix indent --- src/registrar/views/domain.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index a30db761c..86a22be0f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,6 +917,7 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + try: UserDomainRole.objects.create( user=requested_user, From 56d94227e26eeab65ccf829fa7d0a8da351594c2 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:26:09 -0400 Subject: [PATCH 100/175] fixed broken test --- src/registrar/tests/test_views_domains_json.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py index 07799104b..c4e5832c0 100644 --- a/src/registrar/tests/test_views_domains_json.py +++ b/src/registrar/tests/test_views_domains_json.py @@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest): UserDomainRole.objects.all().delete() UserPortfolioPermission.objects.all().delete() DomainInformation.objects.all().delete() + Domain.objects.all().delete() Portfolio.objects.all().delete() super().tearDown() From 18656a89432951458aa70ed903094ef3bd76aa9f Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:30:18 -0400 Subject: [PATCH 101/175] owasp fix --- src/zap.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zap.conf b/src/zap.conf index dd9ae1565..710efbc6f 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -73,6 +73,7 @@ 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ +10038 OUTOFSCOPE http://app:8080/permissions # 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 6c1c0dd7351421a8c13d9b961d0ae453e1a5e4da Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:35:16 -0400 Subject: [PATCH 102/175] zap fix1 --- src/zap.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 710efbc6f..8ae245c5f 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -73,7 +73,7 @@ 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ -10038 OUTOFSCOPE http://app:8080/permissions +10038 OUTOFSCOPE http://app:8080/permissionstemp # 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 b1ebfec2fb5e8cddbe62420933c794f2a12c7c96 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:35:37 -0400 Subject: [PATCH 103/175] zap fix2 --- src/zap.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 8ae245c5f..710efbc6f 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -73,7 +73,7 @@ 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ -10038 OUTOFSCOPE http://app:8080/permissionstemp +10038 OUTOFSCOPE http://app:8080/permissions # 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 40c061af98a6bc3e52270f8d15429bec23ee1cb9 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 15:37:54 -0400 Subject: [PATCH 104/175] fixed tabs in zap --- src/zap.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 710efbc6f..9a3897c39 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -71,9 +71,9 @@ 10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/organization/ +10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ -10038 OUTOFSCOPE http://app:8080/permissions # 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 3efd90776457385d7c1f19b113501b44db3ad229 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 16:02:55 -0400 Subject: [PATCH 105/175] incremental fix for spacing in zap.conf --- src/zap.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 9a3897c39..dd9ae1565 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -71,7 +71,6 @@ 10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/organization/ -10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ # This URL always returns 404, so include it as well. From 2f36be268ea2f24cfe33535f5633e2695ffc14b6 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 8 Oct 2024 16:03:54 -0400 Subject: [PATCH 106/175] another incremental fix to zap.conf --- src/zap.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zap.conf b/src/zap.conf index dd9ae1565..1f0548f2d 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -71,6 +71,7 @@ 10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/organization/ +10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ # This URL always returns 404, so include it as well. From 23e99c054d79f6d323d55868bf78c6e54e2524f4 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 8 Oct 2024 15:53:04 -0600 Subject: [PATCH 107/175] added unit test --- .../models/user_portfolio_permission.py | 16 ++++++-- src/registrar/tests/test_models.py | 39 ++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 1ea54acc7..74ef9901e 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,6 +5,13 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField +# ---Logger +import logging +from venv import logger +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper +logger = logging.getLogger(__name__) + + class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -105,12 +112,15 @@ class UserPortfolioPermission(TimeStampedModel): if has_portfolio and not self._get_portfolio_permissions(): raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") - + # Check if a user is set without accessing the related object. has_user = bool(self.user_id) + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"***CLEANING***") if has_user: - existing_permissions = UserPortfolioPermission.objects.filter(user=self.user) - if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists(): + existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list("pk", flat=True) + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"existing_permission_pks: {existing_permission_pks}") + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"self pk: {self.pk}") + if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permission_pks.exists() and not self.pk in existing_permission_pks: raise ValidationError( "This user is already assigned to a portfolio. " "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 015c67dab..681d04c92 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -41,7 +41,6 @@ from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator - @boto3_mocking.patching class TestDomainRequest(TestCase): @less_console_noise_decorator @@ -1274,6 +1273,7 @@ class TestUserPortfolioPermission(TestCase): @less_console_noise_decorator def setUp(self): self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov") + self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2") super().setUp() def tearDown(self): @@ -1306,21 +1306,20 @@ class TestUserPortfolioPermission(TestCase): portfolio_permission_2.clean() except ValidationError as error: self.fail(f"Raised ValidationError unexpectedly: {error}") - + @less_console_noise_decorator @override_flag("multiple_portfolios", active=False) def test_clean_on_creates_multiple_portfolios(self): """Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled""" - # Create an instance of User with a portfolio but no roles or additional permissions + # Create an instance of User with a single portfolio portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") - portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California") portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] ) + portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California") portfolio_permission_2 = UserPortfolioPermission( portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] ) - # This should work as intended portfolio_permission.clean() @@ -1328,7 +1327,35 @@ class TestUserPortfolioPermission(TestCase): with self.assertRaises(ValidationError) as cm: portfolio_permission_2.clean() - portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user) + self.assertEqual( + cm.exception.message, + ( + "This user is already assigned to a portfolio. " + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + ), + ) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_multiple_portfolio_reassignment(self): + """Ensures that a user cannot be assigned to multiple portfolios based on reassignment""" + # Create an instance of two users with separate portfolios + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Motel California") + portfolio_permission_2 = UserPortfolioPermission( + portfolio=portfolio_2, user=self.user2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # This should work as intended + portfolio_permission.clean() + portfolio_permission_2.clean() + + with self.assertRaises(ValidationError) as cm: + portfolio_permission_2.user = self.user + portfolio_permission_2.clean() self.assertEqual( cm.exception.message, From bd018e14ab5ab64141e4b7cad1dfe97f9694b0bf Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 8 Oct 2024 15:53:31 -0600 Subject: [PATCH 108/175] cleanup --- src/registrar/models/user_portfolio_permission.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 74ef9901e..825e82c88 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,12 +5,6 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField -# ---Logger -import logging -from venv import logger -from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper -logger = logging.getLogger(__name__) - class UserPortfolioPermission(TimeStampedModel): """This is a linking table that connects a user with a role on a portfolio.""" @@ -115,11 +109,8 @@ class UserPortfolioPermission(TimeStampedModel): # Check if a user is set without accessing the related object. has_user = bool(self.user_id) - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"***CLEANING***") if has_user: existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list("pk", flat=True) - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"existing_permission_pks: {existing_permission_pks}") - TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"self pk: {self.pk}") if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permission_pks.exists() and not self.pk in existing_permission_pks: raise ValidationError( "This user is already assigned to a portfolio. " From 9425d4c35f462eabe7824c7cfc02ca1f156ac28f Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:26:16 -0700 Subject: [PATCH 109/175] Isolate user domain role create --- src/registrar/views/domain.py | 38 ++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 86a22be0f..a2d287b69 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -823,10 +823,6 @@ class DomainAddUserView(DomainFormBaseView): email, requestor, requested_user ): add_success = False - messages.error( - self.request, - "That email is already a member of another .gov organization.", - ) raise OutsideOrgMemberError # Check to see if an invite has already been sent @@ -880,6 +876,18 @@ class DomainAddUserView(DomainFormBaseView): DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) return redirect(self.get_success_url()) + def _create_user_domain_role(self, requested_user, requested_email, domain, role): + """Assign a user to a domain as a specified role""" + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + messages.success(self.request, f"Added user {requested_email}.") + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + def form_valid(self, form): """Add the specified user on this domain. Throws EmailSendingError.""" @@ -890,7 +898,8 @@ class DomainAddUserView(DomainFormBaseView): requested_user = User.objects.get(email=requested_email) except User.DoesNotExist: # no matching user, go make an invitation - return self._make_invitation(requested_email, requestor) + requested_user = self._make_invitation(requested_email, requestor) + self._create_user_domain_role(requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER) else: # if user already exists then just send an email try: @@ -910,6 +919,10 @@ class DomainAddUserView(DomainFormBaseView): self.object, exc_info=True, ) + messages.error( + self.request, + "That email is already a member of another .gov organization.", + ) except Exception: logger.warn( "Could not send email invitation (Other Exception)", @@ -917,17 +930,10 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - else: - messages.success(self.request, f"Added user {requested_email}.") + else: + self._create_user_domain_role( + requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER + ) return redirect(self.get_success_url()) From 2f2c4e1951701c59ce4e6b9741b87b907b638b31 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:27:36 -0700 Subject: [PATCH 110/175] Simplify try catch --- src/registrar/views/domain.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index a2d287b69..c857e7dd5 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -906,6 +906,9 @@ class DomainAddUserView(DomainFormBaseView): self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) + self._create_user_domain_role( + requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER + ) except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -930,10 +933,6 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") - else: - self._create_user_domain_role( - requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER - ) return redirect(self.get_success_url()) From 6a01e5646d969b315761a150b9f931505c31befc Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:39:38 -0700 Subject: [PATCH 111/175] Debug email bug --- src/registrar/views/domain.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index c857e7dd5..433ae1230 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -876,39 +876,26 @@ class DomainAddUserView(DomainFormBaseView): DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) return redirect(self.get_success_url()) - def _create_user_domain_role(self, requested_user, requested_email, domain, role): - """Assign a user to a domain as a specified role""" - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - messages.success(self.request, f"Added user {requested_email}.") - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - def form_valid(self, form): """Add the specified user on this domain. Throws EmailSendingError.""" requested_email = form.cleaned_data["email"] requestor = self.request.user + email_success = False # look up a user with that email try: requested_user = User.objects.get(email=requested_email) except User.DoesNotExist: # no matching user, go make an invitation - requested_user = self._make_invitation(requested_email, requestor) - self._create_user_domain_role(requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER) + email_success = True + return self._make_invitation(requested_email, requestor) else: # if user already exists then just send an email try: self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) - self._create_user_domain_role( - requested_user, requested_email, self.object, UserDomainRole.Roles.MANAGER - ) + email_success = True except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -933,6 +920,17 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + if email_success: + try: + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + messages.success(self.request, f"Added user {requested_email}.") + except IntegrityError: + messages.warning(self.request, f"{requested_email} is already a manager for this domain") + return redirect(self.get_success_url()) From 889c0a25db9bd2867b5aca571b2d6c1b11267f20 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:10:27 -0700 Subject: [PATCH 112/175] Secure portfolio check on requestor org --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 433ae1230..332af5978 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -785,7 +785,7 @@ class DomainAddUserView(DomainFormBaseView): def _is_member_of_different_org(self, email, requestor, requested_user): """Verifies if an email belongs to a different organization as a member or invited member.""" # Check if user is a already member of a different organization than the requestor's org - requestor_org = UserPortfolioPermission.objects.get(user=requestor).portfolio + requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() From 8d13ea2f9fd80b663cc25b12f70e33b029b76378 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 8 Oct 2024 17:48:25 -0600 Subject: [PATCH 113/175] Initial ANDI screenreader fixes --- src/registrar/forms/domain_request_wizard.py | 6 +++++- src/registrar/templates/domain_request_org_federal.html | 2 +- src/registrar/templates/includes/senior_official.html | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index f2fdd32bc..29e9fa639 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -27,6 +27,7 @@ class OrganizationTypeForm(RegistrarForm): choices=DomainRequest.OrganizationChoicesVerbose.choices, widget=forms.RadioSelect, error_messages={"required": "Select the type of organization you represent."}, + label="What kind of U.S.-based government organization do you represent?" ) @@ -70,6 +71,7 @@ class OrganizationFederalForm(RegistrarForm): federal_type = forms.ChoiceField( choices=BranchChoices.choices, widget=forms.RadioSelect, + label = "Which federal branch is your organization in?", error_messages={"required": ("Select the part of the federal government your organization is in.")}, ) @@ -81,7 +83,8 @@ class OrganizationElectionForm(RegistrarForm): (True, "Yes"), (False, "No"), ], - ) + ), + label="Is your organization an election office?" ) def clean_is_election_board(self): @@ -440,6 +443,7 @@ class OtherContactsForm(RegistrarForm): message="Response must be less than 320 characters.", ) ], + help_text="Enter an email address in the required format, like name@example.com." ) phone = PhoneNumberField( label="Phone", diff --git a/src/registrar/templates/domain_request_org_federal.html b/src/registrar/templates/domain_request_org_federal.html index 834298f24..8e0aa3938 100644 --- a/src/registrar/templates/domain_request_org_federal.html +++ b/src/registrar/templates/domain_request_org_federal.html @@ -2,7 +2,7 @@ {% load field_helpers %} {% block form_instructions %} -

    +

    Which federal branch is your organization in?

    {% endblock %} diff --git a/src/registrar/templates/includes/senior_official.html b/src/registrar/templates/includes/senior_official.html index 0302bc71f..073b82457 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -21,7 +21,9 @@ {% input_with_errors form.first_name %} {% input_with_errors form.last_name %} {% input_with_errors form.title %} - {% input_with_errors form.email %} + {% with sublabel_text="Enter an email address in the required format, like name@example.com." %} + {% input_with_errors form.email %} + {% endwith %} {% elif not form.full_name.value and not form.title.value and not form.email.value %} From d1f3f0efb14aa78e1b3de175888d6885e0c84f9c Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 9 Oct 2024 14:08:05 -0600 Subject: [PATCH 114/175] Revert "resolved merge conflict" This reverts commit 85be976ee483e1364e763add30fea4e53e2d4617, reversing changes made to bd018e14ab5ab64141e4b7cad1dfe97f9694b0bf. --- src/registrar/admin.py | 30 +- src/registrar/assets/js/get-gov-admin.js | 21 +- src/registrar/assets/sass/_theme/_admin.scss | 101 +- .../admin/change_form_object_tools.html | 3 +- .../templates/admin/input_with_clipboard.html | 32 +- .../admin/includes/contact_detail_list.html | 2 +- .../admin/includes/detail_table_fieldset.html | 37 +- src/registrar/tests/test_admin.py | 2 +- src/registrar/tests/test_admin_domain.py | 2 +- src/registrar/tests/test_admin_request.py | 54 +- src/registrar/tests/test_models.py | 7 + src/registrar/tests/test_models_requests.py | 1029 ----------------- src/registrar/tests/test_views_request.py | 1 + 13 files changed, 129 insertions(+), 1192 deletions(-) delete mode 100644 src/registrar/tests/test_models_requests.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 485a1b07d..ca51e8b72 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1976,30 +1976,18 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # If the status is not mapped properly, saving could cause # weird issues down the line. Instead, we should block this. - # NEEDS A UNIT TEST should_proceed = False - return (obj, should_proceed) + return should_proceed - obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED - if obj_is_not_approved and not obj.domain_is_not_active(): - # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE: - # This action (moving a request from approved to - # another status) when the domain is already active (READY), - # would still not go through even without this check as the rules are - # duplicated in the model and the error is raised from the model. - # This avoids an ugly Django error screen. + request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + if request_is_not_approved and not obj.domain_is_not_active(): + # If an admin tried to set an approved domain request to + # another status and the related domain is already + # active, shortcut the action and throw a friendly + # error message. This action would still not go through + # shortcut or not as the rules are duplicated on the model, + # but the error would be an ugly Django error screen. error_message = "This action is not permitted. The domain is already active." - elif ( - original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED - and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED - and original_obj.requested_domain is not None - and Domain.objects.filter(name=original_obj.requested_domain.name).exists() - ): - # REDUNDANT CHECK: - # This action (approving a request when the domain exists) - # would still not go through even without this check as the rules are - # duplicated in the model and the error is raised from the model. - error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE) elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: # This condition should never be triggered. # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index f44211c6d..73f3dded1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -515,14 +515,10 @@ document.addEventListener('DOMContentLoaded', function() { const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); let lastSentEmailContent = document.getElementById("last-sent-email-content"); const initialDropdownValue = dropdown ? dropdown.value : null; - let initialEmailValue; - if (textarea) - initialEmailValue = textarea.value + const initialEmailValue = textarea.value; // We will use the const to control the modal - let isEmailAlreadySentConst; - if (lastSentEmailContent) - isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); + let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); // We will use the function to control the label and help function isEmailAlreadySent() { return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); @@ -710,6 +706,18 @@ document.addEventListener('DOMContentLoaded', function() { } return ''; } + // Extract the submitter name, title, email, and phone number + const submitterDiv = document.querySelector('.form-row.field-submitter'); + const submitterNameElement = document.getElementById('id_submitter'); + // We have to account for different superuser and analyst markups + const submitterName = submitterNameElement + ? submitterNameElement.options[submitterNameElement.selectedIndex].text + : submitterDiv.querySelector('a').text; + const submitterTitle = extractTextById('contact_info_title', submitterDiv); + const submitterEmail = extractTextById('contact_info_email', submitterDiv); + const submitterPhone = extractTextById('contact_info_phone', submitterDiv); + let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`; + //------ Senior Official const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); @@ -726,6 +734,7 @@ document.addEventListener('DOMContentLoaded', function() { `Current Websites: ${existingWebsites.join(', ')}
    ` + `Rationale:
    ` + `Alternative Domains: ${alternativeDomains.join(', ')}
    ` + + `Submitter: ${submitterInfo}
    ` + `Senior Official: ${seniorOfficialInfo}
    ` + `Other Employees: ${otherContactsSummary}
    `; diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index b6bc0d296..5cea72c4c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -385,7 +385,6 @@ a.button, font-kerning: auto; font-family: inherit; font-weight: normal; - text-decoration: none !important; } .button svg, .button span, @@ -393,9 +392,6 @@ a.button, .usa-button--dja span { vertical-align: middle; } -.usa-button--dja.usa-button--unstyled { - color: var(--link-fg); -} .usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { background: var(--button-bg); } @@ -425,34 +421,11 @@ input[type=submit].button--dja-toolbar { input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { border-color: var(--body-quiet-color); } -.admin-icon-group { - position: relative; - display: inline; - align-items: center; - - input { - // Allow for padding around the copy button - padding-right: 35px !important; - } - - button { - width: max-content; - } - - @media (max-width: 1000px) { - button { - display: block; - } - } - - span { - padding-left: 0.05rem; - } - -} -.usa-button__small-text, -.usa-button__small-text span { - font-size: 13px; +// Targets the DJA buttom with a nested icon +button .usa-icon, +.button .usa-icon, +.button--clipboard .usa-icon { + vertical-align: middle; } .module--custom { @@ -700,10 +673,71 @@ address.dja-address-contact-list { } } +// Make the clipboard button "float" inside of the input box +.admin-icon-group { + position: relative; + display: inline; + align-items: center; + + input { + // Allow for padding around the copy button + padding-right: 35px !important; + // Match the height of other inputs + min-height: 2.25rem !important; + } + + button { + line-height: 14px; + width: max-content; + font-size: unset; + text-decoration: none !important; + } + + @media (max-width: 1000px) { + button { + display: block; + padding-top: 8px; + } + } + + span { + padding-left: 0.1rem; + } + +} + +.admin-icon-group.admin-icon-group__clipboard-link { + position: relative; + display: inline; + align-items: center; + + + .usa-button--icon { + position: absolute; + right: auto; + left: 4px; + height: 100%; + top: -1px; + } + button { + font-size: unset !important; + display: inline-flex; + padding-top: 4px; + line-height: 14px; + width: max-content; + font-size: unset; + text-decoration: none !important; + } +} + .no-outline-on-click:focus { outline: none !important; } +.usa-button__small-text { + font-size: small; +} + // Get rid of padding on all help texts form .aligned p.help, form .aligned div.help { padding-left: 0px !important; @@ -853,9 +887,6 @@ div.dja__model-description{ padding-top: 0 !important; } -.padding-bottom-0 { - padding-bottom: 0 !important; -} .flex-container { @media screen and (min-width: 700px) and (max-width: 1150px) { diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 66011a3c4..198140c19 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -20,11 +20,10 @@
  • {% if opts.model_name == 'domainrequest' %}
  • - + - {% translate "Copy request summary" %}
  • diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index d6a016fd5..5ad2b27f7 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@ Template for an input field with a clipboard
    {{ field }}
    {% else %} -
    + -{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html index 84fb07f33..0a28a6532 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -26,7 +26,7 @@ {% if user.email %} {{ user.email }} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} -
    +
    {% else %} None
    {% endif %} diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 2369f235b..6b755724e 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -254,7 +254,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - + @@ -267,31 +267,18 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 9c5e3b582..cdc3c97de 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=3) + self.assertContains(response, "button--clipboard", count=3) # cleanup this test domain_info.delete() diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index f02b59a91..a9b94781f 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase): self.assertContains(response, "Testy Tester") # Test for the copy link - self.assertContains(response, "copy-to-clipboard") + self.assertContains(response, "button--clipboard") # cleanup from this test domain.delete() diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index f19008ca1..a9b073472 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1511,7 +1511,7 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=4) + self.assertContains(response, "button--clipboard", count=4) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") @@ -1846,58 +1846,6 @@ class TestDomainRequestAdmin(MockEppLib): def test_side_effects_when_saving_approved_to_ineligible(self): self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) - @less_console_noise - def test_error_when_saving_to_approved_and_domain_exists(self): - """Redundant admin check on model transition not allowed.""" - Domain.objects.create(name="wabbitseason.gov") - - new_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov" - ) - - # Create a request object with a superuser - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk)) - request.user = self.superuser - - request.session = {} - - # Use ExitStack to combine patch contexts - with ExitStack() as stack: - # Patch django.contrib.messages.error - stack.enter_context(patch.object(messages, "error")) - - new_request.status = DomainRequest.DomainRequestStatus.APPROVED - - self.admin.save_model(request, new_request, None, True) - - messages.error.assert_called_once_with( - request, - "Cannot approve. Requested domain is already in use.", - ) - - @less_console_noise - def test_no_error_when_saving_to_approved_and_domain_exists(self): - """The negative of the redundant admin check on model transition not allowed.""" - new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED) - - # Create a request object with a superuser - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk)) - request.user = self.superuser - - request.session = {} - - # Use ExitStack to combine patch contexts - with ExitStack() as stack: - # Patch Domain.is_active and django.contrib.messages.error simultaneously - stack.enter_context(patch.object(messages, "error")) - - new_request.status = DomainRequest.DomainRequestStatus.APPROVED - - self.admin.save_model(request, new_request, None, True) - - # Assert that the error message was never called - messages.error.assert_not_called() - def test_has_correct_filters(self): """ This test verifies that DomainRequestAdmin has the correct filters set up. diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index f1d25ece9..681d04c92 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1,5 +1,7 @@ from django.forms import ValidationError from django.test import TestCase +from django.db.utils import IntegrityError +from django.db import transaction from unittest.mock import patch from django.test import RequestFactory @@ -18,18 +20,23 @@ from registrar.models import ( UserPortfolioPermission, AllowedEmail, ) + import boto3_mocking from registrar.models.portfolio import Portfolio from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.transition_domain import TransitionDomain from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore +from registrar.utility.constants import BranchChoices from .common import ( MockSESClient, + less_console_noise, completed_domain_request, + set_domain_request_investigators, create_test_user, ) +from django_fsm import TransitionNotAllowed from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py deleted file mode 100644 index 9e86f5f9c..000000000 --- a/src/registrar/tests/test_models_requests.py +++ /dev/null @@ -1,1029 +0,0 @@ -from django.test import TestCase -from django.db.utils import IntegrityError -from django.db import transaction -from unittest.mock import patch - - -from registrar.models import ( - Contact, - DomainRequest, - DomainInformation, - User, - Website, - Domain, - DraftDomain, - FederalAgency, - AllowedEmail, -) - -import boto3_mocking -from registrar.utility.constants import BranchChoices -from registrar.utility.errors import FSMDomainRequestError - -from .common import ( - MockSESClient, - less_console_noise, - completed_domain_request, - set_domain_request_investigators, -) -from django_fsm import TransitionNotAllowed - -from api.tests.common import less_console_noise_decorator - - -@boto3_mocking.patching -class TestDomainRequest(TestCase): - @less_console_noise_decorator - def setUp(self): - - self.dummy_user, _ = Contact.objects.get_or_create( - email="mayor@igorville.com", first_name="Hello", last_name="World" - ) - self.dummy_user_2, _ = User.objects.get_or_create( - username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World" - ) - self.started_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.STARTED, - name="started.gov", - ) - self.submitted_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="submitted.gov", - ) - self.in_review_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="in-review.gov", - ) - self.action_needed_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="action-needed.gov", - ) - self.approved_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.APPROVED, - name="approved.gov", - ) - self.withdrawn_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - name="withdrawn.gov", - ) - self.rejected_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="rejected.gov", - ) - self.ineligible_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.INELIGIBLE, - name="ineligible.gov", - ) - - # Store all domain request statuses in a variable for ease of use - self.all_domain_requests = [ - self.started_domain_request, - self.submitted_domain_request, - self.in_review_domain_request, - self.action_needed_domain_request, - self.approved_domain_request, - self.withdrawn_domain_request, - self.rejected_domain_request, - self.ineligible_domain_request, - ] - - self.mock_client = MockSESClient() - - def tearDown(self): - super().tearDown() - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - DraftDomain.objects.all().delete() - Domain.objects.all().delete() - User.objects.all().delete() - self.mock_client.EMAILS_SENT.clear() - - def assertNotRaises(self, exception_type): - """Helper method for testing allowed transitions.""" - with less_console_noise(): - return self.assertRaises(Exception, None, exception_type) - - @less_console_noise_decorator - def test_request_is_withdrawable(self): - """Tests the is_withdrawable function""" - domain_request_1 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="city2.gov", - ) - domain_request_2 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city3.gov", - ) - domain_request_3 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="city4.gov", - ) - domain_request_4 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="city5.gov", - ) - self.assertTrue(domain_request_1.is_withdrawable()) - self.assertTrue(domain_request_2.is_withdrawable()) - self.assertTrue(domain_request_3.is_withdrawable()) - self.assertFalse(domain_request_4.is_withdrawable()) - - @less_console_noise_decorator - def test_request_is_awaiting_review(self): - """Tests the is_awaiting_review function""" - domain_request_1 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="city2.gov", - ) - domain_request_2 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city3.gov", - ) - domain_request_3 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="city4.gov", - ) - domain_request_4 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="city5.gov", - ) - self.assertTrue(domain_request_1.is_awaiting_review()) - self.assertTrue(domain_request_2.is_awaiting_review()) - self.assertFalse(domain_request_3.is_awaiting_review()) - self.assertFalse(domain_request_4.is_awaiting_review()) - - @less_console_noise_decorator - def test_federal_agency_set_to_non_federal_on_approve(self): - """Ensures that when the federal_agency field is 'none' when .approve() is called, - the field is set to the 'Non-Federal Agency' record""" - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city2.gov", - federal_agency=None, - ) - - # Ensure that the federal agency is None - self.assertEqual(domain_request.federal_agency, None) - - # Approve the request - domain_request.approve() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - - # After approval, it should be "Non-Federal agency" - expected_federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() - self.assertEqual(domain_request.federal_agency, expected_federal_agency) - - def test_empty_create_fails(self): - """Can't create a completely empty domain request.""" - with less_console_noise(): - with transaction.atomic(): - with self.assertRaisesRegex(IntegrityError, "creator"): - DomainRequest.objects.create() - - @less_console_noise_decorator - def test_minimal_create(self): - """Can create with just a creator.""" - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED) - - @less_console_noise_decorator - def test_full_create(self): - """Can create with all fields.""" - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - com_website, _ = Website.objects.get_or_create(website="igorville.com") - gov_website, _ = Website.objects.get_or_create(website="igorville.gov") - domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=user, - investigator=user, - generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - requested_domain=domain, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - ) - domain_request.current_websites.add(com_website) - domain_request.alternative_domains.add(gov_website) - domain_request.other_contacts.add(contact) - domain_request.save() - - @less_console_noise_decorator - def test_domain_info(self): - """Can create domain info with all fields.""" - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - domain, _ = Domain.objects.get_or_create(name="igorville.gov") - information = DomainInformation.objects.create( - creator=user, - generic_org_type=DomainInformation.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - domain=domain, - ) - information.other_contacts.add(contact) - information.save() - self.assertEqual(information.domain.id, domain.id) - self.assertEqual(information.id, domain.domain_info.id) - - @less_console_noise_decorator - def test_status_fsm_submit_fail(self): - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - with self.assertRaises(ValueError): - # can't submit a domain request with a null domain name - domain_request.submit() - - @less_console_noise_decorator - def test_status_fsm_submit_succeed(self): - user, _ = User.objects.get_or_create(username="testy") - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) - - # no email sent to creator so this emits a log warning - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - domain_request.submit() - self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) - - @less_console_noise_decorator - def check_email_sent( - self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" - ): - """Check if an email was sent after performing an action.""" - email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) - with self.subTest(msg=msg, action=action): - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Perform the specified action - action_method = getattr(domain_request, action) - action_method() - - # Check if an email was sent - sent_emails = [ - email - for email in MockSESClient.EMAILS_SENT - if expected_email in email["kwargs"]["Destination"]["ToAddresses"] - ] - self.assertEqual(len(sent_emails), expected_count) - - if expected_content: - email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] - self.assertIn(expected_content, email_content) - - email_allowed.delete() - - @less_console_noise_decorator - def test_submit_from_started_sends_email_to_creator(self): - """tests that we send an email to the creator""" - msg = "Create a domain request and submit it and see if email was sent when the feature flag is on." - domain_request = completed_domain_request(user=self.dummy_user_2) - self.check_email_sent( - domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com" - ) - - @less_console_noise_decorator - def test_submit_from_withdrawn_sends_email(self): - msg = "Create a withdrawn domain request and submit it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user) - self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email) - - @less_console_noise_decorator - def test_submit_from_action_needed_does_not_send_email(self): - msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.check_email_sent(domain_request, msg, "submit", 0) - - @less_console_noise_decorator - def test_submit_from_in_review_does_not_send_email(self): - msg = "Create a withdrawn domain request and submit it and see if email was sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - self.check_email_sent(domain_request, msg, "submit", 0) - - @less_console_noise_decorator - def test_approve_sends_email(self): - msg = "Create a domain request and approve it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) - self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email) - - @less_console_noise_decorator - def test_withdraw_sends_email(self): - msg = "Create a domain request and withdraw it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) - self.check_email_sent( - domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email - ) - - @less_console_noise_decorator - def test_reject_sends_email(self): - msg = "Create a domain request and reject it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) - self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) - - @less_console_noise_decorator - def test_reject_with_prejudice_does_not_send_email(self): - msg = "Create a domain request and reject it with prejudice and see if email was sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) - self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0) - - @less_console_noise_decorator - def assert_fsm_transition_raises_error(self, test_cases, method_to_run): - """Given a list of test cases, check if each transition throws the intended error""" - with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - # Retrieve the method by name from the domain_request object and call it - method = getattr(domain_request, method_to_run) - # Call the method - method() - - @less_console_noise_decorator - def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run): - """Given a list of test cases, ensure that none of them throw transition errors""" - with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - # Retrieve the method by name from the DomainRequest object and call it - method = getattr(domain_request, method_to_run) - # Call the method - method() - except exception_type: - self.fail(f"{exception_type} was raised, but it was not expected.") - - @less_console_noise_decorator - def test_submit_transition_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator. - For submit, this should be valid in all cases. - """ - - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator user that is not staff. - For submit, this should be valid in all cases. - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed(self): - """ - Test that calling submit from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed_twice(self): - """ - Test that rotating between submit and in_review doesn't throw an error - """ - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - try: - # Make a submission - self.in_review_domain_request.submit() - - # Rerun the old method to get back to the original state - self.in_review_domain_request.in_review() - - # Make another submission - self.in_review_domain_request.submit() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") - - self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED) - - @less_console_noise_decorator - def test_submit_transition_not_allowed(self): - """ - Test that calling submit against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "submit") - - @less_console_noise_decorator - def test_in_review_transition_allowed(self): - """ - Test that calling in_review from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff. - This should throw an exception. - """ - - test_cases = [ - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed(self): - """ - Test that calling in_review against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_action_needed_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_approved_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_skips_sending_email(self): - """ - Test that calling .approve with send_email=False doesn't actually send - an email - """ - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - self.submitted_domain_request.approve(send_email=False) - - # Assert that no emails were sent - self.assertEqual(len(self.mock_client.EMAILS_SENT), 0) - - @less_console_noise_decorator - def test_approved_transition_not_allowed(self): - """ - Test that calling approve against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_transition_not_allowed_when_domain_already_approved(self): - """ - Test that calling approve whith an already approved requested domain raises - TransitionNotAllowed. - """ - Domain.objects.all().create(name=self.submitted_domain_request.requested_domain.name) - test_cases = [ - (self.submitted_domain_request, FSMDomainRequestError), - ] - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_withdraw_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator. - For withdraw, this should be valid in all cases. - """ - - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff. - For withdraw, this should be valid in all cases. - """ - - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_reject_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_transition_not_allowed_approved_in_review_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call in_review against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.in_review() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call action_needed against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.action_needed() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call reject against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call reject_with_prejudice against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject_with_prejudice() - - @less_console_noise_decorator - def test_approve_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to approved on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.approve() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_in_review_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to in_review on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.in_review() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_action_needed_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to action_needed on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.action_needed() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_has_rationale_returns_true(self): - """has_rationale() returns true when a domain request has no_other_contacts_rationale""" - self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?" - self.started_domain_request.save() - self.assertEquals(self.started_domain_request.has_rationale(), True) - - @less_console_noise_decorator - def test_has_rationale_returns_false(self): - """has_rationale() returns false when a domain request has no no_other_contacts_rationale""" - self.assertEquals(self.started_domain_request.has_rationale(), False) - - @less_console_noise_decorator - def test_has_other_contacts_returns_true(self): - """has_other_contacts() returns true when a domain request has other_contacts""" - # completed_domain_request has other contacts by default - self.assertEquals(self.started_domain_request.has_other_contacts(), True) - - @less_console_noise_decorator - def test_has_other_contacts_returns_false(self): - """has_other_contacts() returns false when a domain request has no other_contacts""" - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False - ) - self.assertEquals(domain_request.has_other_contacts(), False) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index ff2e61939..8530859e2 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -82,6 +82,7 @@ class DomainRequestTests(TestWithUser, WebTest): response = self.app.get(f"/domain-request/{domain_request.id}") # Ensure that the date is still set to None self.assertIsNone(domain_request.last_status_update) + print(response) # We should still grab a date for this field in this event - but it should come from the audit log instead self.assertContains(response, "Started on:") self.assertContains(response, fixed_date.strftime("%B %-d, %Y")) From 75c610476c6813205108e6cf75cabec11b114567 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 9 Oct 2024 14:09:39 -0600 Subject: [PATCH 115/175] Reapply "resolved merge conflict" This reverts commit d1f3f0efb14aa78e1b3de175888d6885e0c84f9c. --- src/registrar/admin.py | 30 +- src/registrar/assets/js/get-gov-admin.js | 21 +- src/registrar/assets/sass/_theme/_admin.scss | 101 +- .../admin/change_form_object_tools.html | 3 +- .../templates/admin/input_with_clipboard.html | 32 +- .../admin/includes/contact_detail_list.html | 2 +- .../admin/includes/detail_table_fieldset.html | 37 +- src/registrar/tests/test_admin.py | 2 +- src/registrar/tests/test_admin_domain.py | 2 +- src/registrar/tests/test_admin_request.py | 54 +- src/registrar/tests/test_models.py | 7 - src/registrar/tests/test_models_requests.py | 1029 +++++++++++++++++ src/registrar/tests/test_views_request.py | 1 - 13 files changed, 1192 insertions(+), 129 deletions(-) create mode 100644 src/registrar/tests/test_models_requests.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ca51e8b72..485a1b07d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1976,18 +1976,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # If the status is not mapped properly, saving could cause # weird issues down the line. Instead, we should block this. + # NEEDS A UNIT TEST should_proceed = False - return should_proceed + return (obj, should_proceed) - request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED - if request_is_not_approved and not obj.domain_is_not_active(): - # If an admin tried to set an approved domain request to - # another status and the related domain is already - # active, shortcut the action and throw a friendly - # error message. This action would still not go through - # shortcut or not as the rules are duplicated on the model, - # but the error would be an ugly Django error screen. + obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + if obj_is_not_approved and not obj.domain_is_not_active(): + # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE: + # This action (moving a request from approved to + # another status) when the domain is already active (READY), + # would still not go through even without this check as the rules are + # duplicated in the model and the error is raised from the model. + # This avoids an ugly Django error screen. error_message = "This action is not permitted. The domain is already active." + elif ( + original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED + and original_obj.requested_domain is not None + and Domain.objects.filter(name=original_obj.requested_domain.name).exists() + ): + # REDUNDANT CHECK: + # This action (approving a request when the domain exists) + # would still not go through even without this check as the rules are + # duplicated in the model and the error is raised from the model. + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE) elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: # This condition should never be triggered. # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 73f3dded1..f44211c6d 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -515,10 +515,14 @@ document.addEventListener('DOMContentLoaded', function() { const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); let lastSentEmailContent = document.getElementById("last-sent-email-content"); const initialDropdownValue = dropdown ? dropdown.value : null; - const initialEmailValue = textarea.value; + let initialEmailValue; + if (textarea) + initialEmailValue = textarea.value // We will use the const to control the modal - let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); + let isEmailAlreadySentConst; + if (lastSentEmailContent) + isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); // We will use the function to control the label and help function isEmailAlreadySent() { return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); @@ -706,18 +710,6 @@ document.addEventListener('DOMContentLoaded', function() { } return ''; } - // Extract the submitter name, title, email, and phone number - const submitterDiv = document.querySelector('.form-row.field-submitter'); - const submitterNameElement = document.getElementById('id_submitter'); - // We have to account for different superuser and analyst markups - const submitterName = submitterNameElement - ? submitterNameElement.options[submitterNameElement.selectedIndex].text - : submitterDiv.querySelector('a').text; - const submitterTitle = extractTextById('contact_info_title', submitterDiv); - const submitterEmail = extractTextById('contact_info_email', submitterDiv); - const submitterPhone = extractTextById('contact_info_phone', submitterDiv); - let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`; - //------ Senior Official const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); @@ -734,7 +726,6 @@ document.addEventListener('DOMContentLoaded', function() { `Current Websites: ${existingWebsites.join(', ')}
    ` + `Rationale:
    ` + `Alternative Domains: ${alternativeDomains.join(', ')}
    ` + - `Submitter: ${submitterInfo}
    ` + `Senior Official: ${seniorOfficialInfo}
    ` + `Other Employees: ${otherContactsSummary}
    `; diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 5cea72c4c..b6bc0d296 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -385,6 +385,7 @@ a.button, font-kerning: auto; font-family: inherit; font-weight: normal; + text-decoration: none !important; } .button svg, .button span, @@ -392,6 +393,9 @@ a.button, .usa-button--dja span { vertical-align: middle; } +.usa-button--dja.usa-button--unstyled { + color: var(--link-fg); +} .usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { background: var(--button-bg); } @@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar { input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { border-color: var(--body-quiet-color); } -// Targets the DJA buttom with a nested icon -button .usa-icon, -.button .usa-icon, -.button--clipboard .usa-icon { - vertical-align: middle; +.admin-icon-group { + position: relative; + display: inline; + align-items: center; + + input { + // Allow for padding around the copy button + padding-right: 35px !important; + } + + button { + width: max-content; + } + + @media (max-width: 1000px) { + button { + display: block; + } + } + + span { + padding-left: 0.05rem; + } + +} +.usa-button__small-text, +.usa-button__small-text span { + font-size: 13px; } .module--custom { @@ -673,71 +700,10 @@ address.dja-address-contact-list { } } -// Make the clipboard button "float" inside of the input box -.admin-icon-group { - position: relative; - display: inline; - align-items: center; - - input { - // Allow for padding around the copy button - padding-right: 35px !important; - // Match the height of other inputs - min-height: 2.25rem !important; - } - - button { - line-height: 14px; - width: max-content; - font-size: unset; - text-decoration: none !important; - } - - @media (max-width: 1000px) { - button { - display: block; - padding-top: 8px; - } - } - - span { - padding-left: 0.1rem; - } - -} - -.admin-icon-group.admin-icon-group__clipboard-link { - position: relative; - display: inline; - align-items: center; - - - .usa-button--icon { - position: absolute; - right: auto; - left: 4px; - height: 100%; - top: -1px; - } - button { - font-size: unset !important; - display: inline-flex; - padding-top: 4px; - line-height: 14px; - width: max-content; - font-size: unset; - text-decoration: none !important; - } -} - .no-outline-on-click:focus { outline: none !important; } -.usa-button__small-text { - font-size: small; -} - // Get rid of padding on all help texts form .aligned p.help, form .aligned div.help { padding-left: 0px !important; @@ -887,6 +853,9 @@ div.dja__model-description{ padding-top: 0 !important; } +.padding-bottom-0 { + padding-bottom: 0 !important; +} .flex-container { @media screen and (min-width: 700px) and (max-width: 1150px) { diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 198140c19..66011a3c4 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -20,10 +20,11 @@ {% if opts.model_name == 'domainrequest' %}
  • - + + {% translate "Copy request summary" %}
  • diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index 5ad2b27f7..d6a016fd5 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@ Template for an input field with a clipboard
    {{ field }}
    {% else %} -
    Other contact informationOther contact information
    {{ contact.phone }} - {% if contact.email %} - - - {% endif %} + + + Copy email +
    - + @@ -267,18 +267,31 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index cdc3c97de..9c5e3b582 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "button--clipboard", count=3) + self.assertContains(response, "copy-to-clipboard", count=3) # cleanup this test domain_info.delete() diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index a9b94781f..f02b59a91 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase): self.assertContains(response, "Testy Tester") # Test for the copy link - self.assertContains(response, "button--clipboard") + self.assertContains(response, "copy-to-clipboard") # cleanup from this test domain.delete() diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index a9b073472..f19008ca1 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1511,7 +1511,7 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "button--clipboard", count=4) + self.assertContains(response, "copy-to-clipboard", count=4) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") @@ -1846,6 +1846,58 @@ class TestDomainRequestAdmin(MockEppLib): def test_side_effects_when_saving_approved_to_ineligible(self): self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) + @less_console_noise + def test_error_when_saving_to_approved_and_domain_exists(self): + """Redundant admin check on model transition not allowed.""" + Domain.objects.create(name="wabbitseason.gov") + + new_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov" + ) + + # Create a request object with a superuser + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk)) + request.user = self.superuser + + request.session = {} + + # Use ExitStack to combine patch contexts + with ExitStack() as stack: + # Patch django.contrib.messages.error + stack.enter_context(patch.object(messages, "error")) + + new_request.status = DomainRequest.DomainRequestStatus.APPROVED + + self.admin.save_model(request, new_request, None, True) + + messages.error.assert_called_once_with( + request, + "Cannot approve. Requested domain is already in use.", + ) + + @less_console_noise + def test_no_error_when_saving_to_approved_and_domain_exists(self): + """The negative of the redundant admin check on model transition not allowed.""" + new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED) + + # Create a request object with a superuser + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk)) + request.user = self.superuser + + request.session = {} + + # Use ExitStack to combine patch contexts + with ExitStack() as stack: + # Patch Domain.is_active and django.contrib.messages.error simultaneously + stack.enter_context(patch.object(messages, "error")) + + new_request.status = DomainRequest.DomainRequestStatus.APPROVED + + self.admin.save_model(request, new_request, None, True) + + # Assert that the error message was never called + messages.error.assert_not_called() + def test_has_correct_filters(self): """ This test verifies that DomainRequestAdmin has the correct filters set up. diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 681d04c92..f1d25ece9 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1,7 +1,5 @@ from django.forms import ValidationError from django.test import TestCase -from django.db.utils import IntegrityError -from django.db import transaction from unittest.mock import patch from django.test import RequestFactory @@ -20,23 +18,18 @@ from registrar.models import ( UserPortfolioPermission, AllowedEmail, ) - import boto3_mocking from registrar.models.portfolio import Portfolio from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.transition_domain import TransitionDomain from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore -from registrar.utility.constants import BranchChoices from .common import ( MockSESClient, - less_console_noise, completed_domain_request, - set_domain_request_investigators, create_test_user, ) -from django_fsm import TransitionNotAllowed from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py new file mode 100644 index 000000000..9e86f5f9c --- /dev/null +++ b/src/registrar/tests/test_models_requests.py @@ -0,0 +1,1029 @@ +from django.test import TestCase +from django.db.utils import IntegrityError +from django.db import transaction +from unittest.mock import patch + + +from registrar.models import ( + Contact, + DomainRequest, + DomainInformation, + User, + Website, + Domain, + DraftDomain, + FederalAgency, + AllowedEmail, +) + +import boto3_mocking +from registrar.utility.constants import BranchChoices +from registrar.utility.errors import FSMDomainRequestError + +from .common import ( + MockSESClient, + less_console_noise, + completed_domain_request, + set_domain_request_investigators, +) +from django_fsm import TransitionNotAllowed + +from api.tests.common import less_console_noise_decorator + + +@boto3_mocking.patching +class TestDomainRequest(TestCase): + @less_console_noise_decorator + def setUp(self): + + self.dummy_user, _ = Contact.objects.get_or_create( + email="mayor@igorville.com", first_name="Hello", last_name="World" + ) + self.dummy_user_2, _ = User.objects.get_or_create( + username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World" + ) + self.started_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, + name="started.gov", + ) + self.submitted_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.SUBMITTED, + name="submitted.gov", + ) + self.in_review_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + name="in-review.gov", + ) + self.action_needed_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + name="action-needed.gov", + ) + self.approved_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.APPROVED, + name="approved.gov", + ) + self.withdrawn_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + name="withdrawn.gov", + ) + self.rejected_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.REJECTED, + name="rejected.gov", + ) + self.ineligible_domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.INELIGIBLE, + name="ineligible.gov", + ) + + # Store all domain request statuses in a variable for ease of use + self.all_domain_requests = [ + self.started_domain_request, + self.submitted_domain_request, + self.in_review_domain_request, + self.action_needed_domain_request, + self.approved_domain_request, + self.withdrawn_domain_request, + self.rejected_domain_request, + self.ineligible_domain_request, + ] + + self.mock_client = MockSESClient() + + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + DraftDomain.objects.all().delete() + Domain.objects.all().delete() + User.objects.all().delete() + self.mock_client.EMAILS_SENT.clear() + + def assertNotRaises(self, exception_type): + """Helper method for testing allowed transitions.""" + with less_console_noise(): + return self.assertRaises(Exception, None, exception_type) + + @less_console_noise_decorator + def test_request_is_withdrawable(self): + """Tests the is_withdrawable function""" + domain_request_1 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.SUBMITTED, + name="city2.gov", + ) + domain_request_2 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + name="city3.gov", + ) + domain_request_3 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + name="city4.gov", + ) + domain_request_4 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.REJECTED, + name="city5.gov", + ) + self.assertTrue(domain_request_1.is_withdrawable()) + self.assertTrue(domain_request_2.is_withdrawable()) + self.assertTrue(domain_request_3.is_withdrawable()) + self.assertFalse(domain_request_4.is_withdrawable()) + + @less_console_noise_decorator + def test_request_is_awaiting_review(self): + """Tests the is_awaiting_review function""" + domain_request_1 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.SUBMITTED, + name="city2.gov", + ) + domain_request_2 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + name="city3.gov", + ) + domain_request_3 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + name="city4.gov", + ) + domain_request_4 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.REJECTED, + name="city5.gov", + ) + self.assertTrue(domain_request_1.is_awaiting_review()) + self.assertTrue(domain_request_2.is_awaiting_review()) + self.assertFalse(domain_request_3.is_awaiting_review()) + self.assertFalse(domain_request_4.is_awaiting_review()) + + @less_console_noise_decorator + def test_federal_agency_set_to_non_federal_on_approve(self): + """Ensures that when the federal_agency field is 'none' when .approve() is called, + the field is set to the 'Non-Federal Agency' record""" + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + name="city2.gov", + federal_agency=None, + ) + + # Ensure that the federal agency is None + self.assertEqual(domain_request.federal_agency, None) + + # Approve the request + domain_request.approve() + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) + + # After approval, it should be "Non-Federal agency" + expected_federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() + self.assertEqual(domain_request.federal_agency, expected_federal_agency) + + def test_empty_create_fails(self): + """Can't create a completely empty domain request.""" + with less_console_noise(): + with transaction.atomic(): + with self.assertRaisesRegex(IntegrityError, "creator"): + DomainRequest.objects.create() + + @less_console_noise_decorator + def test_minimal_create(self): + """Can create with just a creator.""" + user, _ = User.objects.get_or_create(username="testy") + domain_request = DomainRequest.objects.create(creator=user) + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED) + + @less_console_noise_decorator + def test_full_create(self): + """Can create with all fields.""" + user, _ = User.objects.get_or_create(username="testy") + contact = Contact.objects.create() + com_website, _ = Website.objects.get_or_create(website="igorville.com") + gov_website, _ = Website.objects.get_or_create(website="igorville.gov") + domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=user, + investigator=user, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_type=BranchChoices.EXECUTIVE, + is_election_board=False, + organization_name="Test", + address_line1="100 Main St.", + address_line2="APT 1A", + state_territory="CA", + zipcode="12345-6789", + senior_official=contact, + requested_domain=domain, + purpose="Igorville rules!", + anything_else="All of Igorville loves the dotgov program.", + is_policy_acknowledged=True, + ) + domain_request.current_websites.add(com_website) + domain_request.alternative_domains.add(gov_website) + domain_request.other_contacts.add(contact) + domain_request.save() + + @less_console_noise_decorator + def test_domain_info(self): + """Can create domain info with all fields.""" + user, _ = User.objects.get_or_create(username="testy") + contact = Contact.objects.create() + domain, _ = Domain.objects.get_or_create(name="igorville.gov") + information = DomainInformation.objects.create( + creator=user, + generic_org_type=DomainInformation.OrganizationChoices.FEDERAL, + federal_type=BranchChoices.EXECUTIVE, + is_election_board=False, + organization_name="Test", + address_line1="100 Main St.", + address_line2="APT 1A", + state_territory="CA", + zipcode="12345-6789", + senior_official=contact, + purpose="Igorville rules!", + anything_else="All of Igorville loves the dotgov program.", + is_policy_acknowledged=True, + domain=domain, + ) + information.other_contacts.add(contact) + information.save() + self.assertEqual(information.domain.id, domain.id) + self.assertEqual(information.id, domain.domain_info.id) + + @less_console_noise_decorator + def test_status_fsm_submit_fail(self): + user, _ = User.objects.get_or_create(username="testy") + domain_request = DomainRequest.objects.create(creator=user) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + with self.assertRaises(ValueError): + # can't submit a domain request with a null domain name + domain_request.submit() + + @less_console_noise_decorator + def test_status_fsm_submit_succeed(self): + user, _ = User.objects.get_or_create(username="testy") + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) + + # no email sent to creator so this emits a log warning + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + domain_request.submit() + self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) + + @less_console_noise_decorator + def check_email_sent( + self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" + ): + """Check if an email was sent after performing an action.""" + email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) + with self.subTest(msg=msg, action=action): + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Perform the specified action + action_method = getattr(domain_request, action) + action_method() + + # Check if an email was sent + sent_emails = [ + email + for email in MockSESClient.EMAILS_SENT + if expected_email in email["kwargs"]["Destination"]["ToAddresses"] + ] + self.assertEqual(len(sent_emails), expected_count) + + if expected_content: + email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] + self.assertIn(expected_content, email_content) + + email_allowed.delete() + + @less_console_noise_decorator + def test_submit_from_started_sends_email_to_creator(self): + """tests that we send an email to the creator""" + msg = "Create a domain request and submit it and see if email was sent when the feature flag is on." + domain_request = completed_domain_request(user=self.dummy_user_2) + self.check_email_sent( + domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com" + ) + + @less_console_noise_decorator + def test_submit_from_withdrawn_sends_email(self): + msg = "Create a withdrawn domain request and submit it and see if email was sent." + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user) + self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email) + + @less_console_noise_decorator + def test_submit_from_action_needed_does_not_send_email(self): + msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent." + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED) + self.check_email_sent(domain_request, msg, "submit", 0) + + @less_console_noise_decorator + def test_submit_from_in_review_does_not_send_email(self): + msg = "Create a withdrawn domain request and submit it and see if email was sent." + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + self.check_email_sent(domain_request, msg, "submit", 0) + + @less_console_noise_decorator + def test_approve_sends_email(self): + msg = "Create a domain request and approve it and see if email was sent." + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) + self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email) + + @less_console_noise_decorator + def test_withdraw_sends_email(self): + msg = "Create a domain request and withdraw it and see if email was sent." + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) + self.check_email_sent( + domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email + ) + + @less_console_noise_decorator + def test_reject_sends_email(self): + msg = "Create a domain request and reject it and see if email was sent." + user, _ = User.objects.get_or_create(username="testy") + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) + self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) + + @less_console_noise_decorator + def test_reject_with_prejudice_does_not_send_email(self): + msg = "Create a domain request and reject it with prejudice and see if email was sent." + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) + self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0) + + @less_console_noise_decorator + def assert_fsm_transition_raises_error(self, test_cases, method_to_run): + """Given a list of test cases, check if each transition throws the intended error""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): + for domain_request, exception_type in test_cases: + with self.subTest(domain_request=domain_request, exception_type=exception_type): + with self.assertRaises(exception_type): + # Retrieve the method by name from the domain_request object and call it + method = getattr(domain_request, method_to_run) + # Call the method + method() + + @less_console_noise_decorator + def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run): + """Given a list of test cases, ensure that none of them throw transition errors""" + with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): + for domain_request, exception_type in test_cases: + with self.subTest(domain_request=domain_request, exception_type=exception_type): + try: + # Retrieve the method by name from the DomainRequest object and call it + method = getattr(domain_request, method_to_run) + # Call the method + method() + except exception_type: + self.fail(f"{exception_type} was raised, but it was not expected.") + + @less_console_noise_decorator + def test_submit_transition_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator. + For submit, this should be valid in all cases. + """ + + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + + @less_console_noise_decorator + def test_submit_transition_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator user that is not staff. + For submit, this should be valid in all cases. + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + + @less_console_noise_decorator + def test_submit_transition_allowed(self): + """ + Test that calling submit from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + + @less_console_noise_decorator + def test_submit_transition_allowed_twice(self): + """ + Test that rotating between submit and in_review doesn't throw an error + """ + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + try: + # Make a submission + self.in_review_domain_request.submit() + + # Rerun the old method to get back to the original state + self.in_review_domain_request.in_review() + + # Make another submission + self.in_review_domain_request.submit() + except TransitionNotAllowed: + self.fail("TransitionNotAllowed was raised, but it was not expected.") + + self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED) + + @less_console_noise_decorator + def test_submit_transition_not_allowed(self): + """ + Test that calling submit against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "submit") + + @less_console_noise_decorator + def test_in_review_transition_allowed(self): + """ + Test that calling in_review from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review") + + @less_console_noise_decorator + def test_in_review_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "in_review") + + @less_console_noise_decorator + def test_in_review_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff. + This should throw an exception. + """ + + test_cases = [ + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "in_review") + + @less_console_noise_decorator + def test_in_review_transition_not_allowed(self): + """ + Test that calling in_review against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "in_review") + + @less_console_noise_decorator + def test_action_needed_transition_allowed(self): + """ + Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed") + + @less_console_noise_decorator + def test_action_needed_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") + + @less_console_noise_decorator + def test_action_needed_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") + + @less_console_noise_decorator + def test_action_needed_transition_not_allowed(self): + """ + Test that calling action_needed against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.submitted_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "action_needed") + + @less_console_noise_decorator + def test_approved_transition_allowed(self): + """ + Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "approve") + + @less_console_noise_decorator + def test_approved_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "approve") + + @less_console_noise_decorator + def test_approved_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition with an investigator that is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "approve") + + @less_console_noise_decorator + def test_approved_skips_sending_email(self): + """ + Test that calling .approve with send_email=False doesn't actually send + an email + """ + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + self.submitted_domain_request.approve(send_email=False) + + # Assert that no emails were sent + self.assertEqual(len(self.mock_client.EMAILS_SENT), 0) + + @less_console_noise_decorator + def test_approved_transition_not_allowed(self): + """ + Test that calling approve against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + self.assert_fsm_transition_raises_error(test_cases, "approve") + + @less_console_noise_decorator + def test_approved_transition_not_allowed_when_domain_already_approved(self): + """ + Test that calling approve whith an already approved requested domain raises + TransitionNotAllowed. + """ + Domain.objects.all().create(name=self.submitted_domain_request.requested_domain.name) + test_cases = [ + (self.submitted_domain_request, FSMDomainRequestError), + ] + self.assert_fsm_transition_raises_error(test_cases, "approve") + + @less_console_noise_decorator + def test_withdraw_transition_allowed(self): + """ + Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + + @less_console_noise_decorator + def test_withdraw_transition_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator. + For withdraw, this should be valid in all cases. + """ + + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + + @less_console_noise_decorator + def test_withdraw_transition_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff. + For withdraw, this should be valid in all cases. + """ + + test_cases = [ + (self.submitted_domain_request, TransitionNotAllowed), + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + + @less_console_noise_decorator + def test_withdraw_transition_not_allowed(self): + """ + Test that calling action_needed against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "withdraw") + + @less_console_noise_decorator + def test_reject_transition_allowed(self): + """ + Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "reject") + + @less_console_noise_decorator + def test_reject_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "reject") + + @less_console_noise_decorator + def test_reject_transition_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "reject") + + @less_console_noise_decorator + def test_reject_transition_not_allowed(self): + """ + Test that calling action_needed against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.submitted_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "reject") + + @less_console_noise_decorator + def test_reject_with_prejudice_transition_allowed(self): + """ + Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. + """ + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice") + + @less_console_noise_decorator + def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self): + """ + Tests for attempting to transition without an investigator + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to none + set_domain_request_investigators(self.all_domain_requests, None) + + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + + @less_console_noise_decorator + def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self): + """ + Tests for attempting to transition when investigator is not staff + """ + + test_cases = [ + (self.in_review_domain_request, TransitionNotAllowed), + (self.action_needed_domain_request, TransitionNotAllowed), + (self.approved_domain_request, TransitionNotAllowed), + (self.rejected_domain_request, TransitionNotAllowed), + ] + + # Set all investigators to a user with no staff privs + user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) + set_domain_request_investigators(self.all_domain_requests, user) + + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + + @less_console_noise_decorator + def test_reject_with_prejudice_transition_not_allowed(self): + """ + Test that calling action_needed against transition rules raises TransitionNotAllowed. + """ + test_cases = [ + (self.started_domain_request, TransitionNotAllowed), + (self.submitted_domain_request, TransitionNotAllowed), + (self.withdrawn_domain_request, TransitionNotAllowed), + (self.ineligible_domain_request, TransitionNotAllowed), + ] + + self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + + @less_console_noise_decorator + def test_transition_not_allowed_approved_in_review_when_domain_is_active(self): + """Create a domain request with status approved, create a matching domain that + is active, and call in_review against transition rules""" + + domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) + self.approved_domain_request.approved_domain = domain + self.approved_domain_request.save() + + # Define a custom implementation for is_active + def custom_is_active(self): + return True # Override to return True + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.in_review() + + @less_console_noise_decorator + def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self): + """Create a domain request with status approved, create a matching domain that + is active, and call action_needed against transition rules""" + + domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) + self.approved_domain_request.approved_domain = domain + self.approved_domain_request.save() + + # Define a custom implementation for is_active + def custom_is_active(self): + return True # Override to return True + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.action_needed() + + @less_console_noise_decorator + def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): + """Create a domain request with status approved, create a matching domain that + is active, and call reject against transition rules""" + + domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) + self.approved_domain_request.approved_domain = domain + self.approved_domain_request.save() + + # Define a custom implementation for is_active + def custom_is_active(self): + return True # Override to return True + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.reject() + + @less_console_noise_decorator + def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self): + """Create a domain request with status approved, create a matching domain that + is active, and call reject_with_prejudice against transition rules""" + + domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) + self.approved_domain_request.approved_domain = domain + self.approved_domain_request.save() + + # Define a custom implementation for is_active + def custom_is_active(self): + return True # Override to return True + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.reject_with_prejudice() + + @less_console_noise_decorator + def test_approve_from_rejected_clears_rejection_reason(self): + """When transitioning from rejected to approved on a domain request, + the rejection_reason is cleared.""" + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.approve() + + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) + self.assertEqual(domain_request.rejection_reason, None) + + @less_console_noise_decorator + def test_in_review_from_rejected_clears_rejection_reason(self): + """When transitioning from rejected to in_review on a domain request, + the rejection_reason is cleared.""" + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.domain_is_not_active = True + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.in_review() + + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) + self.assertEqual(domain_request.rejection_reason, None) + + @less_console_noise_decorator + def test_action_needed_from_rejected_clears_rejection_reason(self): + """When transitioning from rejected to action_needed on a domain request, + the rejection_reason is cleared.""" + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.domain_is_not_active = True + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.action_needed() + + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + self.assertEqual(domain_request.rejection_reason, None) + + @less_console_noise_decorator + def test_has_rationale_returns_true(self): + """has_rationale() returns true when a domain request has no_other_contacts_rationale""" + self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?" + self.started_domain_request.save() + self.assertEquals(self.started_domain_request.has_rationale(), True) + + @less_console_noise_decorator + def test_has_rationale_returns_false(self): + """has_rationale() returns false when a domain request has no no_other_contacts_rationale""" + self.assertEquals(self.started_domain_request.has_rationale(), False) + + @less_console_noise_decorator + def test_has_other_contacts_returns_true(self): + """has_other_contacts() returns true when a domain request has other_contacts""" + # completed_domain_request has other contacts by default + self.assertEquals(self.started_domain_request.has_other_contacts(), True) + + @less_console_noise_decorator + def test_has_other_contacts_returns_false(self): + """has_other_contacts() returns false when a domain request has no other_contacts""" + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False + ) + self.assertEquals(domain_request.has_other_contacts(), False) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 8530859e2..ff2e61939 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest): response = self.app.get(f"/domain-request/{domain_request.id}") # Ensure that the date is still set to None self.assertIsNone(domain_request.last_status_update) - print(response) # We should still grab a date for this field in this event - but it should come from the audit log instead self.assertContains(response, "Started on:") self.assertContains(response, fixed_date.strftime("%B %-d, %Y")) From f0b0e9d246f0331b5bd84de9f46e1a1053177963 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 9 Oct 2024 14:12:38 -0600 Subject: [PATCH 116/175] Manually correct faulty unit test merge --- src/registrar/tests/test_models.py | 1017 ---------------------------- 1 file changed, 1017 deletions(-) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index f1d25ece9..4101cecd3 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -34,1023 +34,6 @@ from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator -@boto3_mocking.patching -class TestDomainRequest(TestCase): - @less_console_noise_decorator - def setUp(self): - - self.dummy_user, _ = Contact.objects.get_or_create( - email="mayor@igorville.com", first_name="Hello", last_name="World" - ) - self.dummy_user_2, _ = User.objects.get_or_create( - username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World" - ) - self.started_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.STARTED, - name="started.gov", - ) - self.submitted_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="submitted.gov", - ) - self.in_review_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="in-review.gov", - ) - self.action_needed_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="action-needed.gov", - ) - self.approved_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.APPROVED, - name="approved.gov", - ) - self.withdrawn_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - name="withdrawn.gov", - ) - self.rejected_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="rejected.gov", - ) - self.ineligible_domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.INELIGIBLE, - name="ineligible.gov", - ) - - # Store all domain request statuses in a variable for ease of use - self.all_domain_requests = [ - self.started_domain_request, - self.submitted_domain_request, - self.in_review_domain_request, - self.action_needed_domain_request, - self.approved_domain_request, - self.withdrawn_domain_request, - self.rejected_domain_request, - self.ineligible_domain_request, - ] - - self.mock_client = MockSESClient() - - def tearDown(self): - super().tearDown() - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - DraftDomain.objects.all().delete() - Domain.objects.all().delete() - User.objects.all().delete() - self.mock_client.EMAILS_SENT.clear() - - def assertNotRaises(self, exception_type): - """Helper method for testing allowed transitions.""" - with less_console_noise(): - return self.assertRaises(Exception, None, exception_type) - - @less_console_noise_decorator - def test_request_is_withdrawable(self): - """Tests the is_withdrawable function""" - domain_request_1 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="city2.gov", - ) - domain_request_2 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city3.gov", - ) - domain_request_3 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="city4.gov", - ) - domain_request_4 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="city5.gov", - ) - self.assertTrue(domain_request_1.is_withdrawable()) - self.assertTrue(domain_request_2.is_withdrawable()) - self.assertTrue(domain_request_3.is_withdrawable()) - self.assertFalse(domain_request_4.is_withdrawable()) - - @less_console_noise_decorator - def test_request_is_awaiting_review(self): - """Tests the is_awaiting_review function""" - domain_request_1 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.SUBMITTED, - name="city2.gov", - ) - domain_request_2 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city3.gov", - ) - domain_request_3 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - name="city4.gov", - ) - domain_request_4 = completed_domain_request( - status=DomainRequest.DomainRequestStatus.REJECTED, - name="city5.gov", - ) - self.assertTrue(domain_request_1.is_awaiting_review()) - self.assertTrue(domain_request_2.is_awaiting_review()) - self.assertFalse(domain_request_3.is_awaiting_review()) - self.assertFalse(domain_request_4.is_awaiting_review()) - - @less_console_noise_decorator - def test_federal_agency_set_to_non_federal_on_approve(self): - """Ensures that when the federal_agency field is 'none' when .approve() is called, - the field is set to the 'Non-Federal Agency' record""" - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, - name="city2.gov", - federal_agency=None, - ) - - # Ensure that the federal agency is None - self.assertEqual(domain_request.federal_agency, None) - - # Approve the request - domain_request.approve() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - - # After approval, it should be "Non-Federal agency" - expected_federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() - self.assertEqual(domain_request.federal_agency, expected_federal_agency) - - def test_empty_create_fails(self): - """Can't create a completely empty domain request.""" - with less_console_noise(): - with transaction.atomic(): - with self.assertRaisesRegex(IntegrityError, "creator"): - DomainRequest.objects.create() - - @less_console_noise_decorator - def test_minimal_create(self): - """Can create with just a creator.""" - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED) - - @less_console_noise_decorator - def test_full_create(self): - """Can create with all fields.""" - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - com_website, _ = Website.objects.get_or_create(website="igorville.com") - gov_website, _ = Website.objects.get_or_create(website="igorville.gov") - domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=user, - investigator=user, - generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - requested_domain=domain, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - ) - domain_request.current_websites.add(com_website) - domain_request.alternative_domains.add(gov_website) - domain_request.other_contacts.add(contact) - domain_request.save() - - @less_console_noise_decorator - def test_domain_info(self): - """Can create domain info with all fields.""" - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - domain, _ = Domain.objects.get_or_create(name="igorville.gov") - information = DomainInformation.objects.create( - creator=user, - generic_org_type=DomainInformation.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - domain=domain, - ) - information.other_contacts.add(contact) - information.save() - self.assertEqual(information.domain.id, domain.id) - self.assertEqual(information.id, domain.domain_info.id) - - @less_console_noise_decorator - def test_status_fsm_submit_fail(self): - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - with self.assertRaises(ValueError): - # can't submit a domain request with a null domain name - domain_request.submit() - - @less_console_noise_decorator - def test_status_fsm_submit_succeed(self): - user, _ = User.objects.get_or_create(username="testy") - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) - - # no email sent to creator so this emits a log warning - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - domain_request.submit() - self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) - - @less_console_noise_decorator - def check_email_sent( - self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" - ): - """Check if an email was sent after performing an action.""" - email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) - with self.subTest(msg=msg, action=action): - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Perform the specified action - action_method = getattr(domain_request, action) - action_method() - - # Check if an email was sent - sent_emails = [ - email - for email in MockSESClient.EMAILS_SENT - if expected_email in email["kwargs"]["Destination"]["ToAddresses"] - ] - self.assertEqual(len(sent_emails), expected_count) - - if expected_content: - email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] - self.assertIn(expected_content, email_content) - - email_allowed.delete() - - @less_console_noise_decorator - def test_submit_from_started_sends_email_to_creator(self): - """tests that we send an email to the creator""" - msg = "Create a domain request and submit it and see if email was sent when the feature flag is on." - domain_request = completed_domain_request(user=self.dummy_user_2) - self.check_email_sent( - domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com" - ) - - @less_console_noise_decorator - def test_submit_from_withdrawn_sends_email(self): - msg = "Create a withdrawn domain request and submit it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user) - self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email) - - @less_console_noise_decorator - def test_submit_from_action_needed_does_not_send_email(self): - msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.check_email_sent(domain_request, msg, "submit", 0) - - @less_console_noise_decorator - def test_submit_from_in_review_does_not_send_email(self): - msg = "Create a withdrawn domain request and submit it and see if email was sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - self.check_email_sent(domain_request, msg, "submit", 0) - - @less_console_noise_decorator - def test_approve_sends_email(self): - msg = "Create a domain request and approve it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) - self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email) - - @less_console_noise_decorator - def test_withdraw_sends_email(self): - msg = "Create a domain request and withdraw it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user) - self.check_email_sent( - domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email - ) - - @less_console_noise_decorator - def test_reject_sends_email(self): - msg = "Create a domain request and reject it and see if email was sent." - user, _ = User.objects.get_or_create(username="testy") - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) - self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) - - @less_console_noise_decorator - def test_reject_with_prejudice_does_not_send_email(self): - msg = "Create a domain request and reject it with prejudice and see if email was sent." - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) - self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0) - - @less_console_noise_decorator - def assert_fsm_transition_raises_error(self, test_cases, method_to_run): - """Given a list of test cases, check if each transition throws the intended error""" - with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - with self.assertRaises(exception_type): - # Retrieve the method by name from the domain_request object and call it - method = getattr(domain_request, method_to_run) - # Call the method - method() - - @less_console_noise_decorator - def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run): - """Given a list of test cases, ensure that none of them throw transition errors""" - with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): - for domain_request, exception_type in test_cases: - with self.subTest(domain_request=domain_request, exception_type=exception_type): - try: - # Retrieve the method by name from the DomainRequest object and call it - method = getattr(domain_request, method_to_run) - # Call the method - method() - except exception_type: - self.fail(f"{exception_type} was raised, but it was not expected.") - - @less_console_noise_decorator - def test_submit_transition_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator. - For submit, this should be valid in all cases. - """ - - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator user that is not staff. - For submit, this should be valid in all cases. - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed(self): - """ - Test that calling submit from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") - - @less_console_noise_decorator - def test_submit_transition_allowed_twice(self): - """ - Test that rotating between submit and in_review doesn't throw an error - """ - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - try: - # Make a submission - self.in_review_domain_request.submit() - - # Rerun the old method to get back to the original state - self.in_review_domain_request.in_review() - - # Make another submission - self.in_review_domain_request.submit() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") - - self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED) - - @less_console_noise_decorator - def test_submit_transition_not_allowed(self): - """ - Test that calling submit against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "submit") - - @less_console_noise_decorator - def test_in_review_transition_allowed(self): - """ - Test that calling in_review from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff. - This should throw an exception. - """ - - test_cases = [ - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_in_review_transition_not_allowed(self): - """ - Test that calling in_review against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "in_review") - - @less_console_noise_decorator - def test_action_needed_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_action_needed_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "action_needed") - - @less_console_noise_decorator - def test_approved_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition with an investigator that is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_approved_skips_sending_email(self): - """ - Test that calling .approve with send_email=False doesn't actually send - an email - """ - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - self.submitted_domain_request.approve(send_email=False) - - # Assert that no emails were sent - self.assertEqual(len(self.mock_client.EMAILS_SENT), 0) - - @less_console_noise_decorator - def test_approved_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - self.assert_fsm_transition_raises_error(test_cases, "approve") - - @less_console_noise_decorator - def test_withdraw_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator. - For withdraw, this should be valid in all cases. - """ - - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff. - For withdraw, this should be valid in all cases. - """ - - test_cases = [ - (self.submitted_domain_request, TransitionNotAllowed), - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_withdraw_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "withdraw") - - @less_console_noise_decorator - def test_reject_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "reject") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_allowed(self): - """ - Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. - """ - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self): - """ - Tests for attempting to transition without an investigator - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to none - set_domain_request_investigators(self.all_domain_requests, None) - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self): - """ - Tests for attempting to transition when investigator is not staff - """ - - test_cases = [ - (self.in_review_domain_request, TransitionNotAllowed), - (self.action_needed_domain_request, TransitionNotAllowed), - (self.approved_domain_request, TransitionNotAllowed), - (self.rejected_domain_request, TransitionNotAllowed), - ] - - # Set all investigators to a user with no staff privs - user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False) - set_domain_request_investigators(self.all_domain_requests, user) - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_reject_with_prejudice_transition_not_allowed(self): - """ - Test that calling action_needed against transition rules raises TransitionNotAllowed. - """ - test_cases = [ - (self.started_domain_request, TransitionNotAllowed), - (self.submitted_domain_request, TransitionNotAllowed), - (self.withdrawn_domain_request, TransitionNotAllowed), - (self.ineligible_domain_request, TransitionNotAllowed), - ] - - self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") - - @less_console_noise_decorator - def test_transition_not_allowed_approved_in_review_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call in_review against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.in_review() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call action_needed against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.action_needed() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call reject against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject() - - @less_console_noise_decorator - def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self): - """Create a domain request with status approved, create a matching domain that - is active, and call reject_with_prejudice against transition rules""" - - domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name) - self.approved_domain_request.approved_domain = domain - self.approved_domain_request.save() - - # Define a custom implementation for is_active - def custom_is_active(self): - return True # Override to return True - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject_with_prejudice() - - @less_console_noise_decorator - def test_approve_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to approved on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.approve() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_in_review_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to in_review on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.in_review() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_action_needed_from_rejected_clears_rejection_reason(self): - """When transitioning from rejected to action_needed on a domain request, - the rejection_reason is cleared.""" - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.action_needed() - - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.assertEqual(domain_request.rejection_reason, None) - - @less_console_noise_decorator - def test_has_rationale_returns_true(self): - """has_rationale() returns true when a domain request has no_other_contacts_rationale""" - self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?" - self.started_domain_request.save() - self.assertEquals(self.started_domain_request.has_rationale(), True) - - @less_console_noise_decorator - def test_has_rationale_returns_false(self): - """has_rationale() returns false when a domain request has no no_other_contacts_rationale""" - self.assertEquals(self.started_domain_request.has_rationale(), False) - - @less_console_noise_decorator - def test_has_other_contacts_returns_true(self): - """has_other_contacts() returns true when a domain request has other_contacts""" - # completed_domain_request has other contacts by default - self.assertEquals(self.started_domain_request.has_other_contacts(), True) - - @less_console_noise_decorator - def test_has_other_contacts_returns_false(self): - """has_other_contacts() returns false when a domain request has no other_contacts""" - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False - ) - self.assertEquals(domain_request.has_other_contacts(), False) - - -class TestPermissions(TestCase): - """Test the User-Domain-Role connection.""" - - def setUp(self): - super().setUp() - self.mock_client = MockSESClient() - - def tearDown(self): - super().tearDown() - self.mock_client.EMAILS_SENT.clear() - - @boto3_mocking.patching - @less_console_noise_decorator - def test_approval_creates_role(self): - draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") - user, _ = User.objects.get_or_create() - investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True) - domain_request = DomainRequest.objects.create( - creator=user, requested_domain=draft_domain, investigator=investigator - ) - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # skip using the submit method - domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED - domain_request.approve() - - # should be a role for this user - domain = Domain.objects.get(name="igorville.gov") - self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain)) - - class TestDomainInformation(TestCase): """Test the DomainInformation model, when approved or otherwise""" From f1be3fafebc6dd7b1e7d97060a7aa9ae061855e8 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 9 Oct 2024 14:23:03 -0600 Subject: [PATCH 117/175] linted + comments --- src/registrar/models/user_portfolio_permission.py | 10 ++++++++-- src/registrar/tests/test_models.py | 9 ++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 825e82c88..ed233dfea 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -110,8 +110,14 @@ class UserPortfolioPermission(TimeStampedModel): # Check if a user is set without accessing the related object. has_user = bool(self.user_id) if has_user: - existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list("pk", flat=True) - if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permission_pks.exists() and not self.pk in existing_permission_pks: + existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list( + "pk", flat=True + ) + if ( + not flag_is_active_for_user(self.user, "multiple_portfolios") + and existing_permission_pks.exists() + and self.pk not in existing_permission_pks + ): raise ValidationError( "This user is already assigned to a portfolio. " "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 4101cecd3..d269dc912 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -34,6 +34,7 @@ from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator + class TestDomainInformation(TestCase): """Test the DomainInformation model, when approved or otherwise""" @@ -282,7 +283,7 @@ class TestUserPortfolioPermission(TestCase): portfolio_permission_2.clean() except ValidationError as error: self.fail(f"Raised ValidationError unexpectedly: {error}") - + @less_console_noise_decorator @override_flag("multiple_portfolios", active=False) def test_clean_on_creates_multiple_portfolios(self): @@ -310,7 +311,7 @@ class TestUserPortfolioPermission(TestCase): "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." ), ) - + @less_console_noise_decorator @override_flag("multiple_portfolios", active=False) def test_multiple_portfolio_reassignment(self): @@ -328,7 +329,9 @@ class TestUserPortfolioPermission(TestCase): # This should work as intended portfolio_permission.clean() portfolio_permission_2.clean() - + + # Reassign the portfolio of "user2" to "user" (this should throw an error + # preventing "user" from having multiple portfolios) with self.assertRaises(ValidationError) as cm: portfolio_permission_2.user = self.user portfolio_permission_2.clean() From aec1a4f0d439e9892160fd4ee6620a4cb1c5c1d0 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:15:45 -0700 Subject: [PATCH 118/175] Reverse email send check --- src/registrar/views/domain.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 332af5978..95fa78417 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -881,13 +881,12 @@ class DomainAddUserView(DomainFormBaseView): Throws EmailSendingError.""" requested_email = form.cleaned_data["email"] requestor = self.request.user - email_success = False + email_success = True # look up a user with that email try: requested_user = User.objects.get(email=requested_email) except User.DoesNotExist: # no matching user, go make an invitation - email_success = True return self._make_invitation(requested_email, requestor) else: # if user already exists then just send an email @@ -895,7 +894,6 @@ class DomainAddUserView(DomainFormBaseView): self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) - email_success = True except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -904,6 +902,7 @@ class DomainAddUserView(DomainFormBaseView): ) messages.warning(self.request, "Could not send email invitation.") except OutsideOrgMemberError: + email_send = False logger.warn( "Could not send email. Can not invite member of a .gov organization to a different organization.", self.object, From ddc98b63717afcbbd05a938e480c770f7eee9fba Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 9 Oct 2024 16:55:48 -0600 Subject: [PATCH 119/175] Remaining ANDI updates --- src/registrar/assets/js/get-gov.js | 10 ++++++++-- src/registrar/forms/domain_request_wizard.py | 3 ++- src/registrar/templates/includes/senior_official.html | 4 +--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 8a07b3f27..0bf62565d 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1625,8 +1625,14 @@ class DomainRequestsTable extends LoadTableBase { ` } - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - if (request.is_deletable) { + if (!request.is_deletable) { + // If the request is not deletable, insert a message + // for the screenreader to pickup explaining the empty table cell + modalTrigger = ` + Domain request cannot be deleted now. Edit the request for more information.` + } + else { + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages let modalHeading = ''; let modalDescription = ''; diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 29e9fa639..c84ddee78 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -229,7 +229,8 @@ class SeniorOfficialForm(RegistrarForm): email = forms.EmailField( label="Email", max_length=None, - error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, + 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, diff --git a/src/registrar/templates/includes/senior_official.html b/src/registrar/templates/includes/senior_official.html index 073b82457..0302bc71f 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -21,9 +21,7 @@ {% input_with_errors form.first_name %} {% input_with_errors form.last_name %} {% input_with_errors form.title %} - {% with sublabel_text="Enter an email address in the required format, like name@example.com." %} - {% input_with_errors form.email %} - {% endwith %} + {% input_with_errors form.email %} {% elif not form.full_name.value and not form.title.value and not form.email.value %} From 76fc71382da59740384a84b11fd2f464ae1f9df4 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:56:20 -0700 Subject: [PATCH 120/175] Re-revert email_success check --- src/registrar/views/domain.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 95fa78417..332af5978 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -881,12 +881,13 @@ class DomainAddUserView(DomainFormBaseView): Throws EmailSendingError.""" requested_email = form.cleaned_data["email"] requestor = self.request.user - email_success = True + email_success = False # look up a user with that email try: requested_user = User.objects.get(email=requested_email) except User.DoesNotExist: # no matching user, go make an invitation + email_success = True return self._make_invitation(requested_email, requestor) else: # if user already exists then just send an email @@ -894,6 +895,7 @@ class DomainAddUserView(DomainFormBaseView): self._send_domain_invitation_email( requested_email, requestor, requested_user=requested_user, add_success=False ) + email_success = True except EmailSendingError: logger.warn( "Could not send email invitation (EmailSendingError)", @@ -902,7 +904,6 @@ class DomainAddUserView(DomainFormBaseView): ) messages.warning(self.request, "Could not send email invitation.") except OutsideOrgMemberError: - email_send = False logger.warn( "Could not send email. Can not invite member of a .gov organization to a different organization.", self.object, From dd29cbf8cadc168ee9e38641725015dd36eb68d7 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:21:37 -0700 Subject: [PATCH 121/175] Add domain manager after email sending error --- src/registrar/views/domain.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 332af5978..3865bfc36 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -903,6 +903,7 @@ class DomainAddUserView(DomainFormBaseView): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + email_success = True except OutsideOrgMemberError: logger.warn( "Could not send email. Can not invite member of a .gov organization to a different organization.", From bca91a59ef75a334266a0c18f0c215e80cb2b6a6 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 10 Oct 2024 11:22:15 -0500 Subject: [PATCH 122/175] Fix edge case and add test --- src/registrar/tests/test_views_domain.py | 33 ++++++++++++++++++++++++ src/registrar/views/domain.py | 11 ++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 14d504784..559df5d60 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2064,6 +2064,39 @@ class TestDomainChangeNotifications(TestDomainOverview): # Check that an email was not sent self.assertFalse(self.mock_client.send_email.called) + @boto3_mocking.patching + @less_console_noise_decorator + def test_no_notification_on_change_by_analyst(self): + """Test that an email is not sent on org name change when the domain is in a portfolio""" + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + + self.domain_information.organization_name = "Town of Igorville" + self.domain_information.address_line1 = "123 Main St" + self.domain_information.city = "Igorville" + self.domain_information.state_territory = "IL" + self.domain_information.zipcode = "62052" + self.domain_information.portfolio = portfolio + self.domain_information.save() + + org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + session = self.app.session + session["analyst_action"] = "foo" + session["analyst_action_location"] = self.domain.id + session.save() + + org_name_page.form["organization_name"] = "Not igorville" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + org_name_page.form.submit() + + # Check that an email was not sent + self.assertFalse(self.mock_client.send_email.called) + + @boto3_mocking.patching @less_console_noise_decorator def test_notification_on_security_email_change(self): diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 5180fe515..04eab1383 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -173,22 +173,21 @@ class DomainFormBaseView(DomainBaseView, FormMixin): SeniorOfficialContactForm, } - if form.__class__ in form_label_dict: + is_analyst_action = ("analyst_action" in self.session and "analyst_action_location" in self.session) + + if form.__class__ in form_label_dict and not is_analyst_action: # these types of forms can cause notifications should_notify = True if form.__class__ in check_for_portfolio: # some forms shouldn't cause notifications if they are in a portfolio info = self.get_domain_info_from_domain() if not info or info.portfolio: + logger.debug("No notification sent: Domain is part of a portfolio") should_notify = False - elif "analyst_action" in self.session and "analyst_action_location" in self.session: - # action is being made by an analyst - should_notify = False else: # don't notify for any other types of forms should_notify = False - logger.info(f"Not notifying for {form.__class__}") - if (should_notify and form.has_changed()) or force_send: + if should_notify and (form.has_changed() or force_send): context = { "domain": self.object.name, "user": self.request.user, From c2bd4cfe974491ab00eb91943e6cca9d06f87d61 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Thu, 10 Oct 2024 09:59:58 -0700 Subject: [PATCH 123/175] Update wording for suborganization --- src/registrar/templates/domain_suborganization.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html index e6152ecd8..67726e9d5 100644 --- a/src/registrar/templates/domain_suborganization.html +++ b/src/registrar/templates/domain_suborganization.html @@ -11,8 +11,13 @@

    The name of your suborganization will be publicly listed as the domain registrant. - This list of suborganizations has been populated the .gov program. - If you believe there is an error please contact help@get.gov. +

    +

    + When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}. +

    +

    + If you don’t see your suborganization in the menu or need to edit one of the options, + please contact help@get.gov.

    {% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %} From fe59329f8b2a951c40d48bc6df8470e2235f9ffa Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 10 Oct 2024 16:05:00 -0400 Subject: [PATCH 124/175] things so far --- src/registrar/admin.py | 7 ++++++- src/registrar/assets/js/get-gov-admin.js | 16 ++++++++++++++++ .../portfolio_domain_requests_table.html | 14 +++++++++++--- .../portfolio/portfolio_domains_table.html | 8 ++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ca51e8b72..e21f7581b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3208,12 +3208,17 @@ class PortfolioAdmin(ListHeaderAdmin): obj: Portfolio = self.get_object(request, object_id) extra_context = extra_context or {} extra_context["skip_additional_contact_info"] = True + sort_by = request.GET.get('sort', 'requested_domain__name') + order = request.GET.get('order', 'asc') + + order_prefix = '-' if order == 'desc' else '' + domain_requests_order_by = [f"{order_prefix}{sort_by}"] if obj: extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj) extra_context["admins"] = self.get_user_portfolio_permission_admins(obj) extra_context["domains"] = obj.get_domains(order_by=["domain__name"]) - extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"]) + extra_context["domain_requests"] = obj.get_domain_requests(order_by=domain_requests_order_by) return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 73f3dded1..0de2c7c81 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -1035,3 +1035,19 @@ document.addEventListener('DOMContentLoaded', function() { }; } })(); + +(function sortTable(sortBy, order,event) { + event.preventDefault() + const xhr = new XMLHttpRequest(); + xhr.open('GET', `?sort=${sortBy}&order=${order}`, true); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4 && xhr.status === 200) { + // Parse the response and update only the table body or container + const parser = new DOMParser(); + const doc = parser.parseFromString(xhr.responseText, 'text/html'); + const sortedContent = doc.querySelector('#table-content').innerHTML; + document.querySelector('#table-content').innerHTML = sortedContent; + } + }; + xhr.send(); +})(); \ No newline at end of file diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html index 46303efce..9988e62e1 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html @@ -5,11 +5,19 @@
    Other contact informationOther contact information
    {{ contact.phone }} - - + + + + + Copy email + + {% endif %}
    - - + + - + {% for domain_request in domain_requests %} {% url 'admin:registrar_domainrequest_change' domain_request.pk as url %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html index 56621b769..f81bc080c 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html @@ -5,8 +5,12 @@
    NameStatus + + Name + + + + Status + +
    - - + + From 2d88c340a78494d8cb355b245ee71d6d8ae94fb0 Mon Sep 17 00:00:00 2001 From: CuriousX Date: Thu, 10 Oct 2024 21:34:52 -0600 Subject: [PATCH 125/175] Update src/registrar/assets/js/uswds-edited.js Co-authored-by: Rachid Mrad <107004823+rachidatecs@users.noreply.github.com> --- src/registrar/assets/js/uswds-edited.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index 11a71b0df..a76bca276 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -25,7 +25,7 @@ /** * Edits made for dotgov project: * - tooltip exposed to window to be accessible in other js files - * - tooltip postiioning logic updated to allow position:fixed + * - tooltip positioning logic updated to allow position:fixed * - tooltip dynamic content updated to include nested element (for better sizing control) * - modal exposed to window to be accessible in other js files * - fixed bug in createHeaderButton which added newlines to header button tooltips From d791960fd021ab4fa10f1e490cd98e8649e0937b Mon Sep 17 00:00:00 2001 From: CuriousX Date: Thu, 10 Oct 2024 21:35:04 -0600 Subject: [PATCH 126/175] Update src/registrar/assets/js/uswds-edited.js Co-authored-by: Rachid Mrad <107004823+rachidatecs@users.noreply.github.com> --- src/registrar/assets/js/uswds-edited.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index a76bca276..aee5f0552 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5941,7 +5941,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { }; /* - DOTGOV: Tooltip postiioning logic updated to allow position:fixed + DOTGOV: Tooltip positioning logic updated to allow position:fixed */ const tooltipStyle = window.getComputedStyle(tooltipBody); const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed'; From c671c44cbf44484ab1c6481dc5ef4b992f31cb49 Mon Sep 17 00:00:00 2001 From: CuriousX Date: Thu, 10 Oct 2024 21:35:11 -0600 Subject: [PATCH 127/175] Update src/registrar/assets/js/uswds-edited.js Co-authored-by: Rachid Mrad <107004823+rachidatecs@users.noreply.github.com> --- src/registrar/assets/js/uswds-edited.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index aee5f0552..c699f14e6 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -6158,7 +6158,7 @@ const setUpAttributes = tooltipTrigger => { tooltipBody.setAttribute("aria-hidden", "true"); // place the text in the tooltip - // DOTGOV: nest the text element to allow us creater control over width and wrapping behavior + // DOTGOV: nest the text element to allow us greater control over width and wrapping behavior tooltipBody.innerHTML = `
    ${tooltipContent} From b34d4cc140f48e01a3b59afcd0860024bec5dedb Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 10 Oct 2024 22:10:11 -0600 Subject: [PATCH 128/175] implemented feedback --- src/registrar/assets/js/uswds-edited.js | 41 +++++++++++++++---- .../assets/sass/_theme/_tooltips.scss | 2 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index c699f14e6..42ca52b30 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5940,21 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { return offset; }; - /* - DOTGOV: Tooltip positioning logic updated to allow position:fixed - */ + // ---- DOTGOV EDIT (Added section) + // DOTGOV: Tooltip positioning logic updated to allow position:fixed const tooltipStyle = window.getComputedStyle(tooltipBody); const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed'; const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%` const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%` if (tooltipIsFixedPositioned) { - // DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed' - // (so that the tooltip doesn't appear to stick to the screen) + /* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed' + (so that the tooltip doesn't appear to stick to the screen) */ window.addEventListener('scroll', function() { findBestPosition(tooltipBody) }); } + // ---- END DOTGOV EDIT + /** * Positions tooltip at the top * @param {HTMLElement} e - this is the tooltip body @@ -5967,9 +5968,15 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("top"); + // ---- DOTGOV EDIT + // e.style.left = `50%`; // center the element + // e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element + // DOTGOV: updated logic for position:fixed e.style.left = targetLeft; // center the element e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element + // ---- END DOTGOV EDIT + // apply our margins based on the offset e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`; }; @@ -5983,10 +5990,15 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); setPositionClass("bottom"); + // ---- DOTGOV EDIT + // e.style.left = `50%`; + // DOTGOV: updated logic for position:fixed if (tooltipIsFixedPositioned){ e.style.top = triggerRect.bottom+'px'; } + // ---- END DOTGOV EDIT + e.style.left = targetLeft; e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`; }; @@ -6000,10 +6012,15 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); setPositionClass("right"); + // ---- DOTGOV EDIT + // e.style.top = `50%`; + // e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; + // DOTGOV: updated logic for position:fixed e.style.top = targetTop; e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; -; + // ---- END DOTGOV EDIT + e.style.margin = `-${topMargin / 2}px 0 0 0`; }; @@ -6019,9 +6036,15 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => { const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger); setPositionClass("left"); + // ---- DOTGOV EDIT + // e.style.top = `50%`; + // e.style.left = `-${TRIANGLE_SIZE}px`; + // DOTGOV: updated logic for position:fixed e.style.top = targetTop; e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`; + // ---- END DOTGOV EDIT + e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin }; @@ -6158,12 +6181,16 @@ const setUpAttributes = tooltipTrigger => { tooltipBody.setAttribute("aria-hidden", "true"); // place the text in the tooltip + + // -- DOTGOV EDIT + // tooltipBody.textContent = tooltipContent; + // DOTGOV: nest the text element to allow us greater control over width and wrapping behavior tooltipBody.innerHTML = `
    ${tooltipContent}
    ` - // tooltipBody.textContent = tooltipContent; + // -- END DOTGOV EDIT return { tooltipBody, diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 862d43d3c..adb8f43a5 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -66,7 +66,7 @@ text-align: center; font-size: inherit; //inherit tooltip fontsize of .93rem max-width: fit-content; - @include at-media('tablet') { + @include at-media('desktop') { width: 70vw; } } From f1ba7e9209e1fa5d0a296a740b1bab2e59d43fab Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 11 Oct 2024 00:06:31 -0600 Subject: [PATCH 129/175] linted --- src/registrar/forms/domain_request_wizard.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c84ddee78..d76d7ba78 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -27,7 +27,7 @@ class OrganizationTypeForm(RegistrarForm): choices=DomainRequest.OrganizationChoicesVerbose.choices, widget=forms.RadioSelect, error_messages={"required": "Select the type of organization you represent."}, - label="What kind of U.S.-based government organization do you represent?" + label="What kind of U.S.-based government organization do you represent?", ) @@ -71,7 +71,7 @@ class OrganizationFederalForm(RegistrarForm): federal_type = forms.ChoiceField( choices=BranchChoices.choices, widget=forms.RadioSelect, - label = "Which federal branch is your organization in?", + label="Which federal branch is your organization in?", error_messages={"required": ("Select the part of the federal government your organization is in.")}, ) @@ -84,7 +84,7 @@ class OrganizationElectionForm(RegistrarForm): (False, "No"), ], ), - label="Is your organization an election office?" + label="Is your organization an election office?", ) def clean_is_election_board(self): @@ -229,8 +229,10 @@ class SeniorOfficialForm(RegistrarForm): email = forms.EmailField( label="Email", 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.")}, + 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, @@ -444,7 +446,7 @@ class OtherContactsForm(RegistrarForm): message="Response must be less than 320 characters.", ) ], - help_text="Enter an email address in the required format, like name@example.com." + help_text="Enter an email address in the required format, like name@example.com.", ) phone = PhoneNumberField( label="Phone", From ce5a11548e4f2f243f08faebdb6ea1ded7dcbb80 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 10:45:45 -0400 Subject: [PATCH 130/175] updated javascript for code readability --- src/registrar/assets/js/get-gov.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index d44e5f2b7..20c988cdc 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1932,9 +1932,21 @@ class MembersTable extends LoadTableBase { const member_name = member.name; const member_email = member.email; const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const last_active = member.last_active ? member.last_active != 'Invited' ? new Date(member.last_active) : 'Invited' : null; - const last_active_formatted = last_active ? last_active != 'Invited' ? last_active.toLocaleDateString('en-Us', options) : 'Invited' : ''; - const last_active_sort_value = last_active ? last_active != 'Invited' ? last_active.getTime() : 'Invited' : ''; + // set last_active values + // default values + let last_active = null; + let last_active_formatted = ''; + let last_active_sort_value = ''; + // member.last_active could be null, Invited, or a date; below sets values for all scenarios + if (member.last_active && member.last_active != 'Invited') { + last_active = new Date(member.last_active); + last_active_formatted = last_active.toLocaleDateString('en-Us', options); + last_active_sort_value = last_active.getTime(); + } else { + last_active = 'Invited'; + last_active_formatted = 'Invited'; + last_active_sort_value = 'Invited'; + } const action_url = member.action_url; const action_label = member.action_label; const svg_icon = member.svg_icon; From a466b991ff5fd8509916d866d5251b7abf9accc4 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 10:50:24 -0400 Subject: [PATCH 131/175] cleanup of imports --- src/registrar/forms/portfolio.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b8984023a..4151ea9bd 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,12 +4,12 @@ import logging from django import forms from django.core.validators import RegexValidator -from registrar.models.portfolio_invitation import PortfolioInvitation -from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models import ( + PortfolioInvitation, UserPortfolioPermission, + UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial +) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from ..models import DomainInformation, Portfolio, SeniorOfficial - logger = logging.getLogger(__name__) From 20eb1e593babf95e718b6a31bba186f51cbbaa1b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 10:58:13 -0400 Subject: [PATCH 132/175] renaming of mgmt template to management --- .../{member_domain_mgmt.html => member_domain_management.html} | 0 src/registrar/templates/includes/summary_item.html | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/registrar/templates/includes/{member_domain_mgmt.html => member_domain_management.html} (100%) diff --git a/src/registrar/templates/includes/member_domain_mgmt.html b/src/registrar/templates/includes/member_domain_management.html similarity index 100% rename from src/registrar/templates/includes/member_domain_mgmt.html rename to src/registrar/templates/includes/member_domain_management.html diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index aae7b8cf9..0600d7ea7 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -27,7 +27,7 @@ {% if permissions %} {% include "includes/member_permissions.html" with permissions=value %} {% elif domain_mgmt %} - {% include "includes/member_domain_mgmt.html" with domain_count=value %} + {% include "includes/member_domain_management.html" with domain_count=value %} {% elif address %} {% include "includes/organization_address.html" with organization=value %} {% elif contact %} From a31c5ebe318574c6fdf251f72b98c5225e95ab7c Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 11:05:37 -0400 Subject: [PATCH 133/175] fixed improper handling of booleans in template --- src/registrar/templates/portfolio_member.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 5f18442d6..0275f84e9 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -119,18 +119,18 @@ {% if portfolio_permission %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_permission member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% elif portfolio_invitation %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions='true' value=portfolio_invitation member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% endif %} {% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %} {% if portfolio_permission %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_permission.get_managed_domains_count edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %} {% elif portfolio_invitation %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=portfolio_invitation.get_managed_domains_count edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %} {% else %} - {% include "includes/summary_item.html" with title='Domain management' domain_mgmt='true' value=0 edit_link='#' editable='true' manage_button=has_edit_members_portfolio_permission view_button='true' %} + {% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %} {% endif %}
    From 5c305c8557dbaf27a85c73f3dbe816cc6a9a7413 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 11:15:43 -0400 Subject: [PATCH 134/175] safer handling of None values in json view --- src/registrar/views/portfolio_members_json.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 948baa07a..b4e4d230b 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -89,9 +89,9 @@ def apply_search(data_list, request): data_list = [ item for item in data_list - if search_term in (item.get("first_name", "") or "").lower() - or search_term in (item.get("last_name", "") or "").lower() - or search_term in (item.get("email", "") or "").lower() + if search_term in item.get("first_name", "").lower() + or search_term in item.get("last_name", "").lower() + or search_term in item.get("email", "").lower() ] return data_list @@ -153,11 +153,11 @@ def serialize_members(request, portfolio, item, user): # ------- SERIALIZE member_json = { - "id": item["id"], - "name": (item["first_name"] or "") + " " + (item["last_name"] or ""), - "email": item["email"], + "id": item.get("id",""), + "name": item.get("first_name", "") + " " + item.get("last_name", ""), + "email": item.get("email",""), "is_admin": is_admin, - "last_active": item["last_active"], + "last_active": item.get("last_active", None), "action_url": action_url, "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"), From 78a08522237b546236c455be6bb8ca4042640067 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 11 Oct 2024 11:23:42 -0400 Subject: [PATCH 135/175] add member button hidden when user does not have permission --- src/registrar/templates/portfolio_members.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index 82e06c808..ffdb63099 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -18,6 +18,7 @@

    Members

    + {% if has_edit_members_portfolio_permission %} + {% endif %} {% include "includes/members_table.html" with portfolio=portfolio %} From 84c30f69eee8ea63d567563bf591fc62bd8cdb34 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 11 Oct 2024 13:36:15 -0500 Subject: [PATCH 136/175] minor update to email template --- src/registrar/templates/emails/update_to_approved_domain.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 8e615c30c..99f86ea54 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -26,4 +26,6 @@ THANK YOU The .gov team Contact us Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) {% endautoescape %} \ No newline at end of file From bc80c13bd32365dcc44f576510bf6c31fffdb7cb Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 11 Oct 2024 14:56:24 -0500 Subject: [PATCH 137/175] fix notifications on analyst action --- src/registrar/views/domain.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 04eab1383..e54ec9c8f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -174,16 +174,20 @@ class DomainFormBaseView(DomainBaseView, FormMixin): } is_analyst_action = ("analyst_action" in self.session and "analyst_action_location" in self.session) + should_notify=False - if form.__class__ in form_label_dict and not is_analyst_action: - # these types of forms can cause notifications - should_notify = True - if form.__class__ in check_for_portfolio: - # some forms shouldn't cause notifications if they are in a portfolio - info = self.get_domain_info_from_domain() - if not info or info.portfolio: - logger.debug("No notification sent: Domain is part of a portfolio") - should_notify = False + if form.__class__ in form_label_dict: + if is_analyst_action: + logger.debug("No notification sent: Action was conducted by an analyst") + else: + # these types of forms can cause notifications + should_notify = True + if form.__class__ in check_for_portfolio: + # some forms shouldn't cause notifications if they are in a portfolio + info = self.get_domain_info_from_domain() + if not info or info.portfolio: + logger.debug("No notification sent: Domain is part of a portfolio") + should_notify = False else: # don't notify for any other types of forms should_notify = False @@ -202,7 +206,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): ) else: logger.info( - f"Not notifying for {form.__class__}, form changes: {form.has_changed()}, force_send: {force_send}" + f"No notification sent for {form.__class__}. form changes: {form.has_changed()}, force_send: {force_send}" ) def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}): From b683bb1d45a80efecc21422e3dc4cc8d469f9cc1 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 11 Oct 2024 16:09:58 -0400 Subject: [PATCH 138/175] fix first and last name join and lint --- src/registrar/forms/portfolio.py | 8 ++++++-- src/registrar/views/portfolio_members_json.py | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 4151ea9bd..61c2215fa 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -5,8 +5,12 @@ from django import forms from django.core.validators import RegexValidator from registrar.models import ( - PortfolioInvitation, UserPortfolioPermission, - UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial + PortfolioInvitation, + UserPortfolioPermission, + UserPortfolioPermission, + DomainInformation, + Portfolio, + SeniorOfficial, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index b4e4d230b..15cda5a9d 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -153,9 +153,9 @@ def serialize_members(request, portfolio, item, user): # ------- SERIALIZE member_json = { - "id": item.get("id",""), - "name": item.get("first_name", "") + " " + item.get("last_name", ""), - "email": item.get("email",""), + "id": item.get("id", ""), + "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), + "email": item.get("email", ""), "is_admin": is_admin, "last_active": item.get("last_active", None), "action_url": action_url, From 1aaafb205a07fc95a82f455f7b480873e0d1ea9f Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 11 Oct 2024 16:15:38 -0400 Subject: [PATCH 139/175] fix search --- src/registrar/views/portfolio_members_json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 15cda5a9d..7c9e9c4f7 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -89,9 +89,9 @@ def apply_search(data_list, request): data_list = [ item for item in data_list - if search_term in item.get("first_name", "").lower() - or search_term in item.get("last_name", "").lower() - or search_term in item.get("email", "").lower() + if item.get("first_name", "") and search_term in item.get("first_name", "").lower() + or item.get("last_name", "") and search_term in item.get("last_name", "").lower() + or item.get("email", "") and search_term in item.get("email", "").lower() ] return data_list From b103a307e657620c12102d7b1a64f9e8f4f81958 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 11 Oct 2024 16:17:24 -0400 Subject: [PATCH 140/175] lint --- src/registrar/forms/portfolio.py | 1 - src/registrar/views/portfolio_members_json.py | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 61c2215fa..d24943aac 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -7,7 +7,6 @@ from django.core.validators import RegexValidator from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, - UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 7c9e9c4f7..4978d422b 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -89,9 +89,12 @@ def apply_search(data_list, request): data_list = [ item for item in data_list - if item.get("first_name", "") and search_term in item.get("first_name", "").lower() - or item.get("last_name", "") and search_term in item.get("last_name", "").lower() - or item.get("email", "") and search_term in item.get("email", "").lower() + if item.get("first_name", "") + and search_term in item.get("first_name", "").lower() + or item.get("last_name", "") + and search_term in item.get("last_name", "").lower() + or item.get("email", "") + and search_term in item.get("email", "").lower() ] return data_list From af83636e02ec0f6055dfb67b5c67dfd7f696f413 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 11 Oct 2024 17:47:03 -0400 Subject: [PATCH 141/175] refactor portfolio_members_json and move back the computation to the ORM --- src/registrar/assets/js/get-gov.js | 35 ++-- src/registrar/views/portfolio_members_json.py | 196 ++++++++---------- 2 files changed, 104 insertions(+), 127 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 20c988cdc..5c1967d15 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1880,11 +1880,10 @@ class MembersTable extends LoadTableBase { * @param {*} sortBy - the sort column option * @param {*} order - the sort order {asc, desc} * @param {*} scroll - control for the scrollToElement functionality - * @param {*} status - control for the status filter * @param {*} searchTerm - the search term * @param {*} portfolio - the portfolio id */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { // --------- SEARCH let searchParams = new URLSearchParams( @@ -1892,7 +1891,6 @@ class MembersTable extends LoadTableBase { "page": page, "sort_by": sortBy, "order": order, - "status": status, "search_term": searchTerm } ); @@ -1932,21 +1930,34 @@ class MembersTable extends LoadTableBase { const member_name = member.name; const member_email = member.email; const options = { year: 'numeric', month: 'short', day: 'numeric' }; - // set last_active values - // default values - let last_active = null; + + // Handle last_active values + let last_active = member.last_active; let last_active_formatted = ''; let last_active_sort_value = ''; - // member.last_active could be null, Invited, or a date; below sets values for all scenarios - if (member.last_active && member.last_active != 'Invited') { - last_active = new Date(member.last_active); - last_active_formatted = last_active.toLocaleDateString('en-Us', options); - last_active_sort_value = last_active.getTime(); + + // Handle 'Invited' or null/empty values differently from valid dates + if (last_active && last_active !== 'Invited') { + try { + // Try to parse the last_active as a valid date + last_active = new Date(last_active); + if (!isNaN(last_active)) { + last_active_formatted = last_active.toLocaleDateString('en-US', options); + last_active_sort_value = last_active.getTime(); // For sorting purposes + } else { + last_active_formatted='Invalid date' + } + } catch (e) { + console.error(`Error parsing date: ${last_active}. Error: ${e}`); + last_active_formatted='Invalid date' + } } else { + // Handle 'Invited' or null last_active = 'Invited'; last_active_formatted = 'Invited'; - last_active_sort_value = 'Invited'; + last_active_sort_value = 'Invited'; // Keep 'Invited' as a sortable string } + const action_url = member.action_url; const action_label = member.action_label; const svg_icon = member.svg_icon; diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 4978d422b..627d82416 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -1,8 +1,9 @@ -from datetime import datetime from django.http import JsonResponse from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required +from django.db.models import Value, F, CharField, TextField, Q from django.urls import reverse +from django.db.models.functions import Cast from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission @@ -11,58 +12,85 @@ from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices @login_required def get_portfolio_members_json(request): - """Given the current request, - get all members that are associated with the given portfolio""" + """Fetch members (permissions and invitations) for the given portfolio.""" portfolio = request.GET.get("portfolio") + search_term = request.GET.get("search_term", "").lower() + + # Permissions queryset + permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + + if search_term: + permissions = permissions.filter( + Q(user__first_name__icontains=search_term) + | Q(user__last_name__icontains=search_term) + | Q(user__email__icontains=search_term) + ) permissions = ( - UserPortfolioPermission.objects.filter(portfolio=portfolio) - .select_related("user") - .values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") - ) - invitations = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( - "pk", "email", "roles", "additional_permissions", "status" + permissions.select_related("user") + .annotate( + first_name=F("user__first_name"), + last_name=F("user__last_name"), + email_display=F("user__email"), + last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + source=Value("permission", output_field=CharField()), + ) + .values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "source", + ) ) - # Convert the permissions queryset into a list of dictionaries - permission_list = [ - { - "id": perm[0], - "first_name": perm[1], - "last_name": perm[2], - "email": perm[3], - "last_active": perm[4], - "roles": perm[5], - "source": "permission", # Mark the source as permissions - } - for perm in permissions - ] + # Invitations queryset + invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) - # Convert the invitations queryset into a list of dictionaries - invitation_list = [ - { - "id": invite[0], - "first_name": None, # No first name in invitations - "last_name": None, # No last name in invitations - "email": invite[1], - "roles": invite[2], - "additional_permissions": invite[3], - "status": invite[4], - "last_active": "Invited", - "source": "invitation", # Mark the source as invitations - } - for invite in invitations - ] + if search_term: + invitations = invitations.filter(Q(email__icontains=search_term)) - # Combine both lists into one unified list - combined_list = permission_list + invitation_list + invitations = invitations.annotate( + first_name=Value(None, output_field=CharField()), + last_name=Value(None, output_field=CharField()), + email_display=F("email"), + last_active=Value("Invited", output_field=TextField()), # Use "Invited" as a text value + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + source=Value("invitation", output_field=CharField()), + ).values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "source", + ) - unfiltered_total = len(combined_list) + # Union the two querysets after applying search filters + combined_queryset = permissions.union(invitations) - combined_list = apply_search(combined_list, request) - combined_list = apply_sorting(combined_list, request) + # Apply sorting + sort_by = request.GET.get("sort_by", "id") # Default to 'id' + order = request.GET.get("order", "asc") # Default to 'asc' - paginator = Paginator(combined_list, 10) + # Adjust sort_by to match the annotated fields in the unioned queryset + if sort_by == "member": + sort_by = "email_display" # Use email_display instead of email + + if order == "desc": + combined_queryset = combined_queryset.order_by(F(sort_by).desc()) + else: + combined_queryset = combined_queryset.order_by(sort_by) + + paginator = Paginator(combined_queryset, 10) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) @@ -76,91 +104,29 @@ def get_portfolio_members_json(request): "has_previous": page_obj.has_previous(), "has_next": page_obj.has_next(), "total": paginator.count, - "unfiltered_total": unfiltered_total, + "unfiltered_total": combined_queryset.count(), } ) -def apply_search(data_list, request): - search_term = request.GET.get("search_term", "").lower() - - if search_term: - # Filter the list based on the search term (case-insensitive) - data_list = [ - item - for item in data_list - if item.get("first_name", "") - and search_term in item.get("first_name", "").lower() - or item.get("last_name", "") - and search_term in item.get("last_name", "").lower() - or item.get("email", "") - and search_term in item.get("email", "").lower() - ] - - return data_list - - -def apply_sorting(data_list, request): - sort_by = request.GET.get("sort_by", "id") # Default to 'id' - order = request.GET.get("order", "asc") # Default to 'asc' - - if sort_by == "member": - sort_by = "email" - - # Custom key function that handles None, 'Invited', and datetime values for last_active - def sort_key(item): - value = item.get(sort_by) - if sort_by == "last_active": - # Return a tuple to ensure consistent data types for comparison - # First element: ordering value (0 for valid datetime, 1 for 'Invited', 2 for None) - # Second element: the actual value to sort by - if value is None: - return (2, value) # Position None last - if value == "Invited": - return (1, value) # Position 'Invited' before None but after valid datetimes - if isinstance(value, datetime): - return (0, value) # Position valid datetime values first - - # Default case: return the value as is for comparison - return value - - # Sort the list using the custom key function - data_list = sorted(data_list, key=sort_key, reverse=(order == "desc")) - - return data_list - - def serialize_members(request, portfolio, item, user): - # ------- VIEW ONLY - # If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link. - # If view_only (the user only has view user permissions), show the "View" link (no gear icon). - # We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin. - user_can_edit_other_users = False - for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]: - if user.has_perm(user_group_permission): - user_can_edit_other_users = True - break + # Check if the user can edit other users + user_can_edit_other_users = any( + user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"] + ) view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users - # ------- USER STATUSES - is_admin = False - if item["roles"]: - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item["roles"] + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item.get("roles_display", []) + action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) - action_url = "#" - if item["source"] == "permission": - action_url = reverse("member", kwargs={"pk": item["id"]}) - elif item["source"] == "invitation": - action_url = reverse("invitedmember", kwargs={"pk": item["id"]}) - - # ------- SERIALIZE + # Serialize member data member_json = { "id": item.get("id", ""), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), - "email": item.get("email", ""), + "email": item.get("email_display", ""), "is_admin": is_admin, - "last_active": item.get("last_active", None), + "last_active": item.get("last_active", ""), "action_url": action_url, "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"), From 9d1cdbc41b523472697552ce9cfedfc175de7f3d Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 11 Oct 2024 16:53:22 -0500 Subject: [PATCH 142/175] add some extra logging to debug in sandbox --- src/registrar/views/domain.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index e54ec9c8f..1e90780ca 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -174,6 +174,17 @@ class DomainFormBaseView(DomainBaseView, FormMixin): } is_analyst_action = ("analyst_action" in self.session and "analyst_action_location" in self.session) + + if "analyst_action" in self.session: + logger.info(f"analyst action: %s", self.session["analyst_action"]) + else: + logger.info("Analyst_action not found in session") + + if "analyst_action_location" in self.session: + logger.info(f"analyst action location: %s", self.session["analyst_action_location"]) + else: + logger.info("Analyst_action_location not found in session") + should_notify=False if form.__class__ in form_label_dict: From 0bd48030e5441c572a3600220840e5391f4f4263 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Fri, 11 Oct 2024 18:08:15 -0400 Subject: [PATCH 143/175] fix active class on member link --- src/registrar/templatetags/custom_filters.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index a3f35ae8e..592839a46 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -248,6 +248,10 @@ def is_members_subpage(path): # Since our pages aren't unified under a common path, we need this approach for now. url_names = [ "members", + "member", + "member-permissions", + "invitedmember", + "invitedmember-permissions" ] return get_url_name(path) in url_names From 1cd6fdda15046172b49d2170dfea10d80d185a59 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 12 Oct 2024 09:45:19 -0400 Subject: [PATCH 144/175] refactored members_json, fixed unfiltered_total, and member sorting --- src/registrar/assets/js/get-gov.js | 3 +- src/registrar/views/portfolio_members_json.py | 184 +++++++++++------- 2 files changed, 111 insertions(+), 76 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 5c1967d15..baacaee48 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1929,6 +1929,7 @@ class MembersTable extends LoadTableBase { data.members.forEach(member => { const member_name = member.name; const member_email = member.email; + const member_sort_value = member.member_sort_value; const options = { year: 'numeric', month: 'short', day: 'numeric' }; // Handle last_active values @@ -1970,7 +1971,7 @@ class MembersTable extends LoadTableBase { row.innerHTML = `
    NameState + Name + + State +
    - ${member_email ? member_email : member_name} ${admin_tagHTML} + ${member_sort_value} ${admin_tagHTML} ${last_active_formatted} diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 627d82416..684f0d337 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -1,7 +1,8 @@ from django.http import JsonResponse from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required -from django.db.models import Value, F, CharField, TextField, Q +from django.db.models import Value, F, CharField, TextField, Q, Case, When +from django.db.models.functions import Concat, Coalesce from django.urls import reverse from django.db.models.functions import Cast @@ -13,84 +14,24 @@ from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices @login_required def get_portfolio_members_json(request): """Fetch members (permissions and invitations) for the given portfolio.""" + portfolio = request.GET.get("portfolio") - search_term = request.GET.get("search_term", "").lower() - # Permissions queryset - permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + # Two initial querysets which will be combined + permissions = initial_permissions_search(portfolio) + invitations = initial_invitations_search(portfolio) - if search_term: - permissions = permissions.filter( - Q(user__first_name__icontains=search_term) - | Q(user__last_name__icontains=search_term) - | Q(user__email__icontains=search_term) - ) + # Get total across both querysets before applying filters + unfiltered_total = permissions.count() + invitations.count() + + permissions = apply_search_term(permissions, request) + invitations = apply_search_term(permissions, request) - permissions = ( - permissions.select_related("user") - .annotate( - first_name=F("user__first_name"), - last_name=F("user__last_name"), - email_display=F("user__email"), - last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text - roles_display=F("roles"), - additional_permissions_display=F("additional_permissions"), - source=Value("permission", output_field=CharField()), - ) - .values( - "id", - "first_name", - "last_name", - "email_display", - "last_active", - "roles_display", - "additional_permissions_display", - "source", - ) - ) + # Union the two querysets + objects = permissions.union(invitations) + objects = apply_sorting(objects, request) - # Invitations queryset - invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) - - if search_term: - invitations = invitations.filter(Q(email__icontains=search_term)) - - invitations = invitations.annotate( - first_name=Value(None, output_field=CharField()), - last_name=Value(None, output_field=CharField()), - email_display=F("email"), - last_active=Value("Invited", output_field=TextField()), # Use "Invited" as a text value - roles_display=F("roles"), - additional_permissions_display=F("additional_permissions"), - source=Value("invitation", output_field=CharField()), - ).values( - "id", - "first_name", - "last_name", - "email_display", - "last_active", - "roles_display", - "additional_permissions_display", - "source", - ) - - # Union the two querysets after applying search filters - combined_queryset = permissions.union(invitations) - - # Apply sorting - sort_by = request.GET.get("sort_by", "id") # Default to 'id' - order = request.GET.get("order", "asc") # Default to 'asc' - - # Adjust sort_by to match the annotated fields in the unioned queryset - if sort_by == "member": - sort_by = "email_display" # Use email_display instead of email - - if order == "desc": - combined_queryset = combined_queryset.order_by(F(sort_by).desc()) - else: - combined_queryset = combined_queryset.order_by(sort_by) - - paginator = Paginator(combined_queryset, 10) + paginator = Paginator(objects, 10) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) @@ -104,10 +45,102 @@ def get_portfolio_members_json(request): "has_previous": page_obj.has_previous(), "has_next": page_obj.has_next(), "total": paginator.count, - "unfiltered_total": combined_queryset.count(), + "unfiltered_total": unfiltered_total, } ) +def initial_permissions_search(portfolio): + """Perform initial search for permissions before applying any filters.""" + permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + permissions = ( + permissions.select_related("user") + .annotate( + first_name=F("user__first_name"), + last_name=F("user__last_name"), + email_display=F("user__email"), + last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + member_sort_value=Case( + # If email is present and not blank, use email + When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")), + # If first name or last name is present, use concatenation of first_name + " " + last_name + When(Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False), + then=Concat( + Coalesce(F("user__first_name"), Value("")), + Value(" "), + Coalesce(F("user__last_name"), Value("")) + ) + ), + # If neither, use an empty string + default=Value(""), + output_field=CharField() + ), + source=Value("permission", output_field=CharField()), + ) + .values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "member_sort_value", + "source", + ) + ) + return permissions + +def initial_invitations_search(portfolio): + """Perform initial invitations search before applying any filters.""" + invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) + invitations = invitations.annotate( + first_name=Value(None, output_field=CharField()), + last_name=Value(None, output_field=CharField()), + email_display=F("email"), + last_active=Value("Invited", output_field=TextField()), + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + member_sort_value=F("email"), + source=Value("invitation", output_field=CharField()), + ).values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "member_sort_value", + "source", + ) + return invitations + + +def apply_search_term(queryset, request): + """Apply search term to the queryset.""" + search_term = request.GET.get("search_term", "").lower() + if search_term: + queryset = queryset.filter( + Q(first_name__icontains=search_term) + | Q(last_name__icontains=search_term) + | Q(email_display__icontains=search_term) + ) + return queryset + +def apply_sorting(queryset, request): + """Apply sorting to the queryset.""" + sort_by = request.GET.get("sort_by", "id") # Default to 'id' + order = request.GET.get("order", "asc") # Default to 'asc' + # Adjust sort_by to match the annotated fields in the unioned queryset + if sort_by == "member": + sort_by = "member_sort_value" + if order == "desc": + queryset = queryset.order_by(F(sort_by).desc()) + else: + queryset = queryset.order_by(sort_by) + return queryset def serialize_members(request, portfolio, item, user): # Check if the user can edit other users @@ -125,6 +158,7 @@ def serialize_members(request, portfolio, item, user): "id": item.get("id", ""), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "email": item.get("email_display", ""), + "member_sort_value": item.get("member_sort_value", ""), "is_admin": is_admin, "last_active": item.get("last_active", ""), "action_url": action_url, From d469041a8eaeb80f61b4bb7a22a9340ccad109cb Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 12 Oct 2024 09:47:18 -0400 Subject: [PATCH 145/175] formatted code for readability --- src/registrar/templatetags/custom_filters.py | 8 +---- src/registrar/views/portfolio_members_json.py | 31 +++++++++++-------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index 592839a46..b29dccb08 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -246,13 +246,7 @@ def is_members_subpage(path): """Checks if the given page is a subpage of members. Takes a path name, like '/organization/'.""" # Since our pages aren't unified under a common path, we need this approach for now. - url_names = [ - "members", - "member", - "member-permissions", - "invitedmember", - "invitedmember-permissions" - ] + url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"] return get_url_name(path) in url_names diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 684f0d337..5194cc493 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -14,7 +14,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices @login_required def get_portfolio_members_json(request): """Fetch members (permissions and invitations) for the given portfolio.""" - + portfolio = request.GET.get("portfolio") # Two initial querysets which will be combined @@ -23,7 +23,7 @@ def get_portfolio_members_json(request): # Get total across both querysets before applying filters unfiltered_total = permissions.count() + invitations.count() - + permissions = apply_search_term(permissions, request) invitations = apply_search_term(permissions, request) @@ -49,6 +49,7 @@ def get_portfolio_members_json(request): } ) + def initial_permissions_search(portfolio): """Perform initial search for permissions before applying any filters.""" permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) @@ -65,17 +66,18 @@ def initial_permissions_search(portfolio): # If email is present and not blank, use email When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")), # If first name or last name is present, use concatenation of first_name + " " + last_name - When(Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False), - then=Concat( - Coalesce(F("user__first_name"), Value("")), - Value(" "), - Coalesce(F("user__last_name"), Value("")) - ) + When( + Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False), + then=Concat( + Coalesce(F("user__first_name"), Value("")), + Value(" "), + Coalesce(F("user__last_name"), Value("")), + ), + ), + # If neither, use an empty string + default=Value(""), + output_field=CharField(), ), - # If neither, use an empty string - default=Value(""), - output_field=CharField() - ), source=Value("permission", output_field=CharField()), ) .values( @@ -92,6 +94,7 @@ def initial_permissions_search(portfolio): ) return permissions + def initial_invitations_search(portfolio): """Perform initial invitations search before applying any filters.""" invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) @@ -116,7 +119,7 @@ def initial_invitations_search(portfolio): "source", ) return invitations - + def apply_search_term(queryset, request): """Apply search term to the queryset.""" @@ -129,6 +132,7 @@ def apply_search_term(queryset, request): ) return queryset + def apply_sorting(queryset, request): """Apply sorting to the queryset.""" sort_by = request.GET.get("sort_by", "id") # Default to 'id' @@ -142,6 +146,7 @@ def apply_sorting(queryset, request): queryset = queryset.order_by(sort_by) return queryset + def serialize_members(request, portfolio, item, user): # Check if the user can edit other users user_can_edit_other_users = any( From 7bc0dbf9c2e527131a6374a697f58dc0e06dd6da Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 12 Oct 2024 10:19:19 -0400 Subject: [PATCH 146/175] fixed a bug --- src/registrar/views/portfolio_members_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 5194cc493..374982795 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -25,7 +25,7 @@ def get_portfolio_members_json(request): unfiltered_total = permissions.count() + invitations.count() permissions = apply_search_term(permissions, request) - invitations = apply_search_term(permissions, request) + invitations = apply_search_term(invitations, request) # Union the two querysets objects = permissions.union(invitations) From 71b8bf38a18684f61a283a9542028bdf0147f7de Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 12 Oct 2024 10:40:44 -0400 Subject: [PATCH 147/175] properly handle None condition for roles --- src/registrar/views/portfolio_members_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 374982795..2f43a26ee 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -155,7 +155,7 @@ def serialize_members(request, portfolio, item, user): view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item.get("roles_display", []) + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles_display", [])) action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) # Serialize member data From 857138c4f9bf9625a7d2243c73e286d2f7dec74a Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 12 Oct 2024 10:53:39 -0400 Subject: [PATCH 148/175] properly handle None condition for roles --- src/registrar/views/portfolio_members_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 2f43a26ee..3c20e1c5f 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -155,7 +155,7 @@ def serialize_members(request, portfolio, item, user): view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles_display", [])) + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles_display") or []) action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) # Serialize member data From d2e484c144870820d64b2d3e90926c2773dcb34e Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 03:21:44 -0400 Subject: [PATCH 149/175] working solution --- src/registrar/assets/sass/_theme/_admin.scss | 8 ++++++ .../portfolio_domain_requests_table.html | 26 ++++++------------- .../portfolio/portfolio_domains_table.html | 16 +++++------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 5cea72c4c..a89180c58 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -929,3 +929,11 @@ ul.add-list-reset { font-weight: 600; font-size: .8125rem; } + +.domain-request-table { + td, + th { + color: inherit !important; + background-color: transparent !important; + } +} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html index 9988e62e1..bfeb0b1a2 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html @@ -2,31 +2,21 @@ {% load static url_helpers %} {% block detail_content %} - +
    - - - + + - + {% for domain_request in domain_requests %} {% url 'admin:registrar_domainrequest_change' domain_request.pk as url %} - - {% if domain_request.get_status_display %} - - {% else %} - - {% endif %} + + {% endfor %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html index f81bc080c..caf2e202d 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html @@ -2,15 +2,11 @@ {% load static url_helpers %} {% block detail_content %} -
    - - Name - - - - Status - +
    Name + Status
    {{ domain_request }}{{ domain_request.get_status_display }}None {{ domain_request }} {{ domain_request.get_status_display|default:"None" }}
    +
    - - + + @@ -19,11 +15,11 @@ {% with domain=domain_info.domain %} {% url 'admin:registrar_domain_change' domain.pk as url %} - + {% if domain and domain.get_state_display %} - + {% else %} - + {% endif %} {% endwith %} From 445af256b659fbc613c10c74570e99ffd8afdf2e Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 15 Oct 2024 08:09:24 -0400 Subject: [PATCH 150/175] variable name cleanup --- src/registrar/assets/js/get-gov.js | 5 ++--- src/registrar/views/portfolio_members_json.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index baacaee48..337baf11c 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1928,8 +1928,7 @@ class MembersTable extends LoadTableBase { data.members.forEach(member => { const member_name = member.name; - const member_email = member.email; - const member_sort_value = member.member_sort_value; + const member_display = member.member_display; const options = { year: 'numeric', month: 'short', day: 'numeric' }; // Handle last_active values @@ -1971,7 +1970,7 @@ class MembersTable extends LoadTableBase { row.innerHTML = `
    - Name - - State - NameState
    {{ domain }} {{ domain }}{{ domain.get_state_display }} {{ domain.get_state_display }} None None
    - ${member_sort_value} ${admin_tagHTML} + ${member_display} ${admin_tagHTML} ${last_active_formatted} diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 3c20e1c5f..d2f2276cf 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -60,9 +60,8 @@ def initial_permissions_search(portfolio): last_name=F("user__last_name"), email_display=F("user__email"), last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text - roles_display=F("roles"), additional_permissions_display=F("additional_permissions"), - member_sort_value=Case( + member_display=Case( # If email is present and not blank, use email When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")), # If first name or last name is present, use concatenation of first_name + " " + last_name @@ -86,9 +85,9 @@ def initial_permissions_search(portfolio): "last_name", "email_display", "last_active", - "roles_display", + "roles", "additional_permissions_display", - "member_sort_value", + "member_display", "source", ) ) @@ -103,9 +102,8 @@ def initial_invitations_search(portfolio): last_name=Value(None, output_field=CharField()), email_display=F("email"), last_active=Value("Invited", output_field=TextField()), - roles_display=F("roles"), additional_permissions_display=F("additional_permissions"), - member_sort_value=F("email"), + member_display=F("email"), source=Value("invitation", output_field=CharField()), ).values( "id", @@ -113,9 +111,9 @@ def initial_invitations_search(portfolio): "last_name", "email_display", "last_active", - "roles_display", + "roles", "additional_permissions_display", - "member_sort_value", + "member_display", "source", ) return invitations @@ -139,7 +137,7 @@ def apply_sorting(queryset, request): order = request.GET.get("order", "asc") # Default to 'asc' # Adjust sort_by to match the annotated fields in the unioned queryset if sort_by == "member": - sort_by = "member_sort_value" + sort_by = "member_display" if order == "desc": queryset = queryset.order_by(F(sort_by).desc()) else: @@ -155,7 +153,7 @@ def serialize_members(request, portfolio, item, user): view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles_display") or []) + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or []) action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) # Serialize member data @@ -163,7 +161,7 @@ def serialize_members(request, portfolio, item, user): "id": item.get("id", ""), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "email": item.get("email_display", ""), - "member_sort_value": item.get("member_sort_value", ""), + "member_display": item.get("member_display", ""), "is_admin": is_admin, "last_active": item.get("last_active", ""), "action_url": action_url, From da375586eda2b608b90e6a401ad024e4ac0b388c Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 10:02:52 -0400 Subject: [PATCH 151/175] ran exec app --- src/registrar/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e21f7581b..f04a161de 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3208,10 +3208,10 @@ class PortfolioAdmin(ListHeaderAdmin): obj: Portfolio = self.get_object(request, object_id) extra_context = extra_context or {} extra_context["skip_additional_contact_info"] = True - sort_by = request.GET.get('sort', 'requested_domain__name') - order = request.GET.get('order', 'asc') + sort_by = request.GET.get("sort", "requested_domain__name") + order = request.GET.get("order", "asc") - order_prefix = '-' if order == 'desc' else '' + order_prefix = "-" if order == "desc" else "" domain_requests_order_by = [f"{order_prefix}{sort_by}"] if obj: From dbf17e34ceb6dc2e4ae244399869e44e1ffd67aa Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Tue, 15 Oct 2024 09:45:04 -0500 Subject: [PATCH 152/175] remove extraneous logging statements --- src/registrar/views/domain.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1e90780ca..b0fc52cf1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -175,16 +175,6 @@ class DomainFormBaseView(DomainBaseView, FormMixin): is_analyst_action = ("analyst_action" in self.session and "analyst_action_location" in self.session) - if "analyst_action" in self.session: - logger.info(f"analyst action: %s", self.session["analyst_action"]) - else: - logger.info("Analyst_action not found in session") - - if "analyst_action_location" in self.session: - logger.info(f"analyst action location: %s", self.session["analyst_action_location"]) - else: - logger.info("Analyst_action_location not found in session") - should_notify=False if form.__class__ in form_label_dict: From 5f55c6dc7d51e9e7640b926d072060ed8ad8f91c Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 15 Oct 2024 12:55:13 -0600 Subject: [PATCH 153/175] re-push feedback implementation --- src/registrar/assets/js/uswds-edited.js | 4 ++-- src/registrar/assets/sass/_theme/_tooltips.scss | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index 42ca52b30..52dc441fc 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -6187,9 +6187,9 @@ const setUpAttributes = tooltipTrigger => { // DOTGOV: nest the text element to allow us greater control over width and wrapping behavior tooltipBody.innerHTML = ` -
    + ${tooltipContent} -
    ` + ` // -- END DOTGOV EDIT return { diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index adb8f43a5..58beb8ae6 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -69,5 +69,6 @@ @include at-media('desktop') { width: 70vw; } + display: block; } } \ No newline at end of file From 1c073a017b3ac491c6883cfc76f23df410ffb804 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 17:12:23 -0400 Subject: [PATCH 154/175] removed experiments --- src/registrar/admin.py | 5 ----- src/registrar/assets/js/get-gov-admin.js | 16 ---------------- 2 files changed, 21 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 48229a61a..d537235a9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3220,11 +3220,6 @@ class PortfolioAdmin(ListHeaderAdmin): obj: Portfolio = self.get_object(request, object_id) extra_context = extra_context or {} extra_context["skip_additional_contact_info"] = True - sort_by = request.GET.get("sort", "requested_domain__name") - order = request.GET.get("order", "asc") - - order_prefix = "-" if order == "desc" else "" - domain_requests_order_by = [f"{order_prefix}{sort_by}"] if obj: extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 29e1b42b2..da6291ef7 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -1169,20 +1169,4 @@ document.addEventListener('DOMContentLoaded', function() { phoneSpan.textContent = data.phone || "None"; }; } -})(); - -(function sortTable(sortBy, order,event) { - event.preventDefault() - const xhr = new XMLHttpRequest(); - xhr.open('GET', `?sort=${sortBy}&order=${order}`, true); - xhr.onreadystatechange = function() { - if (xhr.readyState === 4 && xhr.status === 200) { - // Parse the response and update only the table body or container - const parser = new DOMParser(); - const doc = parser.parseFromString(xhr.responseText, 'text/html'); - const sortedContent = doc.querySelector('#table-content').innerHTML; - document.querySelector('#table-content').innerHTML = sortedContent; - } - }; - xhr.send(); })(); \ No newline at end of file From e151c666fdf065e346c65a755ef23c75b5187471 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 17:13:44 -0400 Subject: [PATCH 155/175] added some formatting and some correction to things I changed prior --- src/registrar/admin.py | 2 +- src/registrar/assets/js/get-gov-admin.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d537235a9..33765b178 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3225,7 +3225,7 @@ class PortfolioAdmin(ListHeaderAdmin): extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj) extra_context["admins"] = self.get_user_portfolio_permission_admins(obj) extra_context["domains"] = obj.get_domains(order_by=["domain__name"]) - extra_context["domain_requests"] = obj.get_domain_requests(order_by=domain_requests_order_by) + extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"]) return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index da6291ef7..fd50fbb0c 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -1169,4 +1169,4 @@ document.addEventListener('DOMContentLoaded', function() { phoneSpan.textContent = data.phone || "None"; }; } -})(); \ No newline at end of file +})(); From dae51b67eab2369cf66568d28a58650e789f617c Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 15 Oct 2024 16:22:48 -0600 Subject: [PATCH 156/175] Pipfile and requirements.txt updates --- src/Pipfile.lock | 2009 ++++++++++++++++++++---------------------- src/requirements.txt | 77 +- 2 files changed, 1011 insertions(+), 1075 deletions(-) diff --git a/src/Pipfile.lock b/src/Pipfile.lock index a42563c63..33b858314 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9095c4f98f58a9502444584067a63f329d5a5fc4b49454c4e129bda09552d19d" + "sha256": "2799ab9e493352740c6946e604ccc075c5c16359c809753296091bbe2b9fd837" }, "pipfile-spec": 6, "requires": {}, @@ -16,11 +16,11 @@ "default": { "annotated-types": { "hashes": [ - "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", - "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" + "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", + "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" ], "markers": "python_version >= '3.8'", - "version": "==0.6.0" + "version": "==0.7.0" }, "asgiref": { "hashes": [ @@ -32,37 +32,37 @@ }, "boto3": { "hashes": [ - "sha256:decf52f8d5d8a1b10c9ff2a0e96ee207ed79e33d2e53fdf0880a5cbef70785e0", - "sha256:e836b71d79671270fccac0a4d4c8ec239a6b82ea47c399b64675aa597d0ee63b" + "sha256:2bf7e7f376aee52155fc4ae4487f29333a6bcdf3a05c3bc4fede10b972d951a6", + "sha256:e74bc6d69c04ca611b7f58afe08e2ded6cb6504a4a80557b656abeefee395f88" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.95" + "version": "==1.35.41" }, "botocore": { "hashes": [ - "sha256:6bd76a2eadb42b91fa3528392e981ad5b4dfdee3968fa5b904278acf6cbf15ff", - "sha256:ead5823e0dd6751ece5498cb979fd9abf190e691c8833bcac6876fd6ca261fa7" + "sha256:8a09a32136df8768190a6c92f0240cd59c30deb99c89026563efadbbed41fa00", + "sha256:915c4d81e3a0be3b793c1e2efdf19af1d0a9cd4a2d8de08ee18216c14d67764b" ], "markers": "python_version >= '3.8'", - "version": "==1.34.95" + "version": "==1.35.41" }, "cachetools": { "hashes": [ - "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945", - "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105" + "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", + "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==5.3.3" + "version": "==5.5.0" }, "certifi": { "hashes": [ - "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f", - "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1" + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" ], "markers": "python_version >= '3.6'", - "version": "==2024.2.2" + "version": "==2024.8.30" }, "cfenv": { "hashes": [ @@ -74,195 +74,220 @@ }, "cffi": { "hashes": [ - "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", - "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", - "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", - "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", - "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", - "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", - "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", - "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", - "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", - "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", - "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", - "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", - "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", - "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", - "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", - "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", - "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", - "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", - "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", - "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", - "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", - "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", - "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", - "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", - "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", - "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", - "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", - "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", - "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", - "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", - "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", - "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", - "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", - "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", - "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", - "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", - "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", - "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", - "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", - "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", - "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", - "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", - "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", - "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", - "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", - "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", - "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", - "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", - "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", - "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", - "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", - "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" ], "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.16.0" + "version": "==1.17.1" }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "version": "==3.4.0" }, "cryptography": { "hashes": [ - "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee", - "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576", - "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d", - "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30", - "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413", - "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb", - "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da", - "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4", - "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd", - "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc", - "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8", - "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1", - "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc", - "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e", - "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8", - "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940", - "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400", - "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7", - "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16", - "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278", - "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74", - "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec", - "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1", - "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2", - "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c", - "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922", - "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a", - "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6", - "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1", - "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e", - "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac", - "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7" + "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", + "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", + "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", + "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", + "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", + "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", + "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", + "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", + "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84", + "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", + "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", + "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", + "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2", + "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", + "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", + "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365", + "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96", + "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", + "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", + "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d", + "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", + "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", + "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", + "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172", + "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034", + "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", + "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289" ], "markers": "python_version >= '3.7'", - "version": "==42.0.5" + "version": "==43.0.1" }, "defusedxml": { "hashes": [ @@ -282,10 +307,10 @@ }, "dj-database-url": { "hashes": [ - "sha256:04bc34b248d4c21aaa13e4ab419ae6575ef5f10f3df735ce7da97722caa356e0", - "sha256:f2042cefe1086e539c9da39fad5ad7f61173bf79665e69bf7e4de55fa88b135f" + "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de", + "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b" ], - "version": "==2.1.0" + "version": "==2.2.0" }, "dj-email-url": { "hashes": [ @@ -337,12 +362,12 @@ }, "django-cors-headers": { "hashes": [ - "sha256:0b1fd19297e37417fc9f835d39e45c8c642938ddba1acce0c1753d3edef04f36", - "sha256:0bf65ef45e606aff1994d35503e6b677c0b26cafff6506f8fd7187f3be840207" + "sha256:28c1ded847aa70208798de3e42422a782f427b8b720e8d7319d34b654b5978e6", + "sha256:6c01a85cf1ec779a7bde621db853aa3ce5c065a5ba8e27df7a9f9e8dac310f4f" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.3.1" + "markers": "python_version >= '3.9'", + "version": "==4.5.0" }, "django-csp": { "hashes": [ @@ -362,12 +387,12 @@ }, "django-import-export": { "hashes": [ - "sha256:2eac09e8cec8670f36e24314760448011ad23c51e8fb930d55f50d0c3c926da0", - "sha256:4deabc557801d368093608c86fd0f4831bc9540e2ea41ca2f023e2efb3eb6f48" + "sha256:16ecc5a9f0df46bde6eb278a3e65ebda0ee1db55656f36440e9fb83f40ab85a3", + "sha256:730ae2443a02b1ba27d8dba078a27ae9123adfcabb78161b4f130843607b3df9" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==3.3.8" + "version": "==4.1.1" }, "django-login-required-middleware": { "hashes": [ @@ -381,11 +406,11 @@ "phonenumberslite" ], "hashes": [ - "sha256:bc6eaa49d1f9d870944f5280258db511e3a1ba5e2fbbed255488dceacae45d06", - "sha256:f9cdb3de085f99c249328293a3b93d4e5fa440c0c8e3b99eb0d0f54748629797" + "sha256:196c917b70c01a98e327f482eb8a4a4a55a29891db551f99078585397370b3ba", + "sha256:8a560fe1b01b94c9de8cde22bc373b695f023cc6df4baba00264cb079da9f631" ], "markers": "python_version >= '3.8'", - "version": "==7.3.0" + "version": "==8.0.0" }, "django-waffle": { "hashes": [ @@ -416,22 +441,14 @@ "markers": "python_version >= '3.8'", "version": "==11.0.0" }, - "et-xmlfile": { - "hashes": [ - "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", - "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" - ], - "markers": "python_version >= '3.6'", - "version": "==1.1.0" - }, "faker": { "hashes": [ - "sha256:87ef41e24b39a5be66ecd874af86f77eebd26782a2681200e86c5326340a95d3", - "sha256:e23a2b74888885c3d23a9237bacb823041291c03d609a39acb9ebe6c123f3986" + "sha256:8760fbb34564fbb2f394345eef24aec5b8f6506b6cfcefe8195ed66dd1032bdb", + "sha256:e8a15fd1b0f72992b008f5ea94c70d3baa0cb51b0d5a0e899c17b1d1b23d2771" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==25.0.0" + "version": "==30.3.0" }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", @@ -454,133 +471,145 @@ }, "gevent": { "hashes": [ - "sha256:03aa5879acd6b7076f6a2a307410fb1e0d288b84b03cdfd8c74db8b4bc882fc5", - "sha256:117e5837bc74a1673605fb53f8bfe22feb6e5afa411f524c835b2ddf768db0de", - "sha256:141a2b24ad14f7b9576965c0c84927fc85f824a9bb19f6ec1e61e845d87c9cd8", - "sha256:14532a67f7cb29fb055a0e9b39f16b88ed22c66b96641df8c04bdc38c26b9ea5", - "sha256:1dffb395e500613e0452b9503153f8f7ba587c67dd4a85fc7cd7aa7430cb02cc", - "sha256:2955eea9c44c842c626feebf4459c42ce168685aa99594e049d03bedf53c2800", - "sha256:2ae3a25ecce0a5b0cd0808ab716bfca180230112bb4bc89b46ae0061d62d4afe", - "sha256:2e9ac06f225b696cdedbb22f9e805e2dd87bf82e8fa5e17756f94e88a9d37cf7", - "sha256:368a277bd9278ddb0fde308e6a43f544222d76ed0c4166e0d9f6b036586819d9", - "sha256:3adfb96637f44010be8abd1b5e73b5070f851b817a0b182e601202f20fa06533", - "sha256:3d5325ccfadfd3dcf72ff88a92fb8fc0b56cacc7225f0f4b6dcf186c1a6eeabc", - "sha256:432fc76f680acf7cf188c2ee0f5d3ab73b63c1f03114c7cd8a34cebbe5aa2056", - "sha256:44098038d5e2749b0784aabb27f1fcbb3f43edebedf64d0af0d26955611be8d6", - "sha256:5a1df555431f5cd5cc189a6ee3544d24f8c52f2529134685f1e878c4972ab026", - "sha256:6c47ae7d1174617b3509f5d884935e788f325eb8f1a7efc95d295c68d83cce40", - "sha256:6f947a9abc1a129858391b3d9334c45041c08a0f23d14333d5b844b6e5c17a07", - "sha256:782a771424fe74bc7e75c228a1da671578c2ba4ddb2ca09b8f959abdf787331e", - "sha256:7899a38d0ae7e817e99adb217f586d0a4620e315e4de577444ebeeed2c5729be", - "sha256:7b00f8c9065de3ad226f7979154a7b27f3b9151c8055c162332369262fc025d8", - "sha256:8f4b8e777d39013595a7740b4463e61b1cfe5f462f1b609b28fbc1e4c4ff01e5", - "sha256:90cbac1ec05b305a1b90ede61ef73126afdeb5a804ae04480d6da12c56378df1", - "sha256:918cdf8751b24986f915d743225ad6b702f83e1106e08a63b736e3a4c6ead789", - "sha256:9202f22ef811053077d01f43cc02b4aaf4472792f9fd0f5081b0b05c926cca19", - "sha256:94138682e68ec197db42ad7442d3cf9b328069c3ad8e4e5022e6b5cd3e7ffae5", - "sha256:968581d1717bbcf170758580f5f97a2925854943c45a19be4d47299507db2eb7", - "sha256:9d8d0642c63d453179058abc4143e30718b19a85cbf58c2744c9a63f06a1d388", - "sha256:a7ceb59986456ce851160867ce4929edaffbd2f069ae25717150199f8e1548b8", - "sha256:b9913c45d1be52d7a5db0c63977eebb51f68a2d5e6fd922d1d9b5e5fd758cc98", - "sha256:bde283313daf0b34a8d1bab30325f5cb0f4e11b5869dbe5bc61f8fe09a8f66f3", - "sha256:bf5b9c72b884c6f0c4ed26ef204ee1f768b9437330422492c319470954bc4cc7", - "sha256:ca80b121bbec76d7794fcb45e65a7eca660a76cc1a104ed439cdbd7df5f0b060", - "sha256:cdf66977a976d6a3cfb006afdf825d1482f84f7b81179db33941f2fc9673bb1d", - "sha256:d4faf846ed132fd7ebfbbf4fde588a62d21faa0faa06e6f468b7faa6f436b661", - "sha256:d7f87c2c02e03d99b95cfa6f7a776409083a9e4d468912e18c7680437b29222c", - "sha256:dd23df885318391856415e20acfd51a985cba6919f0be78ed89f5db9ff3a31cb", - "sha256:f5de3c676e57177b38857f6e3cdfbe8f38d1cd754b63200c0615eaa31f514b4f", - "sha256:f5e8e8d60e18d5f7fd49983f0c4696deeddaf6e608fbab33397671e2fcc6cc91", - "sha256:f7cac622e11b4253ac4536a654fe221249065d9a69feb6cdcd4d9af3503602e0", - "sha256:f8a04cf0c5b7139bc6368b461257d4a757ea2fe89b3773e494d235b7dd51119f", - "sha256:f8bb35ce57a63c9a6896c71a285818a3922d8ca05d150fd1fe49a7f57287b836", - "sha256:fbfdce91239fe306772faab57597186710d5699213f4df099d1612da7320d682" + "sha256:013150cc0f00f0a06dd898463ad9ebc43bd9c70c7fe35555c77d83fe6f758225", + "sha256:0814a5a7084e0bd357392e44e2a8bd72fc56fbdc3da0ff492ebb310c10fc95e6", + "sha256:103097b39764a0a02f1a051225ea6b4c64a53dd37603424ca8a1e09be63a460b", + "sha256:16bf432b274795b360d88b38cbffe0a6410450c94bfa172548bf1f512cf448c2", + "sha256:1a5012b7d047b16470063f0b8d003530e77362809f38cd7e601efb625c7ca71e", + "sha256:22bc6efb0f9fbb1c2e005ef1b94374568945c711bfb92f85916f66a819a5e6d0", + "sha256:377c02d0ddae3ebf843d6f453943602102bb186b09f1c78a2247e5dbf0e07b1c", + "sha256:421cfeacae2555b11318c6ee11f34bc0a9517657068d8911c916d55a85362ce2", + "sha256:44174aa4dae4db158e6f11a4ea696f1991d43ccc1634aa0c189daf03a9ced5d7", + "sha256:44dd79cfefea24f9bb630844a25047c3807e02722436e826ef2aed3d646190c1", + "sha256:4e3fbaf484ee68437f0ec589bdb1dd6f1dccc01fd6b72eac707e858b407521fa", + "sha256:4f0e6c49aac1c182be15a43d94e3b58c253d830c5b54dc93d6130e6987278611", + "sha256:539af6b66c6b9faca2cdd903f0a7564c85053f1faf95e9a37702df578ac37085", + "sha256:562b66d8b061b9cfae1bc704b0cd5d2b255628d86c3639ddc16e4ffa3ebf6e7a", + "sha256:5bb80c88f572a11156f258333c0e7b1f80d0746a03784600017901a2f1aa584a", + "sha256:5d1db7bc758455e6f6406d66e8b276b80dda5645877392a100d1ed7dda6aa7ad", + "sha256:618c4869e8140fd955b4620b10bc5a92ef1d62ae20aef38c1af7d892ee1bd996", + "sha256:6a93f249a40bda8c42cbeefff9582b22bb1dd769da56b4cbb824038366c4202c", + "sha256:6b9da562d7d7707d5561ecf4a27a361fd9f4856f39b8491a0753c89d8f39674c", + "sha256:73b65ee9a73a35fb68d96899895162beef19d86c1bcbe6f8f92eb0bd18c1d891", + "sha256:7b5f10ac866d3432a829a3a4446489be1fa3648f3140f9373fe99440a2e05682", + "sha256:81b4915081d148a31b64ad0314d2f609920b8ae6a24d9a7e4ddaab7c1fe998e7", + "sha256:90f9bc542f76efc56e5e76b420abaff42baf585db48a9fc0ac8edd6a16d9e60f", + "sha256:96e7bab9de56e0aca3858b8bc9c71f4eb0c0e12b7cf3cbfd170b62ce68cf71d7", + "sha256:975699ac5701d7ec1c633f2067deecea8711dc2a8683530aed260dd641274791", + "sha256:9f74faefea1acb398f057ed31ee9333e100bdae978b1e4c3b6a27d05df66e588", + "sha256:a11db551555c58606ed3dfe359a9a502e44350ed3ecbd59cbe7b0093bd020418", + "sha256:a6a04df4732bb7fdf9969ddee9a16a829e7971692fefdcb5baca760976d23e04", + "sha256:a72a7cb67764adafbac7ddeeffe539a738309068e2b2ac89cbd2f498383ce537", + "sha256:aabffb8b86fb95cb1ee5dffa315c9bd68fe20a7fe7260c0328679723b0257b7c", + "sha256:bc181db59d53e407650ebf44e63ff429c7bc25f9c346edddce1bdff1af436617", + "sha256:dd9c966e5fd8d7b0a54a130c5ad38ef581fd93ff4c44b6e73767519860da6ebe", + "sha256:ec800c25f09a7e031f2fbc3b17b4a4a0b54085c7532ac51b4c7ecef6d3ff8fc3", + "sha256:f0d6cfff74be4efcafecd374e094a8fed9e0d68efe90109d374ef5d8f18aa21a", + "sha256:f57b7a02e83d6e4a205cace6dd63e16b61a641a1da9366d9ec4f2b849430700f", + "sha256:fa190663f964583c8dbbab06bc863966e6f7eceaac8aa67c3ac0fae0a0a73b80", + "sha256:fa4cba4a8acbb71dd4215be8517879e4217c0746f7af2637330e7269694f53f2", + "sha256:fd9b670da1b7160e660cbba7f52e206892b97f61d8ff1872ce99dfaa9b475420" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==24.2.1" + "markers": "python_version >= '3.9'", + "version": "==24.10.2" }, "greenlet": { "hashes": [ - "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", - "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6", - "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257", - "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4", - "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", - "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61", - "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", - "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca", - "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7", - "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728", - "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", - "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", - "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379", - "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414", - "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04", - "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a", - "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf", - "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", - "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559", - "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e", - "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274", - "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb", - "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b", - "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9", - "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b", - "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", - "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506", - "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405", - "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113", - "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f", - "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5", - "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", - "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d", - "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f", - "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a", - "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e", - "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61", - "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6", - "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d", - "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71", - "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22", - "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", - "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3", - "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067", - "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc", - "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881", - "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3", - "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", - "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac", - "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53", - "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0", - "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b", - "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83", - "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41", - "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c", - "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", - "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da", - "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33" + "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", + "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7", + "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", + "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", + "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", + "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", + "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", + "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", + "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", + "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa", + "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", + "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", + "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", + "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", + "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9", + "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", + "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba", + "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3", + "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", + "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", + "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291", + "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", + "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", + "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", + "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", + "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef", + "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c", + "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", + "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c", + "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", + "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", + "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8", + "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d", + "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", + "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145", + "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", + "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", + "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e", + "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", + "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1", + "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef", + "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", + "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", + "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", + "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437", + "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd", + "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981", + "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", + "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", + "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798", + "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", + "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", + "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", + "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", + "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af", + "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", + "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", + "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42", + "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e", + "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81", + "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", + "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", + "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc", + "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de", + "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111", + "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", + "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", + "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", + "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", + "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", + "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803", + "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", + "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==3.0.3" + "version": "==3.1.1" }, "gunicorn": { "hashes": [ - "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", - "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63" + "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", + "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==22.0.0" + "version": "==23.0.0" }, "idna": { "hashes": [ - "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", - "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], - "markers": "python_version >= '3.5'", - "version": "==3.7" + "markers": "python_version >= '3.6'", + "version": "==3.10" }, "jmespath": { "hashes": [ @@ -592,258 +621,230 @@ }, "lxml": { "hashes": [ - "sha256:04ab5415bf6c86e0518d57240a96c4d1fcfc3cb370bb2ac2a732b67f579e5a04", - "sha256:057cdc6b86ab732cf361f8b4d8af87cf195a1f6dc5b0ff3de2dced242c2015e0", - "sha256:058a1308914f20784c9f4674036527e7c04f7be6fb60f5d61353545aa7fcb739", - "sha256:08802f0c56ed150cc6885ae0788a321b73505d2263ee56dad84d200cab11c07a", - "sha256:0a15438253b34e6362b2dc41475e7f80de76320f335e70c5528b7148cac253a1", - "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218", - "sha256:0e7259016bc4345a31af861fdce942b77c99049d6c2107ca07dc2bba2435c1d9", - "sha256:0ed777c1e8c99b63037b91f9d73a6aad20fd035d77ac84afcc205225f8f41188", - "sha256:0f5d65c39f16717a47c36c756af0fb36144069c4718824b7533f803ecdf91138", - "sha256:0f8c09ed18ecb4ebf23e02b8e7a22a05d6411911e6fabef3a36e4f371f4f2585", - "sha256:11a04306fcba10cd9637e669fd73aa274c1c09ca64af79c041aa820ea992b637", - "sha256:1ae67b4e737cddc96c99461d2f75d218bdf7a0c3d3ad5604d1f5e7464a2f9ffe", - "sha256:1c5bb205e9212d0ebddf946bc07e73fa245c864a5f90f341d11ce7b0b854475d", - "sha256:1f7785f4f789fdb522729ae465adcaa099e2a3441519df750ebdccc481d961a1", - "sha256:200e63525948e325d6a13a76ba2911f927ad399ef64f57898cf7c74e69b71095", - "sha256:21c2e6b09565ba5b45ae161b438e033a86ad1736b8c838c766146eff8ceffff9", - "sha256:2213afee476546a7f37c7a9b4ad4d74b1e112a6fafffc9185d6d21f043128c81", - "sha256:27aa20d45c2e0b8cd05da6d4759649170e8dfc4f4e5ef33a34d06f2d79075d57", - "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536", - "sha256:2c9d147f754b1b0e723e6afb7ba1566ecb162fe4ea657f53d2139bbf894d050a", - "sha256:2ddfe41ddc81f29a4c44c8ce239eda5ade4e7fc305fb7311759dd6229a080052", - "sha256:31e9a882013c2f6bd2f2c974241bf4ba68c85eba943648ce88936d23209a2e01", - "sha256:3249cc2989d9090eeac5467e50e9ec2d40704fea9ab72f36b034ea34ee65ca98", - "sha256:3545039fa4779be2df51d6395e91a810f57122290864918b172d5dc7ca5bb433", - "sha256:394ed3924d7a01b5bd9a0d9d946136e1c2f7b3dc337196d99e61740ed4bc6fe1", - "sha256:3a6b45da02336895da82b9d472cd274b22dc27a5cea1d4b793874eead23dd14f", - "sha256:3a74c4f27167cb95c1d4af1c0b59e88b7f3e0182138db2501c353555f7ec57f4", - "sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b", - "sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6", - "sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8", - "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306", - "sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5", - "sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f", - "sha256:491755202eb21a5e350dae00c6d9a17247769c64dcf62d8c788b5c135e179dc4", - "sha256:4951e4f7a5680a2db62f7f4ab2f84617674d36d2d76a729b9a8be4b59b3659be", - "sha256:52421b41ac99e9d91934e4d0d0fe7da9f02bfa7536bb4431b4c05c906c8c6919", - "sha256:530e7c04f72002d2f334d5257c8a51bf409db0316feee7c87e4385043be136af", - "sha256:533658f8fbf056b70e434dff7e7aa611bcacb33e01f75de7f821810e48d1bb66", - "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1", - "sha256:56c22432809085b3f3ae04e6e7bdd36883d7258fcd90e53ba7b2e463efc7a6af", - "sha256:58278b29cb89f3e43ff3e0c756abbd1518f3ee6adad9e35b51fb101c1c1daaec", - "sha256:588008b8497667f1ddca7c99f2f85ce8511f8f7871b4a06ceede68ab62dff64b", - "sha256:59565f10607c244bc4c05c0c5fa0c190c990996e0c719d05deec7030c2aa8289", - "sha256:59689a75ba8d7ffca577aefd017d08d659d86ad4585ccc73e43edbfc7476781a", - "sha256:5aea8212fb823e006b995c4dda533edcf98a893d941f173f6c9506126188860d", - "sha256:5c670c0406bdc845b474b680b9a5456c561c65cf366f8db5a60154088c92d102", - "sha256:5ca1e8188b26a819387b29c3895c47a5e618708fe6f787f3b1a471de2c4a94d9", - "sha256:5d077bc40a1fe984e1a9931e801e42959a1e6598edc8a3223b061d30fbd26bbc", - "sha256:5d5792e9b3fb8d16a19f46aa8208987cfeafe082363ee2745ea8b643d9cc5b45", - "sha256:5dd1537e7cc06efd81371f5d1a992bd5ab156b2b4f88834ca852de4a8ea523fa", - "sha256:5ea7b6766ac2dfe4bcac8b8595107665a18ef01f8c8343f00710b85096d1b53a", - "sha256:622020d4521e22fb371e15f580d153134bfb68d6a429d1342a25f051ec72df1c", - "sha256:627402ad8dea044dde2eccde4370560a2b750ef894c9578e1d4f8ffd54000461", - "sha256:644df54d729ef810dcd0f7732e50e5ad1bd0a135278ed8d6bcb06f33b6b6f708", - "sha256:64641a6068a16201366476731301441ce93457eb8452056f570133a6ceb15fca", - "sha256:64c2baa7774bc22dd4474248ba16fe1a7f611c13ac6123408694d4cc93d66dbd", - "sha256:6588c459c5627fefa30139be4d2e28a2c2a1d0d1c265aad2ba1935a7863a4913", - "sha256:66bc5eb8a323ed9894f8fa0ee6cb3e3fb2403d99aee635078fd19a8bc7a5a5da", - "sha256:68a2610dbe138fa8c5826b3f6d98a7cfc29707b850ddcc3e21910a6fe51f6ca0", - "sha256:6935bbf153f9a965f1e07c2649c0849d29832487c52bb4a5c5066031d8b44fd5", - "sha256:6992030d43b916407c9aa52e9673612ff39a575523c5f4cf72cdef75365709a5", - "sha256:6a014510830df1475176466b6087fc0c08b47a36714823e58d8b8d7709132a96", - "sha256:6ab833e4735a7e5533711a6ea2df26459b96f9eec36d23f74cafe03631647c41", - "sha256:6cc6ee342fb7fa2471bd9b6d6fdfc78925a697bf5c2bcd0a302e98b0d35bfad3", - "sha256:6cf58416653c5901e12624e4013708b6e11142956e7f35e7a83f1ab02f3fe456", - "sha256:70a9768e1b9d79edca17890175ba915654ee1725975d69ab64813dd785a2bd5c", - "sha256:70ac664a48aa64e5e635ae5566f5227f2ab7f66a3990d67566d9907edcbbf867", - "sha256:71e97313406ccf55d32cc98a533ee05c61e15d11b99215b237346171c179c0b0", - "sha256:7221d49259aa1e5a8f00d3d28b1e0b76031655ca74bb287123ef56c3db92f213", - "sha256:74b28c6334cca4dd704e8004cba1955af0b778cf449142e581e404bd211fb619", - "sha256:764b521b75701f60683500d8621841bec41a65eb739b8466000c6fdbc256c240", - "sha256:78bfa756eab503673991bdcf464917ef7845a964903d3302c5f68417ecdc948c", - "sha256:794f04eec78f1d0e35d9e0c36cbbb22e42d370dda1609fb03bcd7aeb458c6377", - "sha256:79bd05260359170f78b181b59ce871673ed01ba048deef4bf49a36ab3e72e80b", - "sha256:7a7efd5b6d3e30d81ec68ab8a88252d7c7c6f13aaa875009fe3097eb4e30b84c", - "sha256:7c17b64b0a6ef4e5affae6a3724010a7a66bda48a62cfe0674dabd46642e8b54", - "sha256:804f74efe22b6a227306dd890eecc4f8c59ff25ca35f1f14e7482bbce96ef10b", - "sha256:853e074d4931dbcba7480d4dcab23d5c56bd9607f92825ab80ee2bd916edea53", - "sha256:857500f88b17a6479202ff5fe5f580fc3404922cd02ab3716197adf1ef628029", - "sha256:865bad62df277c04beed9478fe665b9ef63eb28fe026d5dedcb89b537d2e2ea6", - "sha256:88e22fc0a6684337d25c994381ed8a1580a6f5ebebd5ad41f89f663ff4ec2885", - "sha256:8b9c07e7a45bb64e21df4b6aa623cb8ba214dfb47d2027d90eac197329bb5e94", - "sha256:8de8f9d6caa7f25b204fc861718815d41cbcf27ee8f028c89c882a0cf4ae4134", - "sha256:8e77c69d5892cb5ba71703c4057091e31ccf534bd7f129307a4d084d90d014b8", - "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9", - "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863", - "sha256:96323338e6c14e958d775700ec8a88346014a85e5de73ac7967db0367582049b", - "sha256:9676bfc686fa6a3fa10cd4ae6b76cae8be26eb5ec6811d2a325636c460da1806", - "sha256:9b0ff53900566bc6325ecde9181d89afadc59c5ffa39bddf084aaedfe3b06a11", - "sha256:9b9ec9c9978b708d488bec36b9e4c94d88fd12ccac3e62134a9d17ddba910ea9", - "sha256:9c6ad0fbf105f6bcc9300c00010a2ffa44ea6f555df1a2ad95c88f5656104817", - "sha256:9ca66b8e90daca431b7ca1408cae085d025326570e57749695d6a01454790e95", - "sha256:9e2addd2d1866fe112bc6f80117bcc6bc25191c5ed1bfbcf9f1386a884252ae8", - "sha256:a0af35bd8ebf84888373630f73f24e86bf016642fb8576fba49d3d6b560b7cbc", - "sha256:a2b44bec7adf3e9305ce6cbfa47a4395667e744097faed97abb4728748ba7d47", - "sha256:a2dfe7e2473f9b59496247aad6e23b405ddf2e12ef0765677b0081c02d6c2c0b", - "sha256:a55ee573116ba208932e2d1a037cc4b10d2c1cb264ced2184d00b18ce585b2c0", - "sha256:a7baf9ffc238e4bf401299f50e971a45bfcc10a785522541a6e3179c83eabf0a", - "sha256:a8d5c70e04aac1eda5c829a26d1f75c6e5286c74743133d9f742cda8e53b9c2f", - "sha256:a91481dbcddf1736c98a80b122afa0f7296eeb80b72344d7f45dc9f781551f56", - "sha256:ab31a88a651039a07a3ae327d68ebdd8bc589b16938c09ef3f32a4b809dc96ef", - "sha256:abc25c3cab9ec7fcd299b9bcb3b8d4a1231877e425c650fa1c7576c5107ab851", - "sha256:adfb84ca6b87e06bc6b146dc7da7623395db1e31621c4785ad0658c5028b37d7", - "sha256:afbbdb120d1e78d2ba8064a68058001b871154cc57787031b645c9142b937a62", - "sha256:afd5562927cdef7c4f5550374acbc117fd4ecc05b5007bdfa57cc5355864e0a4", - "sha256:b070bbe8d3f0f6147689bed981d19bbb33070225373338df755a46893528104a", - "sha256:b0b58fbfa1bf7367dde8a557994e3b1637294be6cf2169810375caf8571a085c", - "sha256:b560e3aa4b1d49e0e6c847d72665384db35b2f5d45f8e6a5c0072e0283430533", - "sha256:b6241d4eee5f89453307c2f2bfa03b50362052ca0af1efecf9fef9a41a22bb4f", - "sha256:b6787b643356111dfd4032b5bffe26d2f8331556ecb79e15dacb9275da02866e", - "sha256:bcbf4af004f98793a95355980764b3d80d47117678118a44a80b721c9913436a", - "sha256:beb72935a941965c52990f3a32d7f07ce869fe21c6af8b34bf6a277b33a345d3", - "sha256:bf2e2458345d9bffb0d9ec16557d8858c9c88d2d11fed53998512504cd9df49b", - "sha256:c2d35a1d047efd68027817b32ab1586c1169e60ca02c65d428ae815b593e65d4", - "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0", - "sha256:c6f2c8372b98208ce609c9e1d707f6918cc118fea4e2c754c9f0812c04ca116d", - "sha256:c817d420c60a5183953c783b0547d9eb43b7b344a2c46f69513d5952a78cddf3", - "sha256:c8ba129e6d3b0136a0f50345b2cb3db53f6bda5dd8c7f5d83fbccba97fb5dcb5", - "sha256:c94e75445b00319c1fad60f3c98b09cd63fe1134a8a953dcd48989ef42318534", - "sha256:cc4691d60512798304acb9207987e7b2b7c44627ea88b9d77489bbe3e6cc3bd4", - "sha256:cc518cea79fd1e2f6c90baafa28906d4309d24f3a63e801d855e7424c5b34144", - "sha256:cd53553ddad4a9c2f1f022756ae64abe16da1feb497edf4d9f87f99ec7cf86bd", - "sha256:cf22b41fdae514ee2f1691b6c3cdeae666d8b7fa9434de445f12bbeee0cf48dd", - "sha256:d38c8f50ecf57f0463399569aa388b232cf1a2ffb8f0a9a5412d0db57e054860", - "sha256:d3be9b2076112e51b323bdf6d5a7f8a798de55fb8d95fcb64bd179460cdc0704", - "sha256:d4f2cc7060dc3646632d7f15fe68e2fa98f58e35dd5666cd525f3b35d3fed7f8", - "sha256:d7520db34088c96cc0e0a3ad51a4fd5b401f279ee112aa2b7f8f976d8582606d", - "sha256:d793bebb202a6000390a5390078e945bbb49855c29c7e4d56a85901326c3b5d9", - "sha256:da052e7962ea2d5e5ef5bc0355d55007407087392cf465b7ad84ce5f3e25fe0f", - "sha256:dae0ed02f6b075426accbf6b2863c3d0a7eacc1b41fb40f2251d931e50188dad", - "sha256:ddc678fb4c7e30cf830a2b5a8d869538bc55b28d6c68544d09c7d0d8f17694dc", - "sha256:df2e6f546c4df14bc81f9498bbc007fbb87669f1bb707c6138878c46b06f6510", - "sha256:e02c5175f63effbd7c5e590399c118d5db6183bbfe8e0d118bdb5c2d1b48d937", - "sha256:e196a4ff48310ba62e53a8e0f97ca2bca83cdd2fe2934d8b5cb0df0a841b193a", - "sha256:e233db59c8f76630c512ab4a4daf5a5986da5c3d5b44b8e9fc742f2a24dbd460", - "sha256:e32be23d538753a8adb6c85bd539f5fd3b15cb987404327c569dfc5fd8366e85", - "sha256:e3d30321949861404323c50aebeb1943461a67cd51d4200ab02babc58bd06a86", - "sha256:e89580a581bf478d8dcb97d9cd011d567768e8bc4095f8557b21c4d4c5fea7d0", - "sha256:e998e304036198b4f6914e6a1e2b6f925208a20e2042563d9734881150c6c246", - "sha256:ec42088248c596dbd61d4ae8a5b004f97a4d91a9fd286f632e42e60b706718d7", - "sha256:efa7b51824aa0ee957ccd5a741c73e6851de55f40d807f08069eb4c5a26b2baa", - "sha256:f0a1bc63a465b6d72569a9bba9f2ef0334c4e03958e043da1920299100bc7c08", - "sha256:f18a5a84e16886898e51ab4b1d43acb3083c39b14c8caeb3589aabff0ee0b270", - "sha256:f2a9efc53d5b714b8df2b4b3e992accf8ce5bbdfe544d74d5c6766c9e1146a3a", - "sha256:f3bbbc998d42f8e561f347e798b85513ba4da324c2b3f9b7969e9c45b10f6169", - "sha256:f42038016852ae51b4088b2862126535cc4fc85802bfe30dea3500fdfaf1864e", - "sha256:f443cdef978430887ed55112b491f670bba6462cea7a7742ff8f14b7abb98d75", - "sha256:f51969bac61441fd31f028d7b3b45962f3ecebf691a510495e5d2cd8c8092dbd", - "sha256:f8aca2e3a72f37bfc7b14ba96d4056244001ddcc18382bd0daa087fd2e68a354", - "sha256:f9737bf36262046213a28e789cc82d82c6ef19c85a0cf05e75c670a33342ac2c", - "sha256:fd6037392f2d57793ab98d9e26798f44b8b4da2f2464388588f48ac52c489ea1", - "sha256:feaa45c0eae424d3e90d78823f3828e7dc42a42f21ed420db98da2c4ecf0a2cb", - "sha256:ff097ae562e637409b429a7ac958a20aab237a0378c42dabaa1e3abf2f896e5f", - "sha256:ff46d772d5f6f73564979cd77a4fffe55c916a05f3cb70e7c9c0590059fb29ef" + "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e", + "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229", + "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3", + "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5", + "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70", + "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15", + "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", + "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", + "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22", + "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf", + "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22", + "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", + "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727", + "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", + "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30", + "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f", + "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f", + "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", + "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", + "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de", + "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875", + "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42", + "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e", + "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6", + "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391", + "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc", + "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b", + "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237", + "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", + "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86", + "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f", + "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a", + "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8", + "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", + "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903", + "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03", + "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e", + "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", + "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7", + "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab", + "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", + "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22", + "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", + "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b", + "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3", + "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be", + "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469", + "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", + "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a", + "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c", + "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a", + "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", + "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94", + "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", + "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", + "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84", + "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", + "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9", + "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", + "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", + "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", + "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", + "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21", + "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa", + "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", + "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", + "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe", + "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83", + "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba", + "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040", + "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", + "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8", + "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", + "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2", + "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a", + "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b", + "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce", + "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c", + "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577", + "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", + "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71", + "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512", + "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540", + "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", + "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2", + "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a", + "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce", + "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e", + "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2", + "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27", + "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1", + "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d", + "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1", + "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330", + "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920", + "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99", + "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff", + "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18", + "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", + "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", + "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", + "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", + "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19", + "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d", + "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70", + "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32", + "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", + "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2", + "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", + "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", + "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5", + "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f", + "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", + "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", + "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b", + "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753", + "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9", + "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957", + "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033", + "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", + "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656", + "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab", + "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", + "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", + "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd", + "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859", + "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11", + "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c", + "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", + "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005", + "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", + "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80", + "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e", + "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", + "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7", + "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965", + "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945", + "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8" ], "markers": "python_version >= '3.6'", - "version": "==5.2.1" + "version": "==5.3.0" }, "mako": { "hashes": [ - "sha256:5324b88089a8978bf76d1629774fcc2f1c07b82acdf00f4c5dd8ceadfffc4b40", - "sha256:e16c01d9ab9c11f7290eef1cfefc093fb5a45ee4a3da09e2fec2e4d1bae54e73" + "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a", + "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc" ], "markers": "python_version >= '3.8'", - "version": "==1.3.3" - }, - "markuppy": { - "hashes": [ - "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f" - ], - "version": "==1.14" + "version": "==1.3.5" }, "markupsafe": { "hashes": [ - "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", - "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", - "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", - "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", - "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", - "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", - "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", - "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", - "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", - "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", - "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", - "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", - "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", - "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", - "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", - "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", - "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", - "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", - "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", - "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", - "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", - "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", - "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", - "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", - "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", - "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", - "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", - "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", - "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", - "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", - "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", - "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", - "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", - "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", - "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", - "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", - "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", - "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", - "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", - "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", - "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", - "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", - "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", - "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", - "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", - "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", - "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", - "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", - "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", - "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", - "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", - "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", - "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", - "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", - "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", - "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", - "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", - "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", - "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", - "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396", + "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38", + "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a", + "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8", + "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b", + "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad", + "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a", + "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a", + "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da", + "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6", + "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8", + "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344", + "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a", + "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8", + "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5", + "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7", + "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170", + "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132", + "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9", + "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd", + "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9", + "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346", + "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc", + "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589", + "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5", + "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915", + "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295", + "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453", + "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea", + "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b", + "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d", + "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b", + "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4", + "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b", + "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7", + "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf", + "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f", + "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91", + "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd", + "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50", + "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b", + "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583", + "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a", + "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984", + "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c", + "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c", + "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25", + "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa", + "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4", + "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3", + "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97", + "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1", + "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd", + "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772", + "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a", + "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729", + "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca", + "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6", + "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635", + "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b", + "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f" ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "markers": "python_version >= '3.9'", + "version": "==3.0.1" }, "marshmallow": { "hashes": [ - "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3", - "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633" + "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e", + "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9" ], "markers": "python_version >= '3.8'", - "version": "==3.21.1" - }, - "odfpy": { - "hashes": [ - "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", - "sha256:fc3b8d1bc098eba4a0fda865a76d9d1e577c4ceec771426bcb169a82c5e9dfe0" - ], - "version": "==1.4.1" + "version": "==3.22.0" }, "oic": { "hashes": [ @@ -854,13 +855,6 @@ "markers": "python_version ~= '3.8'", "version": "==1.7.0" }, - "openpyxl": { - "hashes": [ - "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", - "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" - ], - "version": "==3.1.2" - }, "orderedmultidict": { "hashes": [ "sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad", @@ -870,18 +864,18 @@ }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.1" }, "phonenumberslite": { "hashes": [ - "sha256:343b300d9c8ac4dca84e6b922ec51c3d838f2feabf9dd2418da64b639d220879", - "sha256:64b513134b785fbeeaf4cc020e18d384541c4118ed3ece2118437d996f435ca0" + "sha256:9a4d040f4ef9ea5cbbd907f6fe9a52313d46191051e3a9994102c05082a9db67", + "sha256:baf770804c056a122c76f0d29d3a85bd3111c511c5350548e1c3355449b824e9" ], - "version": "==8.13.35" + "version": "==8.13.47" }, "psycopg2-binary": { "hashes": [ @@ -972,143 +966,153 @@ }, "pycryptodomex": { "hashes": [ - "sha256:0daad007b685db36d977f9de73f61f8da2a7104e20aca3effd30752fd56f73e1", - "sha256:108e5f1c1cd70ffce0b68739c75734437c919d2eaec8e85bffc2c8b4d2794305", - "sha256:19764605feea0df966445d46533729b645033f134baeb3ea26ad518c9fdf212c", - "sha256:1be97461c439a6af4fe1cf8bf6ca5936d3db252737d2f379cc6b2e394e12a458", - "sha256:25cd61e846aaab76d5791d006497134602a9e451e954833018161befc3b5b9ed", - "sha256:2a47bcc478741b71273b917232f521fd5704ab4b25d301669879e7273d3586cc", - "sha256:59af01efb011b0e8b686ba7758d59cf4a8263f9ad35911bfe3f416cee4f5c08c", - "sha256:5dcac11031a71348faaed1f403a0debd56bf5404232284cf8c761ff918886ebc", - "sha256:62a5ec91388984909bb5398ea49ee61b68ecb579123694bffa172c3b0a107079", - "sha256:645bd4ca6f543685d643dadf6a856cc382b654cc923460e3a10a49c1b3832aeb", - "sha256:653b29b0819605fe0898829c8ad6400a6ccde096146730c2da54eede9b7b8baa", - "sha256:69138068268127cd605e03438312d8f271135a33140e2742b417d027a0539427", - "sha256:6e186342cfcc3aafaad565cbd496060e5a614b441cacc3995ef0091115c1f6c5", - "sha256:76bd15bb65c14900d98835fcd10f59e5e0435077431d3a394b60b15864fddd64", - "sha256:7805830e0c56d88f4d491fa5ac640dfc894c5ec570d1ece6ed1546e9df2e98d6", - "sha256:7a710b79baddd65b806402e14766c721aee8fb83381769c27920f26476276c1e", - "sha256:7a7a8f33a1f1fb762ede6cc9cbab8f2a9ba13b196bfaf7bc6f0b39d2ba315a43", - "sha256:82ee7696ed8eb9a82c7037f32ba9b7c59e51dda6f105b39f043b6ef293989cb3", - "sha256:88afd7a3af7ddddd42c2deda43d53d3dfc016c11327d0915f90ca34ebda91499", - "sha256:8af1a451ff9e123d0d8bd5d5e60f8e3315c3a64f3cdd6bc853e26090e195cdc8", - "sha256:8ee606964553c1a0bc74057dd8782a37d1c2bc0f01b83193b6f8bb14523b877b", - "sha256:91852d4480a4537d169c29a9d104dda44094c78f1f5b67bca76c29a91042b623", - "sha256:9c682436c359b5ada67e882fec34689726a09c461efd75b6ea77b2403d5665b7", - "sha256:bc3ee1b4d97081260d92ae813a83de4d2653206967c4a0a017580f8b9548ddbc", - "sha256:bca649483d5ed251d06daf25957f802e44e6bb6df2e8f218ae71968ff8f8edc4", - "sha256:c39778fd0548d78917b61f03c1fa8bfda6cfcf98c767decf360945fe6f97461e", - "sha256:cbe71b6712429650e3883dc81286edb94c328ffcd24849accac0a4dbcc76958a", - "sha256:d00fe8596e1cc46b44bf3907354e9377aa030ec4cd04afbbf6e899fc1e2a7781", - "sha256:d3584623e68a5064a04748fb6d76117a21a7cb5eaba20608a41c7d0c61721794", - "sha256:e48217c7901edd95f9f097feaa0388da215ed14ce2ece803d3f300b4e694abea", - "sha256:f2e497413560e03421484189a6b65e33fe800d3bd75590e6d78d4dfdb7accf3b", - "sha256:ff5c9a67f8a4fba4aed887216e32cbc48f2a6fb2673bb10a99e43be463e15913" + "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3", + "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516", + "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f", + "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c", + "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e", + "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e", + "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c", + "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31", + "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b", + "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832", + "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e", + "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b", + "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37", + "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65", + "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a", + "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3", + "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b", + "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9", + "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971", + "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2", + "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42", + "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd", + "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e", + "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0", + "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c", + "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a", + "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce", + "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6", + "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822", + "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9", + "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00", + "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.20.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==3.21.0" }, "pydantic": { "hashes": [ - "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5", - "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc" + "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", + "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12" ], "markers": "python_version >= '3.8'", - "version": "==2.7.1" + "version": "==2.9.2" }, "pydantic-core": { "hashes": [ - "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b", - "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a", - "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90", - "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d", - "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e", - "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d", - "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027", - "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804", - "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347", - "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400", - "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3", - "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399", - "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349", - "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd", - "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c", - "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e", - "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413", - "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3", - "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e", - "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3", - "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91", - "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce", - "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c", - "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb", - "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664", - "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6", - "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd", - "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3", - "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af", - "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043", - "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350", - "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7", - "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0", - "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563", - "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761", - "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72", - "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3", - "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb", - "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788", - "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b", - "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c", - "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038", - "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250", - "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec", - "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c", - "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74", - "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81", - "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439", - "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75", - "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0", - "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8", - "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150", - "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438", - "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae", - "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857", - "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038", - "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374", - "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f", - "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241", - "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592", - "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4", - "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d", - "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b", - "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b", - "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182", - "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e", - "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641", - "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70", - "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9", - "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a", - "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543", - "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b", - "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f", - "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38", - "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845", - "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2", - "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0", - "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4", - "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242" + "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", + "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", + "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", + "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", + "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", + "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", + "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", + "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", + "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", + "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", + "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", + "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", + "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", + "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", + "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", + "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", + "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368", + "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", + "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", + "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2", + "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", + "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", + "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", + "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", + "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", + "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", + "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271", + "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", + "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb", + "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13", + "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", + "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556", + "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665", + "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", + "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", + "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", + "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", + "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", + "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", + "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", + "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", + "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", + "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", + "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", + "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", + "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", + "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658", + "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", + "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", + "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", + "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", + "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", + "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", + "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", + "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", + "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", + "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", + "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad", + "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", + "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", + "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", + "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", + "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", + "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", + "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", + "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", + "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", + "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", + "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555", + "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", + "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6", + "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", + "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", + "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", + "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", + "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", + "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", + "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", + "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", + "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12", + "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", + "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", + "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", + "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", + "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", + "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", + "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", + "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", + "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607" ], "markers": "python_version >= '3.8'", - "version": "==2.18.2" + "version": "==2.23.4" }, "pydantic-settings": { "hashes": [ - "sha256:00b9f6a5e95553590434c0fa01ead0b216c3e10bc54ae02e37f359948643c5ed", - "sha256:0235391d26db4d2190cb9b31051c4b46882d28a51533f97440867f012d4da091" + "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", + "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.5.2" }, "pyjwkest": { "hashes": [ @@ -1133,62 +1137,6 @@ "markers": "python_version >= '3.8'", "version": "==1.0.1" }, - "pyyaml": { - "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" - ], - "version": "==6.0.1" - }, "pyzipper": { "hashes": [ "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc", @@ -1200,28 +1148,28 @@ }, "requests": { "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", + "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0" + "markers": "python_version >= '3.8'", + "version": "==2.32.3" }, "s3transfer": { "hashes": [ - "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", - "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d" + "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d", + "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c" ], "markers": "python_version >= '3.8'", - "version": "==0.10.1" + "version": "==0.10.3" }, "setuptools": { "hashes": [ - "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", - "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" + "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2", + "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538" ], "markers": "python_version >= '3.8'", - "version": "==69.5.1" + "version": "==75.1.0" }, "six": { "hashes": [ @@ -1233,11 +1181,11 @@ }, "sqlparse": { "hashes": [ - "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", - "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" + "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", + "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" ], "markers": "python_version >= '3.8'", - "version": "==0.5.0" + "version": "==0.5.1" }, "tablib": { "extras": [ @@ -1265,43 +1213,29 @@ }, "typing-extensions": { "hashes": [ - "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", - "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.11.0" + "version": "==4.12.2" }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.3" }, "whitenoise": { "hashes": [ - "sha256:8998f7370973447fac1e8ef6e8ded2c5209a7b1f67c1012866dbcd09681c3251", - "sha256:b1f9db9bf67dc183484d760b99f4080185633136a273a03f6436034a41064146" + "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636", + "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==6.6.0" - }, - "xlrd": { - "hashes": [ - "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", - "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88" - ], - "version": "==2.0.1" - }, - "xlwt": { - "hashes": [ - "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", - "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88" - ], - "version": "==1.3.0" + "version": "==6.7.0" }, "zope.event": { "hashes": [ @@ -1313,45 +1247,46 @@ }, "zope.interface": { "hashes": [ - "sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e", - "sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf", - "sha256:10cde8dc6b2fd6a1d0b5ca4be820063e46ddba417ab82bcf55afe2227337b130", - "sha256:187f7900b63845dcdef1be320a523dbbdba94d89cae570edc2781eb55f8c2f86", - "sha256:1b0c4c90e5eefca2c3e045d9f9ed9f1e2cdbe70eb906bff6b247e17119ad89a1", - "sha256:22e8a218e8e2d87d4d9342aa973b7915297a08efbebea5b25900c73e78ed468e", - "sha256:26c9a37fb395a703e39b11b00b9e921c48f82b6e32cc5851ad5d0618cd8876b5", - "sha256:2bb78c12c1ad3a20c0d981a043d133299117b6854f2e14893b156979ed4e1d2c", - "sha256:2c3cfb272bcb83650e6695d49ae0d14dd06dc694789a3d929f23758557a23d92", - "sha256:2f32010ffb87759c6a3ad1c65ed4d2e38e51f6b430a1ca11cee901ec2b42e021", - "sha256:3c8731596198198746f7ce2a4487a0edcbc9ea5e5918f0ab23c4859bce56055c", - "sha256:40aa8c8e964d47d713b226c5baf5f13cdf3a3169c7a2653163b17ff2e2334d10", - "sha256:4137025731e824eee8d263b20682b28a0bdc0508de9c11d6c6be54163e5b7c83", - "sha256:46034be614d1f75f06e7dcfefba21d609b16b38c21fc912b01a99cb29e58febb", - "sha256:483e118b1e075f1819b3c6ace082b9d7d3a6a5eb14b2b375f1b80a0868117920", - "sha256:4d6b229f5e1a6375f206455cc0a63a8e502ed190fe7eb15e94a312dc69d40299", - "sha256:567d54c06306f9c5b6826190628d66753b9f2b0422f4c02d7c6d2b97ebf0a24e", - "sha256:5683aa8f2639016fd2b421df44301f10820e28a9b96382a6e438e5c6427253af", - "sha256:600101f43a7582d5b9504a7c629a1185a849ce65e60fca0f6968dfc4b76b6d39", - "sha256:62e32f02b3f26204d9c02c3539c802afc3eefb19d601a0987836ed126efb1f21", - "sha256:69dedb790530c7ca5345899a1b4cb837cc53ba669051ea51e8c18f82f9389061", - "sha256:72d5efecad16c619a97744a4f0b67ce1bcc88115aa82fcf1dc5be9bb403bcc0b", - "sha256:8d407e0fd8015f6d5dfad481309638e1968d70e6644e0753f229154667dd6cd5", - "sha256:a058e6cf8d68a5a19cb5449f42a404f0d6c2778b897e6ce8fadda9cea308b1b0", - "sha256:a1adc14a2a9d5e95f76df625a9b39f4709267a483962a572e3f3001ef90ea6e6", - "sha256:a56fe1261230093bfeedc1c1a6cd6f3ec568f9b07f031c9a09f46b201f793a85", - "sha256:ad4524289d8dbd6fb5aa17aedb18f5643e7d48358f42c007a5ee51a2afc2a7c5", - "sha256:afa0491a9f154cf8519a02026dc85a416192f4cb1efbbf32db4a173ba28b289a", - "sha256:bf34840e102d1d0b2d39b1465918d90b312b1119552cebb61a242c42079817b9", - "sha256:c40df4aea777be321b7e68facb901bc67317e94b65d9ab20fb96e0eb3c0b60a1", - "sha256:d0e7321557c702bd92dac3c66a2f22b963155fdb4600133b6b29597f62b71b12", - "sha256:d165d7774d558ea971cb867739fb334faf68fc4756a784e689e11efa3becd59e", - "sha256:e78a183a3c2f555c2ad6aaa1ab572d1c435ba42f1dc3a7e8c82982306a19b785", - "sha256:e8fa0fb05083a1a4216b4b881fdefa71c5d9a106e9b094cd4399af6b52873e91", - "sha256:f83d6b4b22262d9a826c3bd4b2fbfafe1d0000f085ef8e44cd1328eea274ae6a", - "sha256:f95bebd0afe86b2adc074df29edb6848fc4d474ff24075e2c263d698774e108d" + "sha256:07add15de0cc7e69917f7d286b64d54125c950aeb43efed7a5ea7172f000fbc1", + "sha256:0ac20581fc6cd7c754f6dff0ae06fedb060fa0e9ea6309d8be8b2701d9ea51c4", + "sha256:124149e2d42067b9c6597f4dafdc7a0983d0163868f897b7bb5dc850b14f9a87", + "sha256:27cfb5205d68b12682b6e55ab8424662d96e8ead19550aad0796b08dd2c9a45e", + "sha256:2a29ac607e970b5576547f0e3589ec156e04de17af42839eedcf478450687317", + "sha256:2b6a4924f5bad9fe21d99f66a07da60d75696a136162427951ec3cb223a5570d", + "sha256:2bd9e9f366a5df08ebbdc159f8224904c1c5ce63893984abb76954e6fbe4381a", + "sha256:3bcff5c09d0215f42ba64b49205a278e44413d9bf9fa688fd9e42bfe472b5f4f", + "sha256:3f005869a1a05e368965adb2075f97f8ee9a26c61898a9e52a9764d93774f237", + "sha256:4a00ead2e24c76436e1b457a5132d87f83858330f6c923640b7ef82d668525d1", + "sha256:4af4a12b459a273b0b34679a5c3dc5e34c1847c3dd14a628aa0668e19e638ea2", + "sha256:5501e772aff595e3c54266bc1bfc5858e8f38974ce413a8f1044aae0f32a83a3", + "sha256:5e28ea0bc4b084fc93a483877653a033062435317082cdc6388dec3438309faf", + "sha256:5e956b1fd7f3448dd5e00f273072e73e50dfafcb35e4227e6d5af208075593c9", + "sha256:5fcf379b875c610b5a41bc8a891841533f98de0520287d7f85e25386cd10d3e9", + "sha256:6159e767d224d8f18deff634a1d3722e68d27488c357f62ebeb5f3e2f5288b1f", + "sha256:661d5df403cd3c5b8699ac480fa7f58047a3253b029db690efa0c3cf209993ef", + "sha256:711eebc77f2092c6a8b304bad0b81a6ce3cf5490b25574e7309fbc07d881e3af", + "sha256:80a3c00b35f6170be5454b45abe2719ea65919a2f09e8a6e7b1362312a872cd3", + "sha256:848b6fa92d7c8143646e64124ed46818a0049a24ecc517958c520081fd147685", + "sha256:91b6c30689cfd87c8f264acb2fc16ad6b3c72caba2aec1bf189314cf1a84ca33", + "sha256:9733a9a0f94ef53d7aa64661811b20875b5bc6039034c6e42fb9732170130573", + "sha256:9940d5bc441f887c5f375ec62bcf7e7e495a2d5b1da97de1184a88fb567f06af", + "sha256:9e3e48f3dea21c147e1b10c132016cb79af1159facca9736d231694ef5a740a8", + "sha256:a14c9decf0eb61e0892631271d500c1e306c7b6901c998c7035e194d9150fdd1", + "sha256:a735f82d2e3ed47ca01a20dfc4c779b966b16352650a8036ab3955aad151ed8a", + "sha256:a99240b1d02dc469f6afbe7da1bf617645e60290c272968f4e53feec18d7dce8", + "sha256:b7b25db127db3e6b597c5f74af60309c4ad65acd826f89609662f0dc33a54728", + "sha256:b936d61dbe29572fd2cfe13e30b925e5383bed1aba867692670f5a2a2eb7b4e9", + "sha256:bec001798ab62c3fc5447162bf48496ae9fba02edc295a9e10a0b0c639a6452e", + "sha256:cc8a318162123eddbdf22fcc7b751288ce52e4ad096d3766ff1799244352449d", + "sha256:d0a45b5af9f72c805ee668d1479480ca85169312211bed6ed18c343e39307d5f", + "sha256:e53c291debef523b09e1fe3dffe5f35dde164f1c603d77f770b88a1da34b7ed6", + "sha256:ec1ef1fdb6f014d5886b97e52b16d0f852364f447d2ab0f0c6027765777b6667", + "sha256:ec59fe53db7d32abb96c6d4efeed84aab4a7c38c62d7a901a9b20c09dd936e7a", + "sha256:f245d039f72e6f802902375755846f5de1ee1e14c3e8736c078565599bcab621", + "sha256:ff115ef91c0eeac69cd92daeba36a9d8e14daee445b504eeea2b1c0b55821984" ], - "markers": "python_version >= '3.7'", - "version": "==6.3" + "markers": "python_version >= '3.8'", + "version": "==7.1.0" } }, "develop": { @@ -1365,12 +1300,12 @@ }, "bandit": { "hashes": [ - "sha256:36de50f720856ab24a24dbaa5fee2c66050ed97c1477e0a1159deab1775eab6b", - "sha256:509f7af645bc0cd8fd4587abc1a038fc795636671ee8204d502b933aee44f381" + "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b", + "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.7.8" + "version": "==1.7.10" }, "beautifulsoup4": { "hashes": [ @@ -1382,49 +1317,49 @@ }, "black": { "hashes": [ - "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474", - "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1", - "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0", - "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8", - "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96", - "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1", - "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04", - "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021", - "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94", - "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d", - "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c", - "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7", - "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c", - "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc", - "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7", - "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d", - "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c", - "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741", - "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce", - "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb", - "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063", - "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e" + "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", + "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", + "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", + "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", + "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", + "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", + "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", + "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", + "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", + "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", + "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", + "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", + "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", + "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", + "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", + "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", + "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", + "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", + "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", + "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", + "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", + "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==24.4.2" + "markers": "python_version >= '3.9'", + "version": "==24.10.0" }, "blinker": { "hashes": [ - "sha256:5f1cdeff423b77c31b89de0565cd03e5275a03028f44b2b15f912632a58cced6", - "sha256:da44ec748222dcd0105ef975eed946da197d5bdf8bafb6aa92f5bc89da63fa25" + "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01", + "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83" ], "markers": "python_version >= '3.8'", - "version": "==1.8.1" + "version": "==1.8.2" }, "boto3": { "hashes": [ - "sha256:decf52f8d5d8a1b10c9ff2a0e96ee207ed79e33d2e53fdf0880a5cbef70785e0", - "sha256:e836b71d79671270fccac0a4d4c8ec239a6b82ea47c399b64675aa597d0ee63b" + "sha256:2bf7e7f376aee52155fc4ae4487f29333a6bcdf3a05c3bc4fede10b972d951a6", + "sha256:e74bc6d69c04ca611b7f58afe08e2ded6cb6504a4a80557b656abeefee395f88" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.95" + "version": "==1.35.41" }, "boto3-mocking": { "hashes": [ @@ -1437,28 +1372,28 @@ }, "boto3-stubs": { "hashes": [ - "sha256:412006b27ee707e9b51a084b02ac92b143af8a3b56727582afec2a76ce93c3b6", - "sha256:4fb5830626de42446c238ca72ca1a53e461281396007fb900edf50ceeb044a10" + "sha256:5884048edf0581479ecc3726c0b4b6d83640b5590d4646cbd229bae8f5a5666b", + "sha256:724c5999390eed5ed84832dcd003d1dcd1b12c941e50f6a6f63378c407d8fa0a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.95" + "version": "==1.35.41" }, "botocore": { "hashes": [ - "sha256:6bd76a2eadb42b91fa3528392e981ad5b4dfdee3968fa5b904278acf6cbf15ff", - "sha256:ead5823e0dd6751ece5498cb979fd9abf190e691c8833bcac6876fd6ca261fa7" + "sha256:8a09a32136df8768190a6c92f0240cd59c30deb99c89026563efadbbed41fa00", + "sha256:915c4d81e3a0be3b793c1e2efdf19af1d0a9cd4a2d8de08ee18216c14d67764b" ], "markers": "python_version >= '3.8'", - "version": "==1.34.95" + "version": "==1.35.41" }, "botocore-stubs": { "hashes": [ - "sha256:64d80a3467e3b19939e9c2750af33328b3087f8f524998dbdf7ed168227f507d", - "sha256:b0345f55babd8b901c53804fc5c326a4a0bd2e23e3b71f9ea5d9f7663466e6ba" + "sha256:62e369aed694471eaf72305cd2f33c356337d49637a5fcc17fc2ef237e8f517f", + "sha256:99e8f0e20266b2abc0e095ef19e8e628a926c25c4a0edbfd25978f484677bac6" ], - "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==1.34.94" + "markers": "python_version >= '3.8'", + "version": "==1.35.41" }, "click": { "hashes": [ @@ -1479,53 +1414,53 @@ }, "django-debug-toolbar": { "hashes": [ - "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4", - "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6" + "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", + "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.3.0" + "version": "==4.4.6" }, "django-model2puml": { "hashes": [ - "sha256:6e773d742e556020a04d3216ce5dee5d3551da162e2d42a997f85b4ed1854771" + "sha256:f7ef57efbf261e8e0f90043c2be379e9457b30603ccc01fe7a01c233d0dfa27c" ], "index": "pypi", - "version": "==0.4.1" + "version": "==0.5.1" }, "django-stubs": { "hashes": [ - "sha256:084484cbe16a6d388e80ec687e46f529d67a232f3befaf55c936b3b476be289d", - "sha256:b8a792bee526d6cab31e197cb414ee7fa218abd931a50948c66a80b3a2548621" + "sha256:86128c228b65e6c9a85e5dc56eb1c6f41125917dae0e21e6cfecdf1b27e630c5", + "sha256:b98d49a80aa4adf1433a97407102d068de26c739c405431d93faad96dd282c40" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.0.0" + "version": "==5.1.0" }, "django-stubs-ext": { "hashes": [ - "sha256:5bacfbb498a206d5938454222b843d81da79ea8b6fcd1a59003f529e775bc115", - "sha256:8e1334fdf0c8bff87e25d593b33d4247487338aaed943037826244ff788b56a8" + "sha256:a455fc222c90b30b29ad8c53319559f5b54a99b4197205ddbb385aede03b395d", + "sha256:ed7d51c0b731651879fc75f331fb0806d98b67bfab464e96e2724db6b46ef926" ], "markers": "python_version >= '3.8'", - "version": "==5.0.0" + "version": "==5.1.0" }, "django-webtest": { "hashes": [ - "sha256:9597d26ced599bc5d4d9366bb451469fc9707b4779f79543cdf401ae6c5aeb35", - "sha256:e29baf8337e7fe7db41ce63ca6661f7b5c77fe56f506f48b305e09313f5475b4" + "sha256:5012c30665e7a6e585a1544eda75045d07d5b3f5ccccd4d0fe144c4555884095", + "sha256:de5c988c20eef7abbb3d0508494d9e576af08087d0fb6109b1d54f15ef4d78fa" ], "index": "pypi", - "version": "==1.9.11" + "version": "==1.9.12" }, "flake8": { "hashes": [ - "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", - "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3" + "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", + "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" ], "index": "pypi", "markers": "python_full_version >= '3.8.1'", - "version": "==7.0.0" + "version": "==7.1.1" }, "jmespath": { "hashes": [ @@ -1561,37 +1496,42 @@ }, "mypy": { "hashes": [ - "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061", - "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99", - "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de", - "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a", - "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9", - "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec", - "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1", - "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131", - "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f", - "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821", - "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5", - "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee", - "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e", - "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746", - "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2", - "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0", - "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b", - "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53", - "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30", - "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda", - "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051", - "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2", - "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7", - "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee", - "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727", - "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976", - "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4" + "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a", + "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa", + "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff", + "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9", + "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57", + "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3", + "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b", + "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721", + "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed", + "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893", + "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b", + "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7", + "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521", + "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9", + "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0", + "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d", + "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469", + "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8", + "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e", + "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1", + "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164", + "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475", + "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e", + "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309", + "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7", + "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601", + "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e", + "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0", + "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642", + "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f", + "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f", + "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.10.0" + "version": "==1.12.0" }, "mypy-extensions": { "hashes": [ @@ -1611,11 +1551,11 @@ }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.1" }, "pathspec": { "hashes": [ @@ -1627,27 +1567,27 @@ }, "pbr": { "hashes": [ - "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda", - "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9" + "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24", + "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a" ], "markers": "python_version >= '2.6'", - "version": "==6.0.0" + "version": "==6.1.0" }, "platformdirs": { "hashes": [ - "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf", - "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.1" + "version": "==4.3.6" }, "pycodestyle": { "hashes": [ - "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", - "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", + "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" ], "markers": "python_version >= '3.8'", - "version": "==2.11.1" + "version": "==2.12.1" }, "pyflakes": { "hashes": [ @@ -1659,11 +1599,11 @@ }, "pygments": { "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" + "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", + "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" + "markers": "python_version >= '3.8'", + "version": "==2.18.0" }, "python-dateutil": { "hashes": [ @@ -1675,75 +1615,78 @@ }, "pyyaml": { "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", + "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", + "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", + "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", + "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", + "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", + "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", + "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", + "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", + "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", + "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", + "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", + "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", + "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", + "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", + "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", + "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", + "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", + "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", + "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", + "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", + "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", + "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", + "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", + "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", + "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", + "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", + "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", + "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", + "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", + "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", + "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", + "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", + "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", + "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", + "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", + "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", + "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", + "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", + "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", + "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", + "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", + "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", + "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", + "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", + "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", + "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", + "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", + "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", + "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", + "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", + "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", + "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" ], - "version": "==6.0.1" + "markers": "python_version >= '3.8'", + "version": "==6.0.2" }, "rich": { "hashes": [ - "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", - "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" + "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", + "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.1" + "markers": "python_full_version >= '3.8.0'", + "version": "==13.9.2" }, "s3transfer": { "hashes": [ - "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", - "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d" + "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d", + "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c" ], "markers": "python_version >= '3.8'", - "version": "==0.10.1" + "version": "==0.10.3" }, "six": { "hashes": [ @@ -1755,94 +1698,94 @@ }, "soupsieve": { "hashes": [ - "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690", - "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7" + "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", + "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9" ], "markers": "python_version >= '3.8'", - "version": "==2.5" + "version": "==2.6" }, "sqlparse": { "hashes": [ - "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", - "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" + "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", + "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" ], "markers": "python_version >= '3.8'", - "version": "==0.5.0" + "version": "==0.5.1" }, "stevedore": { "hashes": [ - "sha256:1c15d95766ca0569cad14cb6272d4d31dae66b011a929d7c18219c176ea1b5c9", - "sha256:46b93ca40e1114cea93d738a6c1e365396981bb6bb78c27045b7587c9473544d" + "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78", + "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a" ], "markers": "python_version >= '3.8'", - "version": "==5.2.0" + "version": "==5.3.0" }, "tomli": { "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", + "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" ], "markers": "python_version < '3.11'", - "version": "==2.0.1" + "version": "==2.0.2" }, "types-awscrt": { "hashes": [ - "sha256:3ae374b553e7228ba41a528cf42bd0b2ad7303d806c73eff4aaaac1515e3ea4e", - "sha256:64898a2f4a2468f66233cb8c29c5f66de907cf80ba1ef5bb1359aef2f81bb521" + "sha256:67a660c90bad360c339f6a79310cc17094d12472042c7ca5a41450aaf5fc9a54", + "sha256:b2c196bbd3226bab42d80fae13c34548de9ddc195f5a366d79c15d18e5897aa9" ], - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==0.20.9" + "markers": "python_version >= '3.8'", + "version": "==0.22.0" }, "types-cachetools": { "hashes": [ - "sha256:27c982cdb9cf3fead8b0089ee6b895715ecc99dac90ec29e2cab56eb1aaf4199", - "sha256:98c069dc7fc087b1b061703369c80751b0a0fc561f6fb072b554e5eee23773a0" + "sha256:b888ab5c1a48116f7799cd5004b18474cd82b5463acb5ffb2db2fc9c7b053bc0", + "sha256:efb2ed8bf27a4b9d3ed70d33849f536362603a90b8090a328acf0cd42fda82e2" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==5.3.0.7" + "markers": "python_version >= '3.8'", + "version": "==5.5.0.20240820" }, "types-pyyaml": { "hashes": [ - "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342", - "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6" + "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570", + "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587" ], "markers": "python_version >= '3.8'", - "version": "==6.0.12.20240311" + "version": "==6.0.12.20240917" }, "types-requests": { "hashes": [ - "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1", - "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5" + "sha256:2850e178db3919d9bf809e434eef65ba49d0e7e33ac92d588f4a5e295fffd405", + "sha256:59c2f673eb55f32a99b2894faf6020e1a9f4a402ad0f192bfee0b64469054310" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.31.0.20240406" + "version": "==2.32.0.20240914" }, "types-s3transfer": { "hashes": [ - "sha256:02154cce46528287ad76ad1a0153840e0492239a0887e8833466eccf84b98da0", - "sha256:49a7c81fa609ac1532f8de3756e64b58afcecad8767933310228002ec7adff74" + "sha256:d34c5a82f531af95bb550927136ff5b737a1ed3087f90a59d545591dfde5b4cc", + "sha256:f761b2876ac4c208e6c6b75cdf5f6939009768be9950c545b11b0225e7703ee7" ], - "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==0.10.1" + "markers": "python_version >= '3.8'", + "version": "==0.10.3" }, "typing-extensions": { "hashes": [ - "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", - "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.11.0" + "version": "==4.12.2" }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.3" }, "waitress": { "hashes": [ @@ -1854,19 +1797,19 @@ }, "webob": { "hashes": [ - "sha256:73aae30359291c14fa3b956f8b5ca31960e420c28c1bec002547fb04928cf89b", - "sha256:b64ef5141be559cfade448f044fa45c2260351edcb6a8ef6b7e00c7dcef0c323" + "sha256:2abc1555e118fc251e705fc6dc66c7f5353bb9fbfab6d20e22f1c02b4b71bcee", + "sha256:b60ba63f05c0cf61e086a10c3781a41fcfe30027753a8ae6d819c77592ce83ea" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.8.7" + "version": "==1.8.8" }, "webtest": { "hashes": [ - "sha256:2a001a9efa40d2a7e5d9cd8d1527c75f41814eb6afce2c3d207402547b1e5ead", - "sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb" + "sha256:493b5c802f8948a65b5e3a1ad5b2524ee5e1ab60cd713d9a3da3b8da082c06fe", + "sha256:b3bc75d020d0576ee93a5f149666045e58fe2400ea5f0c214d7430d7d213d0d0" ], - "markers": "python_version >= '3.6' and python_version < '4'", - "version": "==3.0.0" + "markers": "python_version >= '3.7'", + "version": "==3.0.1" } } } diff --git a/src/requirements.txt b/src/requirements.txt index 3f7158449..52c601b55 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,75 +1,68 @@ -i https://pypi.python.org/simple -annotated-types==0.6.0; python_version >= '3.8' +annotated-types==0.7.0; python_version >= '3.8' asgiref==3.8.1; python_version >= '3.8' -boto3==1.34.95; python_version >= '3.8' -botocore==1.34.95; python_version >= '3.8' -cachetools==5.3.3; python_version >= '3.7' -certifi==2024.2.2; python_version >= '3.6' +boto3==1.35.41; python_version >= '3.8' +botocore==1.35.41; python_version >= '3.8' +cachetools==5.5.0; python_version >= '3.7' +certifi==2024.8.30; python_version >= '3.6' cfenv==0.5.3 -cffi==1.16.0; platform_python_implementation != 'PyPy' -charset-normalizer==3.3.2; python_full_version >= '3.7.0' -cryptography==42.0.5; python_version >= '3.7' +cffi==1.17.1; platform_python_implementation != 'PyPy' +charset-normalizer==3.4.0; python_full_version >= '3.7.0' +cryptography==43.0.1; python_version >= '3.7' defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' diff-match-patch==20230430; python_version >= '3.7' -dj-database-url==2.1.0 +dj-database-url==2.2.0 dj-email-url==1.0.6 django==4.2.10; python_version >= '3.8' django-admin-multiple-choice-list-filter==0.1.1 django-allow-cidr==0.7.1 django-auditlog==3.0.0; python_version >= '3.8' django-cache-url==3.4.5 -django-cors-headers==4.3.1; python_version >= '3.8' +django-cors-headers==4.5.0; python_version >= '3.9' django-csp==3.8 django-fsm==2.8.1 -django-import-export==3.3.8; python_version >= '3.8' +django-import-export==4.1.1; python_version >= '3.8' django-login-required-middleware==0.9.0 -django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8' +django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8' django-waffle==4.1.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8' environs[django]==11.0.0; python_version >= '3.8' -et-xmlfile==1.1.0; python_version >= '3.6' -faker==25.0.0; python_version >= '3.8' +faker==30.3.0; python_version >= '3.8' fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c furl==2.1.3 future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' -gevent==24.2.1; python_version >= '3.8' -greenlet==3.0.3; python_version >= '3.7' -gunicorn==22.0.0; python_version >= '3.7' -idna==3.7; python_version >= '3.5' +gevent==24.10.2; python_version >= '3.9' +greenlet==3.1.1; python_version >= '3.7' +gunicorn==23.0.0; python_version >= '3.7' +idna==3.10; python_version >= '3.6' jmespath==1.0.1; python_version >= '3.7' -lxml==5.2.1; python_version >= '3.6' -mako==1.3.3; python_version >= '3.8' -markuppy==1.14 -markupsafe==2.1.5; python_version >= '3.7' -marshmallow==3.21.1; python_version >= '3.8' -odfpy==1.4.1 +lxml==5.3.0; python_version >= '3.6' +mako==1.3.5; python_version >= '3.8' +markupsafe==3.0.1; python_version >= '3.9' +marshmallow==3.22.0; python_version >= '3.8' oic==1.7.0; python_version ~= '3.8' -openpyxl==3.1.2 orderedmultidict==1.0.1 -packaging==24.0; python_version >= '3.7' -phonenumberslite==8.13.35 +packaging==24.1; python_version >= '3.8' +phonenumberslite==8.13.47 psycopg2-binary==2.9.9; python_version >= '3.7' pycparser==2.22; python_version >= '3.8' -pycryptodomex==3.20.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -pydantic==2.7.1; python_version >= '3.8' -pydantic-core==2.18.2; python_version >= '3.8' -pydantic-settings==2.2.1; python_version >= '3.8' +pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' +pydantic==2.9.2; python_version >= '3.8' +pydantic-core==2.23.4; python_version >= '3.8' +pydantic-settings==2.5.2; python_version >= '3.8' pyjwkest==1.4.2 python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' python-dotenv==1.0.1; python_version >= '3.8' -pyyaml==6.0.1 pyzipper==0.3.6; python_version >= '3.4' -requests==2.31.0; python_version >= '3.7' -s3transfer==0.10.1; python_version >= '3.8' -setuptools==69.5.1; python_version >= '3.8' +requests==2.32.3; python_version >= '3.8' +s3transfer==0.10.3; python_version >= '3.8' +setuptools==75.1.0; python_version >= '3.8' six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -sqlparse==0.5.0; python_version >= '3.8' +sqlparse==0.5.1; python_version >= '3.8' tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8' tblib==3.0.0; python_version >= '3.8' -typing-extensions==4.11.0; python_version >= '3.8' -urllib3==2.2.1; python_version >= '3.8' -whitenoise==6.6.0; python_version >= '3.8' -xlrd==2.0.1 -xlwt==1.3.0 +typing-extensions==4.12.2; python_version >= '3.8' +urllib3==2.2.3; python_version >= '3.8' +whitenoise==6.7.0; python_version >= '3.8' zope.event==5.0; python_version >= '3.7' -zope.interface==6.3; python_version >= '3.7' +zope.interface==7.1.0; python_version >= '3.8' From 4393b5a1d7eb6ac4e57931b2536b2ac87ac130b8 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:15:09 -0700 Subject: [PATCH 157/175] Add domain manager page content updates --- src/registrar/templates/domain_add_user.html | 13 ++++++++++--- src/registrar/templates/domain_users.html | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index e95bacd76..81b6678af 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -18,10 +18,17 @@ {% endblock breadcrumb %}

    Add a domain manager

    - -

    You can add another user to help manage your domain. If they aren't an organization member they will - need to sign in to the .gov registrar with their Login.gov account. +{% if has_organization_feature %} +

    + You can add another user to help manage your domain. Users can only be a member of one .gov organization, + and they'll need to sign in with their Login.gov account.

    +{% else %} +

    + You can add another user to help manage your domain. If they aren't an organization member they will + need to sign in to the .gov registrar with their Login.gov account. +

    +{% endif %}
    {% csrf_token %} diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 412f4ee73..7125f9cb2 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,8 +8,7 @@

    Domain managers can update all information related to a domain within the - .gov registrar, including contact details, senior official, security - email, and DNS name servers. + .gov registrar, including including security email and DNS name servers.

      @@ -17,6 +16,7 @@
    • After adding a domain manager, an email invitation will be sent to that user with instructions on how to set up an account.
    • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
    • +
    • All domain managers will be notified when updates are made to this domain.
    • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.
    From 961a289e663769155efebfa98cc85020400f2dfb Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:22:16 -0700 Subject: [PATCH 158/175] Update domain manager page content --- src/registrar/templates/domain_add_user.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index 81b6678af..fa3f8e821 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -18,15 +18,15 @@ {% endblock breadcrumb %}

    Add a domain manager

    -{% if has_organization_feature %} +{% if has_organization_feature_flag %}

    You can add another user to help manage your domain. Users can only be a member of one .gov organization, and they'll need to sign in with their Login.gov account.

    {% else %}

    - You can add another user to help manage your domain. If they aren't an organization member they will - need to sign in to the .gov registrar with their Login.gov account. + You can add another user to help manage your domain. They will need to sign in to the .gov registrar with + their Login.gov account.

    {% endif %} From 2dd8d53ec9e7c7099701e33bdde5eb5ffb96b30c Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:50:43 -0700 Subject: [PATCH 159/175] Updated with review comments --- .github/pull_request_template.md | 9 +++++---- docs/dev-practices/code_review.md | 8 +++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 934c95ab8..e457d7a63 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -44,14 +44,14 @@ Resolves #00 - [ ] Update documentation in READMEs and/or onboarding guide #### Ensured code standards are met (Original Developer) - + - [ ] If any updated dependencies on Pipfile, also update dependencies in requirements.txt. - [ ] Interactions with external systems are wrapped in try/except - [ ] Error handling exists for unusual or missing values #### Validated user-facing changes (if applicable) -- [ ] Tag @dotgov-designers for design review. If code is not user-facing, delete design reviewer checklist +- [ ] Tag @dotgov-designers in this PR's Reviewers for design review. If code is not user-facing, delete design reviewer checklist - [ ] Verify new pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] Checked keyboard navigability - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) @@ -61,13 +61,13 @@ Resolves #00 #### Reviewed, tested, and left feedback about the changes - [ ] Pulled this branch locally and tested it -- [ ] Verified code meets above code standards and user-facing checklist. Address any checks that are not satisfied +- [ ] Verified code meets all checks above. Address any checks that are not satisfied - [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged - [ ] Checked that all code is adequately covered by tests - [ ] Verify migrations are valid and do not conflict with existing migrations #### Validated user-facing changes as a developer -**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist +**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist. All checks should be checked before approving, even those labeled N/A. - [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] Checked keyboard navigability @@ -81,6 +81,7 @@ Resolves #00 - [ ] Checked that the design translated visually - [ ] Checked behavior. Comment any found issues or broken flows. +- [ ] Checked keyboard navigability - [ ] Checked different states (empty, one, some, error) - [ ] Checked for landmarks, page heading structure, and links diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 4a27d71d6..5a8849754 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -12,12 +12,12 @@ After creating a pull request, pull request submitters should: Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer. All other changes require a single approving review. -The submitter is responsible for merging their PR unless the approver is given explcit permission. Similarly, do not commit to another person's branch unless given explicit permission. +The submitter is responsible for merging their PR unless the approver is given explicit permission. Similarly, do not commit to another person's branch unless given explicit permission. Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review. ## Pull Requests for User-facing changes -When making user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. +When making or reviewing user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari. Add new pages to the .pa11yci file so they are included in our automated accessibility testing. @@ -29,6 +29,4 @@ Add new pages to the .pa11yci file so they are included in our automated accessi ## Coding standards ### Plain language -All functions and methods should use plain language. - -TODO: Plain language description and examples in code standards ticket. +All functions and methods should use plain language. \ No newline at end of file From 928fe01a6c8995e9d78745d801c371c40c33e393 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 16 Oct 2024 12:12:16 -0400 Subject: [PATCH 160/175] fixes related to PR review comments --- src/registrar/assets/js/get-gov.js | 10 ++++++---- src/registrar/templates/portfolio_member.html | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 337baf11c..8281aa50a 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1926,6 +1926,8 @@ class MembersTable extends LoadTableBase { const memberList = document.querySelector('.members__table tbody'); memberList.innerHTML = ''; + const invited = 'Invited'; + data.members.forEach(member => { const member_name = member.name; const member_display = member.member_display; @@ -1937,7 +1939,7 @@ class MembersTable extends LoadTableBase { let last_active_sort_value = ''; // Handle 'Invited' or null/empty values differently from valid dates - if (last_active && last_active !== 'Invited') { + if (last_active && last_active !== invited) { try { // Try to parse the last_active as a valid date last_active = new Date(last_active); @@ -1953,9 +1955,9 @@ class MembersTable extends LoadTableBase { } } else { // Handle 'Invited' or null - last_active = 'Invited'; - last_active_formatted = 'Invited'; - last_active_sort_value = 'Invited'; // Keep 'Invited' as a sortable string + last_active = invited; + last_active_formatted = invited; + last_active_sort_value = invited; // Keep 'Invited' as a sortable string } const action_url = member.action_url; diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index 0275f84e9..f2ee8f4c5 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -119,9 +119,9 @@ {% if portfolio_permission %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% elif portfolio_invitation %} - {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation member_has_view_all_requests_portfolio_permission=member_has_view_all_requests_portfolio_permission member_has_edit_request_portfolio_permission=member_has_edit_request_portfolio_permission member_has_view_members_portfolio_permission=member_has_view_members_portfolio_permission member_has_edit_members_portfolio_permission=member_has_edit_members_portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %} + {% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %} {% endif %} {% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %} From 3cb341da595f15f471a2a2f5ecc486ae4b4de2f7 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:46:39 -0700 Subject: [PATCH 161/175] Add content updates --- src/registrar/templates/domain_users.html | 2 +- src/registrar/views/domain.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 7125f9cb2..c41902c6f 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -17,7 +17,7 @@ instructions on how to set up an account.
  • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
  • All domain managers will be notified when updates are made to this domain.
  • -
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.
  • +
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
  • {% if domain.permissions %} diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 3865bfc36..de156598a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -912,7 +912,7 @@ class DomainAddUserView(DomainFormBaseView): ) messages.error( self.request, - "That email is already a member of another .gov organization.", + f"{requested_email} is already a member of another .gov organization.", ) except Exception: logger.warn( From 7e39e8a0aec0a5b3a391c1655cec00e882de24d9 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Thu, 17 Oct 2024 09:34:00 -0500 Subject: [PATCH 162/175] dns sec form label change --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b0fc52cf1..665defc77 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -160,7 +160,7 @@ class DomainFormBaseView(DomainBaseView, FormMixin): # send notification email for changes to any of these forms form_label_dict = { DomainSecurityEmailForm: "Security email", - DomainDnssecForm: "DNSSec", + DomainDnssecForm: "DNSSEC / DS Data", DomainDsdataFormset: "DNSSEC / DS Data", DomainOrgNameAddressForm: "Organization details", SeniorOfficialContactForm: "Senior official", From 7885f389212a6635f78d9b74b59ec2a3ce00aa2f Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 17 Oct 2024 13:49:06 -0700 Subject: [PATCH 163/175] Remove code standards comment --- docs/dev-practices/code_review.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 5a8849754..7b054cad5 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -25,7 +25,6 @@ Add new pages to the .pa11yci file so they are included in our automated accessi - Keep pull requests as small as possible. This makes them easier to review and track changes. - Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation. -[comment]: The Coding standards section will be moved to a new code standards file in #2898. For now we're simply moving PR template content into the code review document for consolidation ## Coding standards ### Plain language From 6e943b7ecade76a39b9a53dafef50b13855804d4 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:17:45 -0700 Subject: [PATCH 164/175] Readd keyboard navigability check on design review --- .github/pull_request_template.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e457d7a63..b646f7817 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -81,12 +81,10 @@ Resolves #00 - [ ] Checked that the design translated visually - [ ] Checked behavior. Comment any found issues or broken flows. -- [ ] Checked keyboard navigability -- [ ] Checked different states (empty, one, some, error) -- [ ] Checked for landmarks, page heading structure, and links #### Validated user-facing changes as a designer +- [ ] Checked keyboard navigability - [ ] Checked different states (empty, one, some, error) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] Tested with multiple browsers (check off which ones were used) From 55841e3ad5de3f9c2dbe5ff073bb6fef9ec01f4d Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:21:31 -0700 Subject: [PATCH 165/175] Remove references section --- .github/pull_request_template.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b646f7817..40311bd5f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -94,9 +94,6 @@ Resolves #00 - [ ] Safari - [ ] (Rarely needed) Tested as both an analyst and applicant user -### References -- [Code review best practices](../docs/dev-practices/code_review.md) - ## Screenshots