From 9c090ae5fe640767da48cb297eadf56e640ef0bd Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 10 Sep 2024 15:37:04 -0500 Subject: [PATCH 001/116] 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 1b8eda9aba..a1ad0ecf78 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 003f8dd0de..1abdb10e5f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -441,6 +441,9 @@ def form_valid(self, formset): # 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 @@ def form_valid(self, formset): "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/116] 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 1fe1be5962..39bf9df5b8 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -10,6 +10,7 @@ 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 1abdb10e5f..1ac19ec448 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -57,7 +57,7 @@ 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 @@ def form_valid(self, formset): # 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/116] 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 0000000000..b3c7502576 --- /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 0000000000..e69de29bb2 diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 1ac19ec448..aba504c41a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -473,10 +473,9 @@ def form_valid(self, formset): # 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/116] 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 e69de29bb2..d3f6fbedb8 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 39bf9df5b8..4a53661cff 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 aba504c41a..603fbbab52 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -472,6 +472,7 @@ def form_valid(self, formset): # 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/116] 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 603fbbab52..97fa22d884 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -417,6 +417,8 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset): 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/116] 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 97fa22d884..6fbb0fe089 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -403,6 +403,9 @@ def post(self, request, *args, **kwargs): 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/116] 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 6fbb0fe089..1a87f185d2 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -421,7 +421,7 @@ def post(self, request, *args, **kwargs): 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/116] 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 1a87f185d2..cc68161213 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -404,7 +404,7 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset): 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 @@ def form_valid(self, formset): # 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/116] 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 4a53661cff..c1082f50d1 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -10,7 +10,6 @@ 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 cc68161213..6b3b6095f4 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -57,7 +57,7 @@ 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 @@ def get_domain_info_from_domain(self) -> DomainInformation | None: 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 @@ def get_success_url(self): 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 @@ def form_valid(self, form): # 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 @@ def form_valid(self, formset): 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 @@ def form_valid(self, formset): ) # 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/116] 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 153ee5f083..ac46f5d924 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 e699d9b75b..e798a0e8f5 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -60,6 +60,22 @@ def test_disable_email_flag(self): # 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 b096527f9d..a4a9ecf965 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2,6 +2,7 @@ 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 .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 @@ def test_ds_data_form_invalid_digest_sha256(self): 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 c1082f50d1..63d347cae9 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/116] 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 6b3b6095f4..1d693d6e62 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -526,6 +526,14 @@ def form_valid(self, formset): 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 @@ def form_valid(self, formset): "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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset, **kwargs): 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 @@ def form_valid(self, form): 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/116] 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 1d693d6e62..8973ec8ec9 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 @@ def form_valid(self, form): # 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 @@ def get_success_url(self): 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 @@ def form_valid(self, form): # 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 @@ def form_valid(self, formset): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset, **kwargs): 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 @@ def form_valid(self, form): 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/116] 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 ac46f5d924..153ee5f083 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 a1ad0ecf78..1b8eda9aba 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 b3c7502576..0000000000 --- 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 d3f6fbedb8..0000000000 --- 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 0000000000..93ab4819ff --- /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 0000000000..cf4c9a14cf --- /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 8973ec8ec9..895d580907 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -5,6 +5,7 @@ inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView). """ +from datetime import date import logging from django.contrib import messages @@ -146,6 +147,9 @@ def form_valid(self, 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/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/116] 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 63d347cae9..35d8a20294 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/116] 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 895d580907..bd5d60756e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -151,7 +151,7 @@ def form_valid(self, form): "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/116] 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 bd5d60756e..fc7be8fa7e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -143,6 +143,7 @@ def form_valid(self, form): # 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 @@ def form_valid(self, form): # 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/116] 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 1ab8af8161..5d91aadb35 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 @@ def form_valid(self, form): # 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 @@ def get_domain_info_from_domain(self) -> DomainInformation | None: 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 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st 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 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st 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 @@ def form_valid(self, form): """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 @@ def form_valid(self, form): # 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 @@ def form_valid(self, formset): 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 @@ def form_valid(self, formset): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset, **kwargs): 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 @@ def form_valid(self, form): 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/116] 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 03d9e38c64..1b20caf2af 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/116] 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 5d91aadb35..89a1338e6a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -202,7 +202,7 @@ def send_update_notification(self, form, is_formset: bool=False): 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/116] 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 89a1338e6a..47f72d0f8e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,39 +149,35 @@ def get_domain_info_from_domain(self) -> DomainInformation | None: 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 @@ def send_update_notification(self, form, is_formset: bool=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 + "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 @@ def form_valid(self, formset): 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 @@ def form_valid(self, formset, **kwargs): 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/116] 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 93ab4819ff..bc09508086 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 47f72d0f8e..04b740f6e9 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,11 +149,12 @@ def get_domain_info_from_domain(self) -> DomainInformation | None: 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 @@ def send_update_notification(self, form): } 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 @@ def send_update_notification(self, form): # 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 @@ def get_success_url(self): 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 @@ def post(self, request, *args, **kwargs): 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/116] uncomment test --- src/registrar/tests/test_views_domain.py | 75 ++++++++++++------------ 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index f0aea8588e..109b80913a 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1968,47 +1968,46 @@ def test_ds_data_form_invalid_digest_sha256(self): ) -# 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 +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() + 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 = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] -# self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) -# success_result_page = org_name_page.form.submit() + org_name_page.form["organization_name"] = "Not igorville" -# # Check that the page loads successfully -# self.assertEqual(success_result_page.status_code, 200) -# self.assertContains(success_result_page, "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 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/116] 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 109b80913a..43ea9bdd42 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1978,36 +1978,47 @@ def setUpClass(cls): 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/116] 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 412f4ee73d..1b789e590b 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,8 +8,7 @@

Domain managers

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

    Domain managers

  • 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 87e2d551ff..21ba06316a 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -71,7 +71,7 @@ def test_email_with_cc(self): "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 43ea9bdd42..d258dc4727 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -8,6 +8,7 @@ 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 @@ def setUp(self): 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 @@ def setUp(self): 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 @@ def setUp(self): 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 @@ def setUp(self): 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 @@ def setUpClass(cls): super().setUpClass() allowed_emails = [ AllowedEmail(email="info@example.com"), + AllowedEmail(email="doesnotexist@igorville.com"), ] AllowedEmail.objects.bulk_create(allowed_emails) @@ -1990,10 +2007,15 @@ def tearDownClass(cls): 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 @@ def test_notification_email_sent_on_org_name_change(self): 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 04b740f6e9..22ece989b1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -119,6 +119,7 @@ def post(self, request, *args, **kwargs): 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 @@ def send_update_notification(self, form, force_send=False): 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 @@ def send_update_notification(self, form, force_send=False): # 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 @@ def send_update_notification(self, form, force_send=False): "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 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, formset): 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/116] 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 1b20caf2af..c5d4fa95d3 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -478,7 +478,7 @@ def format(self, 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) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index 21ba06316a..abbbb274fd 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -60,20 +60,20 @@ def test_disable_email_flag(self): # 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 d258dc4727..6ca96f8c03 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2,17 +2,14 @@ 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 @@ def setUp(self): 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 @@ def setUpClass(cls): 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 @@ def tearDownClass(cls): @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 @@ def test_notification_on_org_name_change(self): 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 @@ def test_notification_on_org_name_change(self): @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 @@ def test_no_notification_on_org_name_change_with_portfolio(self): 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 @@ def test_no_notification_on_org_name_change_with_portfolio(self): 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 @@ def test_notification_on_security_email_change(self): 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 @@ def test_notification_on_security_email_change(self): @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 @@ def test_notification_on_dnssec_enable(self): 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 @@ def test_notification_on_dnssec_enable(self): @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 @@ def test_notification_on_ds_data_change(self): 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 @@ def test_notification_on_ds_data_change(self): @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 @@ def test_notification_on_senior_official_change(self): 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 @@ def test_notification_on_senior_official_change(self): 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 @@ def test_no_notification_on_senior_official_when_portfolio(self): 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 35d8a20294..655c432ac1 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 22ece989b1..a3f9d153ce 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -149,18 +149,18 @@ def get_domain_info_from_domain(self) -> DomainInformation | None: 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 @@ def send_update_notification(self, form, force_send=False): 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 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st 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 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): 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 @@ def form_valid(self, form): 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/116] 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 655c432ac1..fc7d4f956b 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/116] 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 abbbb274fd..c3a84d22fe 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -66,10 +66,10 @@ 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", + "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/116] 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 161d85ae5f..fdba309f32 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -781,7 +781,7 @@ def _send_status_update_email( 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 @@ def submit(self): # 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 @@ def submit(self): # 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 c3a84d22fe..3b1b45e981 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -71,7 +71,7 @@ def test_email_with_cc(self): "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 @@ def test_email_with_cc(self): @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 @@ def test_submission_confirmation(self): @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 @@ def test_submission_confirmation_no_current_website_spacing(self): @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 @@ def test_submission_confirmation_other_contacts_spacing(self): # 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 @@ def test_submission_confirmation_other_contacts_spacing(self): @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 @@ def test_submission_confirmation_no_other_contacts_spacing(self): @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 @@ def test_submission_confirmation_alternative_govdomain_spacing(self): @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 @@ def test_submission_confirmation_no_alternative_govdomain_spacing(self): @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 @@ def test_submission_confirmation_about_your_organization_spacing(self): @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 @@ def test_submission_confirmation_no_about_your_organization_spacing(self): @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 @@ def test_submission_confirmation_anything_else_spacing(self): @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 a6e8895038..8c9a888c27 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -268,7 +268,7 @@ def test_submit_from_started_sends_email_to_creator(self): @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 @@ def test_submit_from_in_review_does_not_send_email(self): @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 @@ def test_withdraw_sends_email(self): @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 6ca96f8c03..7e9f5b9b80 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -250,6 +250,7 @@ def test_unknown_domain_does_not_show_as_expired_on_detail_page(self): # 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 @@ def tearDownClass(cls): 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 fc7d4f956b..412838d10a 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 a3f9d153ce..9f66622910 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -413,7 +413,7 @@ def form_valid(self, form): # 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/116] 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 fdba309f32..08a4319671 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -828,7 +828,6 @@ def submit(self): 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 @@ def submit(self): # 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 7e9f5b9b80..939cdaaf9c 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2003,7 +2003,6 @@ def tearDownClass(cls): 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/116] 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 db0572bb30..dae3b60cd9 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -21,8 +21,10 @@ DomainRequest, DomainInformation, DomainInvitation, + PortfolioInvitation, User, UserDomainRole, + UserPortfolioPermission, PublicContact, ) from registrar.utility.enums import DefaultEmail @@ -38,6 +40,7 @@ ) 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 @@ def _domain_abs_url(self): """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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, add_success ) 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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/116] 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 b2f9fef24d..320404fa98 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/116] 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 320404fa98..e95bacd76f 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 dae3b60cd9..c3bbe037ab 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -823,15 +823,15 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def form_valid(self, form): 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/116] 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 08a4319671..2aecb49f79 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -781,7 +781,6 @@ def _send_status_update_email( 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 @@ def submit(self): # 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/116] 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 8cb83c0ee4..6a75091a69 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 c3bbe037ab..92da57e067 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -35,6 +35,7 @@ NameserverErrorCodes as nsErrorCodes, DsDataError, DsDataErrorCodes, + OutsideOrgMemberError, SecurityEmailError, SecurityEmailErrorCodes, ) @@ -781,12 +782,15 @@ def _domain_abs_url(self): """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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u "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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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/116] 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 6a75091a69..f12aba2213 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 92da57e067..77c02d990e 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -35,9 +35,9 @@ 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,30 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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( + "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", @@ -867,6 +891,9 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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.""" @@ -896,14 +923,6 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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/116] 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 77c02d990e..02019c6014 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -930,6 +930,12 @@ def form_valid(self, form): 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/116] 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 02019c6014..c2ca65bab7 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -782,14 +782,15 @@ def _domain_abs_url(self): """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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u "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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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/116] 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 c2ca65bab7..5d7a840c78 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -857,8 +857,6 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u "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 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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/116] 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 412838d10a..ecae7ed93d 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/116] 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 3b1b45e981..e76a6124fb 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -77,6 +77,37 @@ def test_email_with_cc(self): # 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 939cdaaf9c..15e21169e9 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -87,12 +87,6 @@ def setUp(self): 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 @@ def tearDownClass(cls): @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 412838d10a..6c5f5f1726 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/116] 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 9f66622910..88da7320a1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -226,7 +226,6 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st "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/116] 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 9f66622910..206509e071 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -204,7 +204,7 @@ def send_update_notification(self, form, force_send=False): 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 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st 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 @@ def email_domain_managers(self, domain_name, template: str, subject_template: st logger.warning( "Could not sent notification email to %s for domain %s", emails, - domain_name, + domain.name, exc_info=True, ) 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 043/116] 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 dec0b9fac8..4f2349204f 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 56d4db3948..38ed832323 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 045/116] 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 e493e0a921..e2340bebeb 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 046/116] 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 e2340bebeb..351ce579bd 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 047/116] 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 351ce579bd..4d3b767462 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 048/116] 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 4d3b767462..a3646c40a2 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 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 049/116] 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 38ed832323..aa3d134048 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 050/116] 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 a3646c40a2..ecf117f15a 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 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 051/116] 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 ecf117f15a..c4e63bccdc 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 aa3d134048..f30eec64e0 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 052/116] 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 c4e63bccdc..20571b3050 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 f30eec64e0..09e6e0c1cc 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 053/116] 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 ab15c660fb..5e1c01be9b 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 09e6e0c1cc..1cea4aa048 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 96d9fcd25205beaeb7b727b3a97e70ea3d23a0d6 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:44:02 -0500 Subject: [PATCH 054/116] 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 1b789e590b..a2eb3e6045 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,7 +8,7 @@

Domain managers

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.

    From 4c3d29399d456879335db27644ffe4fdaa64d0de Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:44:26 -0500 Subject: [PATCH 055/116] Update src/registrar/templates/emails/update_to_approved_domain.txt Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/emails/update_to_approved_domain.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index bc09508086..338b7d811c 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -20,6 +20,7 @@ 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 From aeebe8c997d6205da029e523ae4133edd6257925 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:44:42 -0500 Subject: [PATCH 056/116] Update src/registrar/templates/emails/update_to_approved_domain.txt Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/emails/update_to_approved_domain.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 338b7d811c..3ff1e05f26 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -12,6 +12,7 @@ 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}}, 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. From 35ea584a8f3a3b78ff1d3d815181c2f34207acf7 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:44:54 -0500 Subject: [PATCH 057/116] Update src/registrar/templates/emails/update_to_approved_domain.txt Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/emails/update_to_approved_domain.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 3ff1e05f26..7af71875b1 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -8,7 +8,6 @@ UPDATED ON: {{date}} INFORMATION UPDATED: {{changes}} You can view this update in the .gov registrar . - Get help with managing your .gov domain . ---------------------------------------------------------------- From 83051ae6237f0e9e7081806dff63bfeda3301e18 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:45:02 -0500 Subject: [PATCH 058/116] Update src/registrar/templates/emails/update_to_approved_domain.txt Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/emails/update_to_approved_domain.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 7af71875b1..6e5e9c521f 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -6,6 +6,7 @@ DOMAIN: {{domain}} 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 . From 07d640dddca44a8e80ec1eef2ced43561f55241d Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Fri, 4 Oct 2024 16:45:09 -0500 Subject: [PATCH 059/116] Update src/registrar/templates/emails/update_to_approved_domain.txt Co-authored-by: Katherine-Osos <119689946+Katherine-Osos@users.noreply.github.com> --- src/registrar/templates/emails/update_to_approved_domain.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/templates/emails/update_to_approved_domain.txt b/src/registrar/templates/emails/update_to_approved_domain.txt index 6e5e9c521f..8e615c30cb 100644 --- a/src/registrar/templates/emails/update_to_approved_domain.txt +++ b/src/registrar/templates/emails/update_to_approved_domain.txt @@ -2,6 +2,7 @@ Hi, An update was made to a domain you manage. + DOMAIN: {{domain}} UPDATED BY: {{user}} UPDATED ON: {{date}} From 7646c91691af58231804499bd16ffe087772e8c3 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 7 Oct 2024 12:16:21 -0500 Subject: [PATCH 060/116] design review changes --- src/registrar/tests/test_views_domain.py | 12 ++++----- src/registrar/views/domain.py | 33 +++++++++++++++++------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 15e21169e9..3aea6d9e1e 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2035,7 +2035,7 @@ def test_notification_on_org_name_change(self): self.assertIn("DOMAIN: igorville.gov", body) self.assertIn("UPDATED BY: First Last info@example.com", body) - self.assertIn("INFORMATION UPDATED: Org Name/Address", body) + self.assertIn("INFORMATION UPDATED: Organization details", body) @boto3_mocking.patching @less_console_noise_decorator @@ -2085,7 +2085,7 @@ def test_notification_on_security_email_change(self): self.assertIn("DOMAIN: igorville.gov", body) self.assertIn("UPDATED BY: First Last info@example.com", body) - self.assertIn("INFORMATION UPDATED: Security Email", body) + self.assertIn("INFORMATION UPDATED: Security email", body) @boto3_mocking.patching @less_console_noise_decorator @@ -2147,7 +2147,7 @@ def test_notification_on_ds_data_change(self): self.assertIn("DOMAIN: igorville.gov", body) self.assertIn("UPDATED BY: First Last info@example.com", body) - self.assertIn("INFORMATION UPDATED: DS Data", body) + self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body) @boto3_mocking.patching @less_console_noise_decorator @@ -2178,7 +2178,7 @@ def test_notification_on_senior_official_change(self): self.assertIn("DOMAIN: igorville.gov", body) self.assertIn("UPDATED BY: First Last info@example.com", body) - self.assertIn("INFORMATION UPDATED: Senior Official", body) + self.assertIn("INFORMATION UPDATED: Senior official", body) @boto3_mocking.patching @less_console_noise_decorator @@ -2219,9 +2219,9 @@ def test_no_notification_when_dns_needed(self): 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-server"] = "ns1-new.dns-needed.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-server"] = "ns2-new.dns-needed.gov" nameservers_page.form["form-1-ip"] = "192.168.1.2" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 206509e071..7bd5834c0a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -119,7 +119,6 @@ def post(self, request, *args, **kwargs): 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): @@ -160,12 +159,12 @@ def send_update_notification(self, form, force_send=False): # 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", - SeniorOfficialContactForm: "Senior Official", - NameserverFormset: "Nameservers", + DomainDsdataFormset: "DNSSEC / DS Data", + DomainOrgNameAddressForm: "Organization details", + SeniorOfficialContactForm: "Senior official", + NameserverFormset: "Name servers", } # forms of these types should not send notifications if they're part of a portfolio/Organization @@ -182,6 +181,9 @@ def send_update_notification(self, form, force_send=False): info = self.get_domain_info_from_domain() if not info or info.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 @@ -220,9 +222,16 @@ def email_domain_managers(self, domain: Domain, template: str, subject_template: "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) + # Remove the current user so they aren't CC'ed, since they will be the "to_address" + emails.remove(self.request.user.email) + except ValueError: + pass + + try: + send_templated_email( + template, subject_template, to_address=self.request.user.email, context=context, cc_addresses=emails + ) except EmailSendingError: logger.warning( "Could not sent notification email to %s for domain %s", @@ -491,19 +500,25 @@ def post(self, request, *args, **kwargs): self._get_domain(request) formset = self.get_form() + logger.debug("got formet") + if "btn-cancel-click" in request.POST: url = self.get_success_url() return HttpResponseRedirect(url) if formset.is_valid(): + logger.debug("formset is valid") return self.form_valid(formset) else: + logger.debug("formset is invalid") + logger.debug(formset.errors) 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 + initial_state = self.object.state # Set the nameservers from the formset nameservers = [] @@ -544,7 +559,7 @@ def form_valid(self, formset): messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) logger.error(f"Registry error: {Err}") else: - if self.object.state == Domain.State.READY: + if initial_state == Domain.State.READY: self.send_update_notification(formset) messages.success( self.request, From 491ce93b14fea7cc8cbdd2e8a9aa56fa0273ccc6 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 7 Oct 2024 12:35:19 -0500 Subject: [PATCH 061/116] linter issues --- src/registrar/config/settings.py | 2 +- src/registrar/views/domain.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 523b802a6c..da58eee860 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -479,7 +479,7 @@ def format(self, 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) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 7bd5834c0a..5180fe5155 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -224,13 +224,17 @@ def email_domain_managers(self, domain: Domain, template: str, subject_template: emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True)) try: # Remove the current user so they aren't CC'ed, since they will be the "to_address" - emails.remove(self.request.user.email) + emails.remove(self.request.user.email) # type: ignore except ValueError: pass try: send_templated_email( - template, subject_template, to_address=self.request.user.email, context=context, cc_addresses=emails + template, + subject_template, + to_address=self.request.user.email, # type: ignore + context=context, + cc_addresses=emails, ) except EmailSendingError: logger.warning( From ec74cfb24d316a152e18343ac309942283407d5d Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:30:31 -0700 Subject: [PATCH 062/116] Incorporate feedback --- .github/pull_request_template.md | 13 +++++++++---- docs/dev-practices/code_review.md | 1 - 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 20571b3050..09f9661487 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Ticket -Resolves #001 +Resolves #00 ## Changes @@ -44,12 +44,14 @@ Resolves #001 #### 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) -- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing +- [ ] Tag @dotgov-designers 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) @@ -58,10 +60,10 @@ Resolves #001 #### Reviewed, tested, and left feedback about the changes - [ ] Pulled this branch locally and tested it -- [ ] Verified code meets above code standards and user-facing checks. Addresses any checks that are not satisfied +- [ ] Verified code meets above code standards and user-facing checklist. 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 -- [ ] If any model was updated to modify/add/delete columns, verified migrations can be run with `makemigrations` +- [ ] 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 @@ -92,6 +94,9 @@ Resolves #001 - [ ] Safari - [ ] (Rarely needed) Tested as both an analyst and applicant user +### References +- [Code review best practices](../docs/dev-practices/code_review.md) + ## Screenshots Resolves #00 ## Changes From 61e7b13df69480509f3d6c4af5b1670a5607ffdc Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 7 Oct 2024 17:41:16 -0600 Subject: [PATCH 065/116] 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 241afd3281..968ab5de60 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,6 +5,11 @@ 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 @@ def _get_portfolio_permissions(self): 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 066/116] 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 968ab5de60..2e4c8b704f 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,12 +5,6 @@ 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 @@ def clean(self): # 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 067/116] 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 2e4c8b704f..1ea54acc7b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -97,8 +97,15 @@ def _get_portfolio_permissions(self): 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 @@ def clean(self): "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 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 068/116] 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 3c64eb6b59..6b6e137cd4 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 @@ def load(cls): 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 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 069/116] 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 5d7a840c78..1bc537891b 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,17 +917,16 @@ def form_valid(self, form): 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 070/116] 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 1bc537891b..a30db761ce 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,16 +917,16 @@ def form_valid(self, form): 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 071/116] 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 a30db761ce..86a22be0f5 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -917,6 +917,7 @@ def form_valid(self, form): exc_info=True, ) messages.warning(self.request, "Could not send email invitation.") + try: UserDomainRole.objects.create( user=requested_user, From 23e99c054d79f6d323d55868bf78c6e54e2524f4 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 8 Oct 2024 15:53:04 -0600 Subject: [PATCH 072/116] 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 1ea54acc7b..74ef9901e1 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,6 +5,13 @@ 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 @@ def clean(self): 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 015c67dab2..681d04c925 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -41,7 +41,6 @@ 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 @@ def test_clean_on_multiple_portfolios_when_flag_active(self): 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 @@ def test_clean_on_creates_multiple_portfolios(self): 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 073/116] 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 74ef9901e1..825e82c88c 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -5,12 +5,6 @@ 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 @@ def clean(self): # 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 074/116] 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 86a22be0f5..a2d287b696 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -823,10 +823,6 @@ def _send_domain_invitation_email(self, email: str, requestor: User, requested_u 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 @@ def _make_invitation(self, email_address: str, requestor: User): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 075/116] 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 a2d287b696..c857e7dd5f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -906,6 +906,9 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 076/116] 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 c857e7dd5f..433ae12303 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -876,39 +876,26 @@ def _make_invitation(self, email_address: str, requestor: User): 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 @@ def form_valid(self, form): 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 077/116] 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 433ae12303..332af5978d 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -785,7 +785,7 @@ def _domain_abs_url(self): 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 078/116] 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 f2fdd32bc6..29e9fa6398 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 834298f245..8e0aa39388 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 0302bc71f8..073b82457b 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -21,7 +21,9 @@

    Senior official

    {% 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 079/116] Revert "resolved merge conflict" This reverts commit 85be976ee483e1364e763add30fea4e53e2d4617, reversing changes made to bd018e14ab5ab64141e4b7cad1dfe97f9694b0bf. --- src/registrar/admin.py | 32 +- 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, 130 insertions(+), 1193 deletions(-) delete mode 100644 src/registrar/tests/test_models_requests.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 485a1b07df..ca51e8b72e 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1976,30 +1976,18 @@ def _handle_status_change(self, request, obj, original_obj): # 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) - - 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. + return 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. 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 f44211c6d3..73f3dded16 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 b6bc0d2965..5cea72c4c0 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 66011a3c43..198140c19d 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 d6a016fd5a..5ad2b27f78 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@
    {{ 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 84fb07f334..0a28a65329 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 2369f235bb..6b755724e6 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 @@

    - + @@ -267,31 +267,18 @@

    {% endfor %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 9c5e3b5825..cdc3c97dee 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -654,7 +654,7 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - 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 f02b59a91f..a9b94781fe 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -535,7 +535,7 @@ def test_contact_fields_on_domain_change_form_have_detail_table(self): 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 f19008ca18..a9b0734721 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1511,7 +1511,7 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - 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 @@ def test_side_effects_when_saving_approved_to_rejected(self): 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 f1d25ece9a..681d04c925 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 @@ 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 9e86f5f9c9..0000000000 --- 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 ff2e61939d..8530859e2d 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -82,6 +82,7 @@ def test_get_first_status_started_date(self, mock_get_first_status_set_date): 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 080/116] Reapply "resolved merge conflict" This reverts commit d1f3f0efb14aa78e1b3de175888d6885e0c84f9c. --- src/registrar/admin.py | 32 +- 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, 1193 insertions(+), 130 deletions(-) create mode 100644 src/registrar/tests/test_models_requests.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ca51e8b72e..485a1b07df 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1976,18 +1976,30 @@ def _handle_status_change(self, request, obj, original_obj): # 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 - - 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. + return (obj, 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. 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 73f3dded16..f44211c6d3 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 5cea72c4c0..b6bc0d2965 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 198140c19d..66011a3c43 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 5ad2b27f78..d6a016fd5a 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@
    {{ field }}
    {% else %} -
    Other contact informationOther contact information
    {{ contact.phone }} - {% if contact.email %} - - - {% endif %} + + + Copy email +
    - + @@ -267,18 +267,31 @@

    {% endfor %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index cdc3c97dee..9c5e3b5825 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -654,7 +654,7 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - 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 a9b94781fe..f02b59a91f 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -535,7 +535,7 @@ def test_contact_fields_on_domain_change_form_have_detail_table(self): 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 a9b0734721..f19008ca18 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1511,7 +1511,7 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - 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 @@ def test_side_effects_when_saving_approved_to_rejected(self): 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 681d04c925..f1d25ece9a 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 @@ 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 0000000000..9e86f5f9c9 --- /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 8530859e2d..ff2e61939d 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -82,7 +82,6 @@ def test_get_first_status_started_date(self, mock_get_first_status_set_date): 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 081/116] 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 f1d25ece9a..4101cecd34 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -34,1023 +34,6 @@ 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 082/116] 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 825e82c88c..ed233dfea4 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -110,8 +110,14 @@ def clean(self): # 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 4101cecd34..d269dc9126 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -34,6 +34,7 @@ from api.tests.common import less_console_noise_decorator + class TestDomainInformation(TestCase): """Test the DomainInformation model, when approved or otherwise""" @@ -282,7 +283,7 @@ def test_clean_on_multiple_portfolios_when_flag_active(self): 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 @@ def test_clean_on_creates_multiple_portfolios(self): "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 @@ def test_multiple_portfolio_reassignment(self): # 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 083/116] 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 332af5978d..95fa784177 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -881,13 +881,12 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): ) 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 084/116] 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 8a07b3f276..0bf62565d3 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 29e9fa6398..c84ddee78d 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -229,7 +229,8 @@ def from_database(cls, obj): 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 073b82457b..0302bc71f8 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -21,9 +21,7 @@

    Senior official

    {% 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 085/116] 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 95fa784177..332af5978d 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -881,12 +881,13 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): 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 @@ def form_valid(self, form): ) 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 086/116] 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 332af5978d..3865bfc366 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -903,6 +903,7 @@ def form_valid(self, form): 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 087/116] 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 14d504784f..559df5d60f 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -2064,6 +2064,39 @@ def test_no_notification_on_org_name_change_with_portfolio(self): # 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 5180fe5155..04eab13831 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -173,22 +173,21 @@ def send_update_notification(self, form, force_send=False): 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 fe59329f8b2a951c40d48bc6df8470e2235f9ffa Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 10 Oct 2024 16:05:00 -0400 Subject: [PATCH 088/116] 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 ca51e8b72e..e21f7581bf 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3208,12 +3208,17 @@ def change_view(self, request, object_id, form_url="", extra_context=None): 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 73f3dded16..0de2c7c813 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 46303efced..9988e62e11 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 56621b7693..f81bc080cf 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 f1ba7e9209e1fa5d0a296a740b1bab2e59d43fab Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 11 Oct 2024 00:06:31 -0600 Subject: [PATCH 089/116] 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 c84ddee78d..d76d7ba78d 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 @@ def from_database(cls, obj): 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 84c30f69eee8ea63d567563bf591fc62bd8cdb34 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 11 Oct 2024 13:36:15 -0500 Subject: [PATCH 090/116] 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 8e615c30cb..99f86ea540 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 091/116] 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 04eab13831..e54ec9c8fe 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -174,16 +174,20 @@ def send_update_notification(self, form, force_send=False): } 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 @@ def send_update_notification(self, form, force_send=False): ) 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 9d1cdbc41b523472697552ce9cfedfc175de7f3d Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Fri, 11 Oct 2024 16:53:22 -0500 Subject: [PATCH 092/116] 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 e54ec9c8fe..1e90780ca0 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -174,6 +174,17 @@ def send_update_notification(self, form, force_send=False): } 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 d2e484c144870820d64b2d3e90926c2773dcb34e Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 03:21:44 -0400 Subject: [PATCH 093/116] 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 5cea72c4c0..a89180c585 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 9988e62e11..bfeb0b1a24 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 %} -
    NameState + Name + + State +
    +
    - - - + + - + {% 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 f81bc080cf..caf2e202de 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 da375586eda2b608b90e6a401ad024e4ac0b388c Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 10:02:52 -0400 Subject: [PATCH 094/116] 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 e21f7581bf..f04a161dee 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3208,10 +3208,10 @@ def change_view(self, request, object_id, form_url="", extra_context=None): 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 095/116] 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 1e90780ca0..b0fc52cf15 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -175,16 +175,6 @@ def send_update_notification(self, form, force_send=False): 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 1c073a017b3ac491c6883cfc76f23df410ffb804 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 15 Oct 2024 17:12:23 -0400 Subject: [PATCH 096/116] 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 48229a61a3..d537235a92 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3220,11 +3220,6 @@ def change_view(self, request, object_id, form_url="", extra_context=None): 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 29e1b42b24..da6291ef75 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 097/116] 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 d537235a92..33765b1781 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3225,7 +3225,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None): 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 da6291ef75..fd50fbb0c2 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 098/116] Pipfile and requirements.txt updates --- src/Pipfile.lock | 2013 ++++++++++++++++++++---------------------- src/requirements.txt | 77 +- 2 files changed, 1013 insertions(+), 1077 deletions(-) diff --git a/src/Pipfile.lock b/src/Pipfile.lock index a42563c63d..33b8583143 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" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "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.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" - ], - "version": "==6.0.1" + "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" + ], + "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 3f71584490..52c601b555 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 099/116] 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 e95bacd76f..81b6678afe 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 412f4ee73d..7125f9cb2a 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -8,8 +8,7 @@

    Domain managers

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

      Domain managers

    • 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 100/116] 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 81b6678afe..fa3f8e8214 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 101/116] 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 934c95ab84..e457d7a63c 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 4a27d71d64..5a8849754e 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 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 102/116] 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 7125f9cb2a..c41902c6f4 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -17,7 +17,7 @@

    Domain managers

    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 3865bfc366..de156598a1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -912,7 +912,7 @@ def form_valid(self, form): ) 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 103/116] 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 b0fc52cf15..665defc777 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -160,7 +160,7 @@ def send_update_notification(self, form, force_send=False): # 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 104/116] 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 5a8849754e..7b054cad5e 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 105/116] 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 e457d7a63c..b646f78176 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 106/116] 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 b646f78176..40311bd5ff 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
    - Name - - State - NameState
    {{ domain }} {{ domain }}{{ domain.get_state_display }} {{ domain.get_state_display }} None None