From f918720383561bcad51f6ad6a66a3fe8245fa885 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:57:48 -0800 Subject: [PATCH 001/231] Add screenreader text to no other contacts form --- src/registrar/forms/domain_request_wizard.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c7f5571af..a619aec26 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -733,7 +733,14 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm): required=True, # label has to end in a space to get the label_suffix to show label=("No other employees rationale"), - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "You don’t need to provide names of other employees now, \ + but it may slow down our assessment of your eligibility. Describe \ + why there are no other employees who can help verify your request. \ + You can enter up to 1000 characters." + } + ), validators=[ MaxLengthValidator( 1000, From e76571eb50d4eead1429aa936ff96b48259172d2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:05:08 -0800 Subject: [PATCH 002/231] Remove unused legends from radio groups --- src/registrar/forms/domain_request_wizard.py | 1 + .../forms/utility/wizard_form_helper.py | 10 +++++- .../templates/django/forms/label.html | 36 +++++++++++-------- .../templates/includes/input_with_errors.html | 6 +++- src/registrar/templatetags/field_helpers.py | 1 + 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index a619aec26..5d8f23057 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -544,6 +544,7 @@ class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" form_choices = ((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")) + title_label = "Are there other employees who can help verify your request?" field_name = "has_other_contacts" @property diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index eedf5839b..22c70b010 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -239,6 +239,10 @@ class BaseYesNoForm(RegistrarForm): # Default form choice mapping. Default is suitable for most cases. form_choices = ((True, "Yes"), (False, "No")) + # Option to append question to aria label for screenreader accessibility. + # Not added by default. + aria_label = "" + def __init__(self, *args, **kwargs): """Extend the initialization of the form from RegistrarForm __init__""" super().__init__(*args, **kwargs) @@ -256,7 +260,11 @@ class BaseYesNoForm(RegistrarForm): coerce=lambda x: x.lower() == "true" if x is not None else None, choices=self.form_choices, initial=self.get_initial_value(), - widget=forms.RadioSelect, + widget=forms.RadioSelect( + attrs={ + "aria-label": self.aria_label + } + ), error_messages={ "required": self.required_error_message, }, diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 545ccf781..e4d7842fa 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,19 +1,25 @@ -<{{ label_tag }} - class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" - {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} -> - {% if span_for_text %} - {{ field.label }} - {% else %} - {{ field.label }} - {% endif %} + +{% if not type == "radio" and not label_tag == "legend" %} + <{{ label_tag }} + class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" + {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} + > +{% endif %} - {% if widget.attrs.required %} - - {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + {% if span_for_text %} + {{ field.label }} {% else %} - * + {{ field.label }} {% endif %} - {% endif %} - + {% if widget.attrs.required %} + + {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + {% else %} + * + {% endif %} + {% endif %} + +{% if not type == "radio" and not label_tag == "legend" %} + +{% endif %} diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index d1e53968e..d239954a0 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -68,7 +68,11 @@ error messages, if necessary. {% endif %} {# this is the input field, itself #} - {% include widget.template_name %} + {% with aria_label=aria_label %} + {% include widget.template_name %} + {% endwith %} + + {% if append_gov %} .gov diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 8a80a75b9..d2bca13fb 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -169,5 +169,6 @@ def input_with_errors(context, field=None): # noqa: C901 ) # -> {"widget": {"name": ...}} context["widget"] = widget["widget"] + print("context: ", context) return context From 36b78e9cea8207cf14b64c11271a3bbe6033464a Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:43:31 -0800 Subject: [PATCH 003/231] Refactor legends from two separate legends to one combined legend --- .../forms/utility/wizard_form_helper.py | 5 +++-- .../templates/django/forms/label.html | 21 +++++++++---------- .../domain_request_other_contacts.html | 11 +++------- src/registrar/templatetags/field_helpers.py | 6 ++++++ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 22c70b010..d48f7af64 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -241,7 +241,8 @@ class BaseYesNoForm(RegistrarForm): # Option to append question to aria label for screenreader accessibility. # Not added by default. - aria_label = "" + title_label = "" + aria_label = title_label.join("") def __init__(self, *args, **kwargs): """Extend the initialization of the form from RegistrarForm __init__""" @@ -262,7 +263,7 @@ class BaseYesNoForm(RegistrarForm): initial=self.get_initial_value(), widget=forms.RadioSelect( attrs={ - "aria-label": self.aria_label + # "aria-label": self.title_label } ), error_messages={ diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index e4d7842fa..eb9604dec 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,25 +1,24 @@ -{% if not type == "radio" and not label_tag == "legend" %} - <{{ label_tag }} - class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" - {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} - > -{% endif %} - +<{{ label_tag }} + class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" + {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} +> + {% if legend_label %} +

{{ legend_label }}

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

Are there other employees who can help verify your request?

-
- - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} +
+ {% with add_class="usa-radio__input--tile" add_legend_label="Are there other employees who can help verify your request?" %} {% input_with_errors forms.0.has_other_contacts %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #} - -
+
{% include "includes/required_fields.html" %} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index d2bca13fb..31ceb1072 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -57,6 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901 legend_classes = [] group_classes = [] aria_labels = [] + legend_labels = [] # this will be converted to an attribute string described_by = [] @@ -90,6 +91,8 @@ def input_with_errors(context, field=None): # noqa: C901 label_classes.append(value) elif key == "add_legend_class": legend_classes.append(value) + elif key == "add_legend_label": + legend_labels.append(value) elif key == "add_group_class": group_classes.append(value) @@ -149,6 +152,9 @@ def input_with_errors(context, field=None): # noqa: C901 if legend_classes: context["legend_classes"] = " ".join(legend_classes) + if legend_labels: + context["legend_label"] = " ".join(legend_labels) + if group_classes: context["group_classes"] = " ".join(group_classes) From 3f66face606cf13c916490b1d5bc1e6fdbdded12 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:43:54 -0800 Subject: [PATCH 004/231] Remove outdated comment --- src/registrar/templates/django/forms/label.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index eb9604dec..27407247b 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -1,4 +1,3 @@ - <{{ label_tag }} class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} From 394a04d47d173978b03a3ab2ce569dc55681f118 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:28:39 -0800 Subject: [PATCH 005/231] Save changes --- src/registrar/templates/django/forms/label.html | 3 +++ .../templates/domain_request_additional_details.html | 9 ++++++--- src/registrar/templatetags/field_helpers.py | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 27407247b..3fbb6b7af 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -4,6 +4,9 @@ > {% if legend_label %}

{{ legend_label }}

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

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

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

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

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

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

--> -

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

- - Select one. * {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} From ab05cb7e4674e261de8154ae63a958b26fb91638 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:16:20 -0800 Subject: [PATCH 007/231] Add select text to radios --- src/registrar/assets/sass/_theme/_typography.scss | 3 +++ src/registrar/templates/domain_request_additional_details.html | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index d815ef6dd..3b3958b9b 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -27,6 +27,9 @@ h2 { .usa-form, .usa-form fieldset { font-size: 1rem; + legend em { + font-size: 1rem; + } } .p--blockquote { diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index d09ec6966..74a024c9b 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -16,7 +16,6 @@ --> - Select one. * {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} From 1beb0947f7273ce200adc62b9851c0fef60feefb Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:26:29 -0800 Subject: [PATCH 008/231] Refactor additional details radio --- src/registrar/assets/sass/_theme/_typography.scss | 1 + .../templates/domain_request_additional_details.html | 12 +----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index 3b3958b9b..c60b7d802 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -29,6 +29,7 @@ h2 { font-size: 1rem; legend em { font-size: 1rem; + margin-bottom: 0.5rem; } } diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 74a024c9b..386a7a4af 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -10,11 +10,6 @@ {% block form_fields %}
- - {% with add_class="usa-radio__input--tile" add_legend_label="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} @@ -30,13 +25,8 @@
- -

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

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

    {{ legend_label }}

    +

    {{ legend_label }}

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

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

    {% endif %} From b9ea3d884665f863f18cc3852af6f1de6b97a469 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:31:58 -0800 Subject: [PATCH 011/231] Save changes --- src/registrar/forms/domain_request_wizard.py | 7 +------ .../templates/domain_request_additional_details.html | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5ce50dc0c..5d8f23057 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -789,12 +789,7 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea( - attrs={ - "aria-label": "Is there anything else you’d like us to know about your domain request? Provide details below. \ - You can enter up to 2000 characters" - } - ), + widget=forms.Textarea(), validators=[ MaxLengthValidator( 2000, diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 386a7a4af..6e28e5869 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -34,7 +34,7 @@

    Provide details below. *

    - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_label="Is there anything else you’d like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} From 6ff3901c91f021e123feba9ef04328c35988e3b1 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:43:41 -0800 Subject: [PATCH 012/231] Add aria label to anything else text field --- src/registrar/forms/domain_request_wizard.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 5d8f23057..ca1313184 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -789,7 +789,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "Is there anything else you’d like us to know about your domain request? \ + Provide details below. You can enter up to 2000 characters" + } + ), validators=[ MaxLengthValidator( 2000, From 4671c8967fcbf1d3ae98c8b53e2425300abe9f95 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:10:57 -0800 Subject: [PATCH 013/231] Refactor legend heading --- src/registrar/assets/sass/_theme/_base.scss | 8 ++++++++ src/registrar/assets/sass/_theme/_typography.scss | 3 +-- src/registrar/templates/django/forms/label.html | 4 ++-- .../templates/domain_request_additional_details.html | 8 ++++---- .../templates/domain_request_other_contacts.html | 2 +- src/registrar/templatetags/field_helpers.py | 10 +++++----- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index db1599621..9a6818f09 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -149,6 +149,14 @@ footer { color: color('primary'); } +.usa-footer { + margin-bottom: 0.5rem; +} + +.usa-radio { + margin-top: 1rem; +} + abbr[title] { // workaround for underlining abbr element border-bottom: none; diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index c60b7d802..952fc6dad 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -27,9 +27,8 @@ h2 { .usa-form, .usa-form fieldset { font-size: 1rem; - legend em { + .usa-legend { font-size: 1rem; - margin-bottom: 0.5rem; } } diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 2852ce2ba..3783c0fef 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -2,8 +2,8 @@ class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} > - {% if legend_label %} -

    {{ legend_label }}

    + {% if legend_heading %} +

    {{ legend_heading }}

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

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

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

    Provide details below. *

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

    Member access and permissions

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

    Member email

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

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

    + +
    + + +
    + +

    Member Access

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

    Admin access permissions

    +

    Member permissions available for admin-level acccess.

    + +

    Organization domain requests

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

    Organization members

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

    Basic member permissions

    +

    Member permissions available for basic-level acccess.

    + +

    Organization domain requests

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.basic_org_domain_request_permissions %} + {% endwith %} +
    + + +
    + Cancel + + + +
    +
    + +{% endblock portfolio_content%} + +{% comment %} {% extends 'portfolio_base.html' %} {% load static field_helpers%} {% block title %}Organization member {% endblock %} @@ -39,4 +180,4 @@
    -{% endblock %} +{% endblock %} {% endcomment %} From dffae9163e7ccaa88388bd0c3ffd082c28c994fe Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:20:58 -0800 Subject: [PATCH 019/231] Update portfolio screenreader additional details form --- .../portfolio_domain_request_additional_details.html | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index 5bc529243..1adc4e308 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,15 +6,9 @@ {% endblock %} {% block form_fields %} - -
    -

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

    - -
    -

    This question is optional.

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

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

    + +
    +

    This question is optional.

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

    {{ description }}

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

    Member access and permissions

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

    Member email

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

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

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

    Organization members

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

    Organization domain requests

    {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.basic_org_domain_request_permissions %} + {% input_with_errors form.domain_request_permissions_member %} {% endwith %}
    @@ -123,17 +105,11 @@ href="{% url 'members' %}" class="usa-button usa-button--outline" name="btn-cancel-click" - aria-label="Cancel adding new member" - >Cancel - - - + aria-label="Cancel editing member" + > + Cancel + + diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index e88830156..6140130c8 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -282,3 +282,11 @@ def display_requesting_entity(domain_request): ) return display + + +@register.filter +def get_dict_value(dictionary, key): + """Get a value from a dictionary. Returns a string on empty.""" + if isinstance(dictionary, dict): + return dictionary.get(key, "") + return "" From 278bc60099ba9291da1df8fcf37c0ef838bd5f6b Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 9 Dec 2024 23:33:48 -0700 Subject: [PATCH 022/231] Design request #1 - make portfolio org name editable for analysts --- src/registrar/admin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..17f3fd292 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3533,10 +3533,6 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - analyst_readonly_fields = [ - "organization_name", - ] - def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) From bc3a96aa87d1d6216a1fcb0105d61b6fcb445353 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:50:30 -0700 Subject: [PATCH 023/231] fine comb --- src/registrar/admin.py | 1 + .../src/js/getgov/portfolio-member-page.js | 2 +- src/registrar/context_processors.py | 2 + src/registrar/forms/portfolio.py | 99 ++++++++++--------- .../django/forms/widgets/multiple_input.html | 2 +- .../portfolio_member_permissions.html | 15 +-- src/registrar/views/portfolios.py | 2 +- 7 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..144d1fcab 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3791,6 +3791,7 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + extra_context["organization_member"] = flag_is_active_for_user(request.user, "organization_member") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index ac0b7cffe..98bcf7d03 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() { * on the Add New Member page. */ export function initAddNewMemberPageListeners() { - add_member_form = document.getElementById("add_member_form") + let add_member_form = document.getElementById("add_member_form") if (!add_member_form){ return; } diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index c1547ad88..5a526f86f 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -107,6 +107,8 @@ def is_widescreen_mode(request): "/no-organization-requests/", "/no-organization-domains/", "/domain-request/", + # "/members/", + # "/member/" ] is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" is_portfolio_widescreen = bool( diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 65911200b..92fd23906 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,7 +4,7 @@ import logging from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator - +from django.utils.safestring import mark_safe from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, @@ -109,13 +109,13 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): cleaned_data.pop("full_name", None) return cleaned_data - -class BasePortfolioMemberForm(forms.ModelForm): +class BasePortfolioMemberForm(forms.Form): + required_star = '*' + role = forms.ChoiceField( - label="Select permission", choices=[ - (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin Access"), - (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic Access") + (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access") ], widget=forms.RadioSelect, required=True, @@ -123,12 +123,12 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Member access level is required", }, ) - # Permissions for admins + domain_request_permissions_admin = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Create and edit requests") + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), ], widget=forms.RadioSelect, required=False, @@ -136,11 +136,12 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Admin domain request permission is required", }, ) + member_permissions_admin = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), ], widget=forms.RadioSelect, required=False, @@ -148,11 +149,13 @@ class BasePortfolioMemberForm(forms.ModelForm): "required": "Admin member permission is required", }, ) + domain_request_permissions_member = forms.ChoiceField( - label="Select permission", + label=mark_safe(f"Select permission {required_star}"), choices=[ - (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "Create and edit members") + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + ("no_access", "No access"), ], widget=forms.RadioSelect, required=False, @@ -161,8 +164,6 @@ class BasePortfolioMemberForm(forms.ModelForm): }, ) - # this form dynamically shows/hides some fields, depending on what - # was selected prior. This toggles which field is required or not. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ "domain_request_permissions_admin", @@ -173,10 +174,19 @@ class BasePortfolioMemberForm(forms.ModelForm): ], } + def __init__(self, *args, instance=None, **kwargs): + self.instance = instance + # If we have an instance, set initial + if instance: + kwargs['initial'] = self._map_instance_to_form(instance) + + super().__init__(*args, **kwargs) + def _map_instance_to_form(self, instance): """Maps model instance data to form fields""" if not instance: return {} + mapped_data = {} # Map roles with priority for admin if instance.roles: @@ -192,37 +202,16 @@ class BasePortfolioMemberForm(forms.ModelForm): mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_REQUESTS.value elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value in perms: mapped_data['domain_request_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + else: + mapped_data["member_permissions_admin"] = "no_access" if UserPortfolioPermissionChoices.EDIT_MEMBERS.value in perms: mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.EDIT_MEMBERS.value elif UserPortfolioPermissionChoices.VIEW_MEMBERS.value in perms: mapped_data['member_permissions_admin'] = UserPortfolioPermissionChoices.VIEW_MEMBERS.value - + return mapped_data - def _map_form_to_instance(self, instance): - """Maps form data to model instance""" - if not self.is_valid(): - return - - role = self.cleaned_data.get("role") - domain_request_permissions_member = self.cleaned_data.get("domain_request_permissions_member") - domain_request_permissions_admin = self.cleaned_data.get('domain_request_permissions_admin') - member_permissions_admin = self.cleaned_data.get('member_permissions_admin') - - instance.roles = [role] - additional_permissions = [] - if domain_request_permissions_member: - additional_permissions.append(domain_request_permissions_member) - elif domain_request_permissions_admin: - additional_permissions.append(domain_request_permissions_admin) - - if member_permissions_admin: - additional_permissions.append(member_permissions_admin) - - instance.additional_permissions = additional_permissions - return instance - def clean(self): cleaned_data = super().clean() role = cleaned_data.get("role") @@ -239,6 +228,27 @@ class BasePortfolioMemberForm(forms.ModelForm): return cleaned_data + def save(self): + """Save the form data to the instance""" + if not self.instance: + raise ValueError("Cannot save form without instance") + + role = self.cleaned_data.get("role") + self.instance.roles = [self.cleaned_data["role"]] + + additional_permissions = [] + if self.cleaned_data.get("domain_request_permissions_member") and self.cleaned_data["domain_request_permissions_member"] != "no_access": + additional_permissions.append(self.cleaned_data["domain_request_permissions_member"]) + elif self.cleaned_data.get("domain_request_permissions_admin"): + additional_permissions.append(self.cleaned_data["domain_request_permissions_admin"]) + + if self.cleaned_data.get("member_permissions_admin"): + additional_permissions.append(self.cleaned_data["member_permissions_admin"]) + self.instance.additional_permissions = additional_permissions + + self.instance.save() + return self.instance + class PortfolioMemberForm(BasePortfolioMemberForm): """ @@ -250,6 +260,7 @@ class PortfolioMemberForm(BasePortfolioMemberForm): "roles", "additional_permissions", ] + def __init__(self, *args, instance=None, **kwargs): super().__init__(*args, **kwargs) self.fields['role'].descriptions = { @@ -258,14 +269,6 @@ class PortfolioMemberForm(BasePortfolioMemberForm): } self.instance = instance self.initial = self._map_instance_to_form(self.instance) - - def save(self): - """Save form data to instance""" - if not self.instance: - self.instance = self.Meta.model() - self._map_form_to_instance(self.instance) - self.instance.save() - return self.instance class PortfolioInvitedMemberForm(BasePortfolioMemberForm): diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index 76e19b169..cc0e11989 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -21,7 +21,7 @@ {% if field and field.field and field.field.descriptions %} {% with description=field.field.descriptions|get_dict_value:option.value %} {% if description %} -

    {{ description }}

    +

    {{ description }}

    {% endif %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index a5f1731d0..f1db5941c 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -9,6 +9,7 @@ {% endblock %} {% block portfolio_content %} +{% include "includes/form_errors.html" with form=form %}
    From ddf7d92b4b9434718dc7c8c3e32b50efadeeb0fe Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 16 Dec 2024 10:59:58 -0500 Subject: [PATCH 068/231] moving around logic, prepping for refactor --- src/registrar/utility/email_invitations.py | 80 ++++++------------ src/registrar/views/domain.py | 98 ++++++++++++++++++++-- src/registrar/views/portfolios.py | 4 +- 3 files changed, 117 insertions(+), 65 deletions(-) diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index af5a8998e..1c076493a 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -17,19 +17,7 @@ import logging logger = logging.getLogger(__name__) -def _is_member_of_different_org(email, requestor, requested_user): - """Verifies if an email belongs to a different organization as a member or invited member.""" - # Check if requested_user is a already member of a different organization than the requestor's org - 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() - - 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(email: str, requestor, domain, requested_user=None): +def send_domain_invitation_email(email: str, requestor, domain, is_member_of_different_org): """ Sends a domain invitation email to the specified address. @@ -39,7 +27,7 @@ def send_domain_invitation_email(email: str, requestor, domain, requested_user=N email (str): Email address of the recipient. requestor (User): The user initiating the invitation. domain (Domain): The domain object for which the invitation is being sent. - requested_user (User): The user of the recipient, if exists; defaults to None + is_member_of_different_org (bool): if an email belongs to a different org Raises: MissingEmailError: If the requestor has no email associated with their account. @@ -59,8 +47,11 @@ def send_domain_invitation_email(email: str, requestor, domain, requested_user=N requestor_email = requestor.email # Check if the recipient is part of a different organization - if flag_is_active_for_user(requestor, "organization_feature") and _is_member_of_different_org( - email, requestor, requested_user + # COMMENT: this does not account for multiple_portfolios flag being active + if ( + flag_is_active_for_user(requestor, "organization_feature") + and not flag_is_active_for_user(requestor, "multiple_portfolios") + and is_member_of_different_org ): raise OutsideOrgMemberError @@ -78,24 +69,15 @@ def send_domain_invitation_email(email: str, requestor, domain, requested_user=N pass # Send the email - try: - send_templated_email( - "emails/domain_invitation.txt", - "emails/domain_invitation_subject.txt", - to_address=email, - context={ - "domain": domain, - "requestor_email": requestor_email, - }, - ) - except EmailSendingError as exc: - logger.warning( - "Could not send email invitation to %s for domain %s", - email, - domain, - exc_info=True, - ) - raise EmailSendingError("Could not send email invitation.") from exc + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email, + context={ + "domain": domain, + "requestor_email": requestor_email, + }, + ) def send_portfolio_invitation_email(email: str, requestor, portfolio): @@ -136,23 +118,13 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio): except PortfolioInvitation.DoesNotExist: pass - try: - send_templated_email( - "emails/portfolio_invitation.txt", - "emails/portfolio_invitation_subject.txt", - to_address=email, - context={ - "portfolio": portfolio, - "requestor_email": requestor_email, - "email": email, - }, - ) - except EmailSendingError as exc: - logger.warning( - "Could not sent email invitation to %s for portfolio %s", - email, - portfolio, - exc_info=True, - ) - raise EmailSendingError("Could not send email invitation.") from exc - + send_templated_email( + "emails/portfolio_invitation.txt", + "emails/portfolio_invitation_subject.txt", + to_address=email, + context={ + "portfolio": portfolio, + "requestor_email": requestor_email, + "email": email, + }, + ) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index eccc49aad..f0b0d5085 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -27,11 +27,14 @@ from registrar.models import ( UserDomainRole, PublicContact, ) +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.enums import DefaultEmail from registrar.utility.errors import ( AlreadyDomainInvitedError, AlreadyDomainManagerError, + AlreadyPortfolioInvitedError, + AlreadyPortfolioMemberError, GenericError, GenericErrorCodes, MissingEmailError, @@ -65,7 +68,7 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError -from ..utility.email_invitations import send_domain_invitation_email +from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email from .utility import DomainPermissionView, DomainInvitationPermissionCancelView from django import forms @@ -1117,6 +1120,7 @@ class DomainUsersView(DomainBaseView): # Check if there are any PortfolioInvitations linked to the same portfolio with the ORGANIZATION_ADMIN role has_admin_flag = False + logger.info(domain_invitation) # Query PortfolioInvitations linked to the same portfolio and check roles portfolio_invitations = PortfolioInvitation.objects.filter( portfolio=portfolio, email=domain_invitation.email @@ -1124,7 +1128,9 @@ class DomainUsersView(DomainBaseView): # If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True for portfolio_invitation in portfolio_invitations: - if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles: + logger.info(portfolio_invitation) + logger.info(portfolio_invitation.roles) + if portfolio_invitation.roles and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles: has_admin_flag = True break # Once we find one match, no need to check further @@ -1186,6 +1192,38 @@ class DomainAddUserView(DomainFormBaseView): def get_success_url(self): return reverse("domain-users", kwargs={"pk": self.object.pk}) + def _get_org_membership(self, requestor_org, requested_email, requested_user): + """ + Verifies if an email belongs to a different organization as a member or invited member. + Verifies if an email belongs to this organization as a member or invited member. + User does not belong to any org can be deduced from the tuple returned. + + Returns a tuple (member_of_a_different_org, member_of_this_org). + """ + + # COMMENT: this code does not take into account multiple portfolios flag + + # COMMENT: shouldn't this code be based on the organization of the domain, not the org + # of the requestor? requestor could have multiple portfolios + + # Check for existing permissions or invitations for the requested user + existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() + existing_org_invitation = PortfolioInvitation.objects.filter(email=requested_email).first() + + # Determine membership in a different organization + member_of_a_different_org = ( + (existing_org_permission and existing_org_permission.portfolio != requestor_org) or + (existing_org_invitation and existing_org_invitation.portfolio != requestor_org) + ) + + # Determine membership in the same organization + member_of_this_org = ( + (existing_org_permission and existing_org_permission.portfolio == requestor_org) or + (existing_org_invitation and existing_org_invitation.portfolio == requestor_org) + ) + + return member_of_a_different_org, member_of_this_org + def form_valid(self, form): """Add the specified user to this domain.""" requested_email = form.cleaned_data["email"] @@ -1193,12 +1231,34 @@ class DomainAddUserView(DomainFormBaseView): # Look up a user with that email requested_user = self._get_requested_user(requested_email) + # Get the requestor's organization + requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio + + member_of_a_different_org, member_of_this_org = self._get_org_membership(requestor_org, requested_email, requested_user) + + # determine portfolio of the domain (code currently is looking at requestor's portfolio) + # if requested_email/user is not member or invited member of this portfolio + # COMMENT: this code does not take into account multiple portfolios flag + # send portfolio invitation email + # create portfolio invitation + # create message to view + if ( + flag_is_active_for_user(requestor, "organization_feature") + and not flag_is_active_for_user(requestor, "multiple_portfolios") + and not member_of_this_org + ): + try: + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=requestor_org) + PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=requestor_org) + messages.success(self.request, f"{requested_email} has been invited.") + except Exception as e: + self._handle_portfolio_exceptions(e, requested_email, requestor_org) try: if requested_user is None: - self._handle_new_user_invitation(requested_email, requestor) + self._handle_new_user_invitation(requested_email, requestor, member_of_a_different_org) else: - self._handle_existing_user(requested_email, requestor, requested_user) + self._handle_existing_user(requested_email, requestor, requested_user, member_of_a_different_org) except Exception as e: self._handle_exceptions(e, requested_email) @@ -1211,23 +1271,25 @@ class DomainAddUserView(DomainFormBaseView): except User.DoesNotExist: return None - def _handle_new_user_invitation(self, email, requestor): + def _handle_new_user_invitation(self, email, requestor, member_of_different_org): """Handle invitation for a new user who does not exist in the system.""" send_domain_invitation_email( email=email, requestor=requestor, domain=self.object, + is_member_of_different_org=member_of_different_org, ) DomainInvitation.objects.get_or_create(email=email, domain=self.object) messages.success(self.request, f"{email} has been invited to this domain.") - def _handle_existing_user(self, email, requestor, requested_user): + def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org): """Handle adding an existing user to the domain.""" send_domain_invitation_email( email=email, requestor=requestor, requested_user=requested_user, domain=self.object, + is_member_of_different_org=member_of_different_org, ) UserDomainRole.objects.create( user=requested_user, @@ -1239,10 +1301,10 @@ class DomainAddUserView(DomainFormBaseView): def _handle_exceptions(self, exception, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warn("Could not send email invitation (EmailSendingError)", self.object, exc_info=True) + logger.warning("Could not send email invitation to %s for domain %s (EmailSendingError)", email, self.object, exc_info=True) messages.warning(self.request, "Could not send email invitation.") elif isinstance(exception, OutsideOrgMemberError): - logger.warn( + logger.warning( "Could not send email. Can not invite member of a .gov organization to a different organization.", self.object, exc_info=True, @@ -1264,9 +1326,27 @@ class DomainAddUserView(DomainFormBaseView): elif isinstance(exception, IntegrityError): messages.warning(self.request, f"{email} is already a manager for this domain") else: - logger.warn("Could not send email invitation (Other Exception)", self.object, exc_info=True) + logger.warning("Could not send email invitation (Other Exception)", self.object, exc_info=True) messages.warning(self.request, "Could not send email invitation.") + def _handle_portfolio_exceptions(self, exception, email, portfolio): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning("Could not send email invitation (EmailSendingError)", portfolio, exc_info=True) + messages.warning(self.request, "Could not send email invitation.") + elif isinstance(exception, AlreadyPortfolioMemberError): + messages.warning(self.request, str(exception)) + elif isinstance(exception, AlreadyPortfolioInvitedError): + messages.warning(self.request, str(exception)) + elif isinstance(exception, MissingEmailError): + messages.error(self.request, str(exception)) + logger.error( + f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.", + exc_info=True, + ) + else: + logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True) + messages.warning(self.request, "Could not send email invitation.") class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): object: DomainInvitation diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index c1a45208e..dae2cc9fe 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -530,10 +530,10 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): requested_user = User.objects.filter(email=requested_email).first() permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists() - # invitation_exists = PortfolioInvitation.objects.filter(email=requested_email, portfolio=self.object).exists() try: if not requested_user or not permission_exists: send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=self.object) + ## NOTE : this is not yet accounting properly for roles and permissions PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=self.object) messages.success(self.request, f"{requested_email} has been invited.") else: @@ -546,7 +546,7 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): def _handle_exceptions(self, exception, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warning("Could not send email invitation (EmailSendingError)", self.object, exc_info=True) + logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", email, self.object, exc_info=True) messages.warning(self.request, "Could not send email invitation.") elif isinstance(exception, AlreadyPortfolioMemberError): messages.warning(self.request, str(exception)) From 36f3d3e8a909588b06ea5efc1e78e6e1c2e4ee0a Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 16 Dec 2024 10:25:25 -0600 Subject: [PATCH 069/231] add warning to domain requests when status cannot be changed --- src/registrar/admin.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..5e8148664 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2543,6 +2543,31 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Further filter the queryset by the portfolio qs = qs.filter(portfolio=portfolio_id) return qs + + def change_view(self, request, object_id, form_url="", extra_context=None): + """Extend the change_view for DomainRequest objects in django admin. + Customize to display notification that statu cannot be changed from 'Approved'.""" + + # Fetch the Contact instance + domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id) + if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY: + domain = domain_request.approved_domain + # get change url for domain + app_label = domain_request.approved_domain._meta.app_label + model_name = domain._meta.model_name + obj_id = domain.id + change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) + + message += f"

    The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa + message += f"{domain}

    " + + message_html = mark_safe(message) # nosec + messages.warning( + request, + message_html, + ) + + return super().change_view(request, object_id, form_url, extra_context=extra_context) class TransitionDomainAdmin(ListHeaderAdmin): From 2ba9fd8be66dc80e3ccc8d27aba696bc0ca46b27 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 11:13:37 -0700 Subject: [PATCH 070/231] fix 500 error for Portfolio Admin --- src/registrar/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b2fc80e17..13729f003 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3607,6 +3607,11 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [ + ] + def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) From d888c616b8f8a87ca1ce54bb7e13b945ae06b5a1 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 16 Dec 2024 13:13:41 -0500 Subject: [PATCH 071/231] wip --- src/registrar/admin.py | 105 ++++++++++++++++++++++++++++++- src/registrar/forms/portfolio.py | 2 +- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0e8e4847a..e7bab8405 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -14,6 +14,7 @@ from django.db.models import ( from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.utility.admin_helpers import ( AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, @@ -21,10 +22,14 @@ from registrar.utility.admin_helpers import ( get_field_links_as_list, ) from django.conf import settings +from django.contrib.messages import get_messages +from django.contrib.admin.helpers import AdminForm from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.utility.email import EmailSendingError +from registrar.utility.email_invitations import send_portfolio_invitation_email from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -37,7 +42,7 @@ from waffle.admin import FlagAdmin from waffle.models import Sample, Switch from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices -from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes +from registrar.utility.errors import AlreadyPortfolioInvitedError, AlreadyPortfolioMemberError, FSMDomainRequestError, FSMErrorCodes, MissingEmailError from registrar.utility.waffle import flag_is_active_for_user from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -1461,6 +1466,104 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): extra_context["tabtitle"] = "Portfolio invitations" # Get the filtered values return super().changelist_view(request, extra_context=extra_context) + + def save_model(self, request, obj, form, change): + """ + Override the save_model method to send an email only on creation of the PortfolioInvitation object. + """ + if not change: # Only send email if this is a new PortfolioInvitation(creation) + portfolio = obj.portfolio + requested_email = obj.email + requestor = request.user + + requested_user = User.objects.filter(email=requested_email).first() + permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() + try: + if not requested_user or not permission_exists: + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) + messages.success(request, f"{requested_email} has been invited.") + else: + if permission_exists: + messages.warning(request, "User is already a member of this portfolio.") + except Exception as e: + self._handle_exceptions(e, request, obj) + return + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + + def _handle_exceptions(self, exception, request, obj): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", obj.email, obj.portfolio, exc_info=True) + messages.warning(request, "Could not send email invitation.") + elif isinstance(exception, AlreadyPortfolioMemberError): + messages.warning(request, str(exception)) + elif isinstance(exception, AlreadyPortfolioInvitedError): + messages.warning(request, str(exception)) + elif isinstance(exception, MissingEmailError): + messages.error(request, str(exception)) + logger.error( + f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. No email exists for the requestor.", + exc_info=True, + ) + else: + logger.warning("Could not send email invitation (Other Exception)", obj.portfolio, exc_info=True) + messages.warning(request, "Could not send email invitation.") + + def response_add(self, request, obj, post_url_continue=None): + """ + Override response_add to handle redirection when exceptions are raised. + """ + # Check if there are any error or warning messages in the `messages` framework + storage = get_messages(request) + has_errors = any(message.level_tag in ["error", "warning"] for message in storage) + + if has_errors: + # Re-render the change form if there are errors or warnings + # Prepare context for rendering the change form + opts = self.model._meta + app_label = opts.app_label + + # Get the model form + ModelForm = self.get_form(request, obj=obj) + form = ModelForm(instance=obj) + + # Create an AdminForm instance + admin_form = AdminForm( + form, + list(self.get_fieldsets(request, obj)), + self.prepopulated_fields, + self.get_readonly_fields(request, obj), + model_admin=self, + ) + + opts = obj._meta + change_form_context = { + **self.admin_site.each_context(request), # Add admin context + "title": f"Change {opts.verbose_name}", + "opts": opts, + "original": obj, + "save_as": self.save_as, + "has_change_permission": self.has_change_permission(request, obj), + "add": False, # Indicate this is not an "Add" form + "change": True, # Indicate this is a "Change" form + "is_popup": False, + "inline_admin_formsets": [], + "save_on_top": self.save_on_top, + "show_delete": self.has_delete_permission(request, obj), + "obj": obj, + "adminform": admin_form, # Pass the AdminForm instance + "errors": None, # You can use this to pass custom form errors + } + return self.render_change_form( + request, + context=change_form_context, + add=False, + change=True, + obj=obj, + ) + + return super().response_add(request, obj, post_url_continue) class DomainInformationResource(resources.ModelResource): diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5309f7263..5dd6fdb3e 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -223,7 +223,7 @@ class NewMemberForm(forms.ModelForm): ) class Meta: - model = User + model = PortfolioInvitation fields = ["email"] def clean(self): From 0554133286e1b35ade507b36a2bcc1fe1304409a Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 12:21:26 -0700 Subject: [PATCH 072/231] Fix ordering on custom_requested_domain. Move portfolio filter to top of filter list --- src/registrar/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 13729f003..c0024c4dc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1862,7 +1862,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): url, text ) - custom_requested_domain.admin_order_field = "requested_domain" # type: ignore + custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore # ------ Converted fields ------ # These fields map to @Property methods and @@ -1998,11 +1998,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Filters list_filter = ( + PortfolioFilter, StatusListFilter, GenericOrgFilter, FederalTypeFilter, ElectionOfficeFilter, - PortfolioFilter, "rejection_reason", InvestigatorFilter, ) From f09627bca6268c05d4515cb97335a2cd57ce2f1d Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 16 Dec 2024 14:28:17 -0500 Subject: [PATCH 073/231] work in progress tracing current form implementation --- src/registrar/config/urls.py | 2 +- src/registrar/forms/portfolio.py | 6 +- src/registrar/models/portfolio.py | 6 ++ src/registrar/models/portfolio_invitation.py | 4 + .../models/utility/portfolio_helper.py | 3 + src/registrar/views/portfolios.py | 74 ++++++++++--------- 6 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index caf51cc36..d71645dee 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -136,7 +136,7 @@ urlpatterns = [ # ), path( "members/new-member/", - views.NewMemberView.as_view(), + views.PortfolioNewMemberView.as_view(), name="new-member", ), path( diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5dd6fdb3e..14b00529f 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -164,7 +164,7 @@ class PortfolioInvitedMemberForm(forms.ModelForm): ] -class NewMemberForm(forms.ModelForm): +class PortfolioNewMemberForm(forms.ModelForm): member_access_level = forms.ChoiceField( label="Select permission", choices=[("admin", "Admin Access"), ("basic", "Basic Access")], @@ -226,6 +226,10 @@ class NewMemberForm(forms.ModelForm): model = PortfolioInvitation fields = ["email"] + def _post_clean(self): + logger.info("in _post_clean") + super()._post_clean() + def clean(self): cleaned_data = super().clean() diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 82afcd4d6..633f27126 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -6,7 +6,9 @@ from registrar.models.user import User from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel +import logging +logger = logging.getLogger(__name__) class Portfolio(TimeStampedModel): """ @@ -163,3 +165,7 @@ class Portfolio(TimeStampedModel): def get_suborganizations(self): """Returns all suborganizations associated with this portfolio""" return self.portfolio_suborganizations.all() + + def full_clean(self, exclude=None, validate_unique=True): + logger.info("portfolio full clean") + super().full_clean(exclude, validate_unique) diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 11c564c36..92f527377 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -111,6 +111,10 @@ class PortfolioInvitation(TimeStampedModel): user_portfolio_permission.additional_permissions = self.additional_permissions user_portfolio_permission.save() + def full_clean(self, exclude=None, validate_unique=True): + logger.info("portfolio invitation full clean") + super().full_clean(exclude, validate_unique) + def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 3768aa77a..396e6ab69 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -4,7 +4,9 @@ from django.apps import apps from django.forms import ValidationError from registrar.utility.waffle import flag_is_active_for_user from django.contrib.auth import get_user_model +import logging +logger = logging.getLogger(__name__) class UserPortfolioRoleChoices(models.TextChoices): """ @@ -153,6 +155,7 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ + logger.info("portfolio invitataion validation") PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index dae2cc9fe..84fa9682b 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -469,33 +469,34 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): return render(request, "portfolio_members.html") -class NewMemberView(PortfolioMembersPermissionView, FormMixin): +class PortfolioNewMemberView(PortfolioMembersPermissionView, FormMixin): template_name = "portfolio_members_add_new.html" - form_class = portfolioForms.NewMemberForm + form_class = portfolioForms.PortfolioNewMemberForm - def get_object(self, queryset=None): - """Get the portfolio object based on the session.""" - portfolio = self.request.session.get("portfolio") - if portfolio is None: - raise Http404("No organization found for this user") - return portfolio + # def get_object(self, queryset=None): + # """Get the portfolio object based on the session.""" + # portfolio = self.request.session.get("portfolio") + # if portfolio is None: + # raise Http404("No organization found for this user") + # return portfolio - def get_form_kwargs(self): - """Include the instance in the form kwargs.""" - kwargs = super().get_form_kwargs() - kwargs["instance"] = self.get_object() - return kwargs + # def get_form_kwargs(self): + # """Include the instance in the form kwargs.""" + # kwargs = super().get_form_kwargs() + # kwargs["instance"] = self.get_object() + # return kwargs def get(self, request, *args, **kwargs): """Handle GET requests to display the form.""" - self.object = self.get_object() + self.object = self.request.session.get("portfolio") form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) def post(self, request, *args, **kwargs): """Handle POST requests to process form submission.""" - self.object = self.get_object() + + # self.object = self.get_object() form = self.get_form() if form.is_valid(): @@ -503,50 +504,51 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): else: return self.form_invalid(form) - def is_ajax(self): - return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + # def is_ajax(self): + # return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" - def form_invalid(self, form): - if self.is_ajax(): - return JsonResponse({"is_valid": False}) # Return a JSON response - else: - return super().form_invalid(form) # Handle non-AJAX requests normally + # def form_invalid(self, form): + # if self.is_ajax(): + # return JsonResponse({"is_valid": False}) # Return a JSON response + # else: + # return super().form_invalid(form) # Handle non-AJAX requests normally - def form_valid(self, form): + # def form_valid(self, form): - if self.is_ajax(): - return JsonResponse({"is_valid": True}) # Return a JSON response - else: - return self.submit_new_member(form) + # if self.is_ajax(): + # return JsonResponse({"is_valid": True}) # Return a JSON response + # else: + # return self.submit_new_member(form) def get_success_url(self): """Redirect to members table.""" return reverse("members") - def submit_new_member(self, form): + def form_valid(self, form): """Add the specified user as a member for this portfolio.""" requested_email = form.cleaned_data["email"] requestor = self.request.user + portfolio = self.request.session.get("portfolio") requested_user = User.objects.filter(email=requested_email).first() - permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists() + permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() try: if not requested_user or not permission_exists: - send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=self.object) + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) ## NOTE : this is not yet accounting properly for roles and permissions - PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=self.object) + PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=portfolio) messages.success(self.request, f"{requested_email} has been invited.") else: if permission_exists: messages.warning(self.request, "User is already a member of this portfolio.") except Exception as e: - self._handle_exceptions(e, requested_email) + self._handle_exceptions(e, portfolio, requested_email) return redirect(self.get_success_url()) - def _handle_exceptions(self, exception, email): + def _handle_exceptions(self, exception, portfolio, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", email, self.object, exc_info=True) + logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", email, portfolio, exc_info=True) messages.warning(self.request, "Could not send email invitation.") elif isinstance(exception, AlreadyPortfolioMemberError): messages.warning(self.request, str(exception)) @@ -555,9 +557,9 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): elif isinstance(exception, MissingEmailError): messages.error(self.request, str(exception)) logger.error( - f"Can't send email to '{email}' for portfolio '{self.object}'. No email exists for the requestor.", + f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.", exc_info=True, ) else: - logger.warning("Could not send email invitation (Other Exception)", self.object, exc_info=True) + logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True) messages.warning(self.request, "Could not send email invitation.") From 679fab221f9118af8d23b1ed3a548aafc92e063b Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 13:10:48 -0700 Subject: [PATCH 074/231] revert erroneous text --- src/registrar/templates/includes/header_basic.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/includes/header_basic.html b/src/registrar/templates/includes/header_basic.html index e42b2529b..3f8b4a2fb 100644 --- a/src/registrar/templates/includes/header_basic.html +++ b/src/registrar/templates/includes/header_basic.html @@ -1,7 +1,7 @@ {% load static %}
    -
    +
    {% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %} From ead90c15411ac2f9d1cadc478bc85992642b4e7a Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 16 Dec 2024 14:11:42 -0600 Subject: [PATCH 075/231] tests for approved domain warning --- src/registrar/admin.py | 50 ++++++++++------------- src/registrar/tests/test_admin_request.py | 38 ++++++++++++++++- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5e8148664..ce3b0220c 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2429,8 +2429,28 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return response def change_view(self, request, object_id, form_url="", extra_context=None): - """Display restricted warning, - Setup the auditlog trail and pass it in extra context.""" + """Display restricted warning, setup the auditlog trail and pass it in extra context, + display warning that status cannot be changed from 'Approved' if domain is in Ready state""" + + # Fetch the Contact instance + domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id) + if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY: + domain = domain_request.approved_domain + # get change url for domain + app_label = domain_request.approved_domain._meta.app_label + model_name = domain._meta.model_name + obj_id = domain.id + change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) + + message = f"
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa + message += f"{domain}
  • " + + message_html = mark_safe(message) # nosec + messages.warning( + request, + message_html, + ) + obj = self.get_object(request, object_id) self.display_restricted_warning(request, obj) @@ -2543,32 +2563,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Further filter the queryset by the portfolio qs = qs.filter(portfolio=portfolio_id) return qs - - def change_view(self, request, object_id, form_url="", extra_context=None): - """Extend the change_view for DomainRequest objects in django admin. - Customize to display notification that statu cannot be changed from 'Approved'.""" - - # Fetch the Contact instance - domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id) - if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY: - domain = domain_request.approved_domain - # get change url for domain - app_label = domain_request.approved_domain._meta.app_label - model_name = domain._meta.model_name - obj_id = domain.id - change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) - - message += f"

    The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa - message += f"{domain}

    " - - message_html = mark_safe(message) # nosec - messages.warning( - request, - message_html, - ) - - return super().change_view(request, object_id, form_url, extra_context=extra_context) - class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index df0902719..d744dd00a 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -25,6 +25,8 @@ from registrar.models import ( Portfolio, AllowedEmail, ) +from registrar.models.host import Host +from registrar.models.public_contact import PublicContact from .common import ( MockSESClient, completed_domain_request, @@ -36,7 +38,7 @@ from .common import ( MockEppLib, GenericTestHelper, ) -from unittest.mock import patch +from unittest.mock import ANY, patch from django.conf import settings import boto3_mocking # type: ignore @@ -76,6 +78,8 @@ class TestDomainRequestAdmin(MockEppLib): def tearDown(self): super().tearDown() + Host.objects.all().delete() + PublicContact.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() @@ -91,6 +95,7 @@ class TestDomainRequestAdmin(MockEppLib): User.objects.all().delete() AllowedEmail.objects.all().delete() + @less_console_noise_decorator def test_domain_request_senior_official_is_alphabetically_sorted(self): """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" @@ -1810,6 +1815,37 @@ class TestDomainRequestAdmin(MockEppLib): request, "Cannot edit a domain request with a restricted creator.", ) + + # @less_console_noise_decorator + def test_approved_domain_request_with_ready_domain_has_warning_message(self): + """Tests if the domain request has a warning message when the approved domain is in Ready state""" + # Create an instance of the model + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Approve the domain request + domain_request.approve() + domain_request.save() + + # Add nameservers to get to Ready state + domain_request.approved_domain.nameservers = [ + ("ns1.city.gov", ["1.1.1.1"]), + ("ns2.city.gov", ["1.1.1.2"]), + ] + domain_request.approved_domain.save() + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with patch("django.contrib.messages.warning") as mock_warning: + # Create a request object + self.client.force_login(self.superuser) + self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Assert that the error message was called with the correct argument + mock_warning.assert_called_once_with( + ANY, # don't care about the request argument + "
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status: city.gov
  • " # care about this message + ) def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None): """Helper method that triggers domain request state changes from approved to another state, From ec39b159ecfffb7786b2530ce127037084edee68 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 16 Dec 2024 14:19:12 -0600 Subject: [PATCH 076/231] make test less brittle --- src/registrar/tests/test_admin_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index d744dd00a..bbf6c1923 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1844,7 +1844,7 @@ class TestDomainRequestAdmin(MockEppLib): # Assert that the error message was called with the correct argument mock_warning.assert_called_once_with( ANY, # don't care about the request argument - "
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status: city.gov
  • " # care about this message + f"
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status: {domain_request.approved_domain.name}
  • ", # noqa ) def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None): From 79077cce31efa1e469ba426507e9e7d44c263890 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:23:14 -0700 Subject: [PATCH 077/231] basic logic --- .../js/getgov-admin/domain-request-form.js | 5 +++ ...0_alter_suborganization_unique_together.py | 17 +++++++++ src/registrar/models/domain.py | 1 + src/registrar/models/domain_request.py | 37 ++++++++++++++++++- src/registrar/models/suborganization.py | 3 ++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/registrar/migrations/0140_alter_suborganization_unique_together.py diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index a815a59a1..6b79a2419 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -629,6 +629,10 @@ export function initRejectedEmail() { }); } +function handleSuborganizationSelection() { + console.log("cats are cool") +} + /** * A function for dynamic DomainRequest fields */ @@ -636,5 +640,6 @@ export function initDynamicDomainRequestFields(){ const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { handlePortfolioSelection(); + handleSuborganizationSelection(); } } diff --git a/src/registrar/migrations/0140_alter_suborganization_unique_together.py b/src/registrar/migrations/0140_alter_suborganization_unique_together.py new file mode 100644 index 000000000..e59ecdf2b --- /dev/null +++ b/src/registrar/migrations/0140_alter_suborganization_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.10 on 2024-12-16 17:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0139_alter_domainrequest_action_needed_reason"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="suborganization", + unique_together={("name", "portfolio")}, + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index cc600e1ce..2ea78ff10 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -237,6 +237,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" + return True if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 44d8511b0..46e188a0a 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -690,6 +690,18 @@ class DomainRequest(TimeStampedModel): # Update the cached values after saving self._cache_status_and_status_reasons() + def create_requested_suborganization(self): + """Creates the requested suborganization. + Adds the name, portfolio, city, and state_territory fields. + Returns the created suborganization.""" + Suborganization = apps.get_model("registrar.Suborganization") + return Suborganization.objects.create( + name=self.requested_suborganization, + portfolio=self.portfolio, + city=self.suborganization_city, + state_territory=self.suborganization_state_territory, + ) + def send_custom_status_update_email(self, status): """Helper function to send out a second status email when the status remains the same, but the reason has changed.""" @@ -785,6 +797,7 @@ class DomainRequest(TimeStampedModel): def delete_and_clean_up_domain(self, called_from): try: + # Clean up the approved domain domain_state = self.approved_domain.state # Only reject if it exists on EPP if domain_state != Domain.State.UNKNOWN: @@ -796,6 +809,19 @@ class DomainRequest(TimeStampedModel): logger.error(err) logger.error(f"Can't query an approved domain while attempting {called_from}") + # Clean up any created suborgs, assuming its for this record only + if self.sub_organization is not None: + request_suborg_count = self.request_sub_organization.count() + domain_suborgs = self.DomainRequest_info.filter( + sub_organization=self.sub_organization + ).count() + if request_suborg_count == 1 and domain_suborgs.count() <= 1: + # if domain_suborgs.count() == 1: + # domain_info = domain_suborgs.first() + # domain_info.sub_organization = None + # domain_info.save() + self.sub_organization.delete() + def _send_status_update_email( self, new_status, @@ -984,6 +1010,7 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.APPROVED: self.delete_and_clean_up_domain("action_needed") + elif self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None @@ -1014,8 +1041,16 @@ class DomainRequest(TimeStampedModel): domain request into an admin on that domain. It also triggers an email notification.""" + should_save = False if self.federal_agency is None: self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() + should_save = True + + if self.is_requesting_new_suborganization(): + self.sub_organization = self.create_requested_suborganization() + should_save = True + + if should_save: self.save() # create the domain @@ -1148,7 +1183,7 @@ class DomainRequest(TimeStampedModel): def is_requesting_new_suborganization(self) -> bool: """Determines if a user is trying to request a new suborganization using the domain request form, rather than one that already exists. - Used for the RequestingEntity page. + Used for the RequestingEntity page and on DomainInformation.create_from_da(). Returns True if a sub_organization does not exist and if requested_suborganization, suborganization_city, and suborganization_state_territory all exist. diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py index 087490244..fb32ad48a 100644 --- a/src/registrar/models/suborganization.py +++ b/src/registrar/models/suborganization.py @@ -9,6 +9,9 @@ class Suborganization(TimeStampedModel): Suborganization under an organization (portfolio) """ + class Meta: + unique_together = ["name", "portfolio"] + name = models.CharField( unique=True, max_length=1000, From 172cd36f74b8835c888abcc715013660760d30e1 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:06:44 -0800 Subject: [PATCH 078/231] Update django version in pipfile --- src/Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pipfile b/src/Pipfile index fdf127d7c..07b1db715 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -django = "4.2.10" +django = "4.2.17" cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" From 060f2d5c8acca4a733311970d4057e33b1576e32 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 16 Dec 2024 17:13:35 -0500 Subject: [PATCH 079/231] refactor form and view for add portfolio member --- src/registrar/assets/src/js/getgov/main.js | 4 +- .../src/js/getgov/portfolio-member-page.js | 4 +- src/registrar/config/urls.py | 2 +- src/registrar/forms/portfolio.py | 94 +++++++++--------- src/registrar/models/portfolio_invitation.py | 5 +- .../models/utility/portfolio_helper.py | 11 ++- .../templates/portfolio_members_add_new.html | 1 - src/registrar/utility/email_invitations.py | 2 +- src/registrar/utility/errors.py | 4 +- src/registrar/views/portfolios.py | 98 +++++++++---------- 10 files changed, 115 insertions(+), 110 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 5de02f35a..0e019dd22 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -23,8 +23,8 @@ hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', hookupRadioTogglerListener( 'member_access_level', { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' + 'organization_admin': 'new-member-admin-permissions', + 'organization_member': 'new-member-basic-permissions' } ); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index ac0b7cffe..fb5bd46f7 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() { * on the Add New Member page. */ export function initAddNewMemberPageListeners() { - add_member_form = document.getElementById("add_member_form") + let add_member_form = document.getElementById("add_member_form") if (!add_member_form){ return; } @@ -156,7 +156,7 @@ export function initAddNewMemberPageListeners() { document.getElementById('modalAccessLevel').textContent = accessText; // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { + if (selectedAccess && selectedAccess.value === 'organization_admin') { populatePermissionDetails('new-member-admin-permissions'); } else { populatePermissionDetails('new-member-basic-permissions'); diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index d71645dee..e92742984 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -136,7 +136,7 @@ urlpatterns = [ # ), path( "members/new-member/", - views.PortfolioNewMemberView.as_view(), + views.PortfolioAddMemberView.as_view(), name="new-member", ), path( diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 14b00529f..bb3da7f7d 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -167,7 +167,7 @@ class PortfolioInvitedMemberForm(forms.ModelForm): class PortfolioNewMemberForm(forms.ModelForm): member_access_level = forms.ChoiceField( label="Select permission", - choices=[("admin", "Admin Access"), ("basic", "Basic Access")], + choices=[("organization_admin", "Admin Access"), ("organization_member", "Basic Access")], widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), required=True, error_messages={ @@ -176,7 +176,7 @@ class PortfolioNewMemberForm(forms.ModelForm): ) admin_org_domain_request_permissions = forms.ChoiceField( label="Select permission", - choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], + choices=[("view_all_requests", "View all requests"), ("edit_requests", "View all requests plus create requests")], widget=forms.RadioSelect, required=True, error_messages={ @@ -185,7 +185,7 @@ class PortfolioNewMemberForm(forms.ModelForm): ) admin_org_members_permissions = forms.ChoiceField( label="Select permission", - choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], + choices=[("view_members", "View all members"), ("edit_members", "View all members plus manage members")], widget=forms.RadioSelect, required=True, error_messages={ @@ -195,9 +195,9 @@ class PortfolioNewMemberForm(forms.ModelForm): basic_org_domain_request_permissions = forms.ChoiceField( label="Select permission", choices=[ - ("view_only", "View all requests"), - ("view_and_create", "View all requests plus create requests"), - ("no_access", "No access"), + ("view_all_requests", "View all requests"), + ("edit_requests", "View all requests plus create requests"), + ("", "No access"), ], widget=forms.RadioSelect, required=True, @@ -226,52 +226,52 @@ class PortfolioNewMemberForm(forms.ModelForm): model = PortfolioInvitation fields = ["email"] - def _post_clean(self): - logger.info("in _post_clean") - super()._post_clean() - def clean(self): - cleaned_data = super().clean() - # Lowercase the value of the 'email' field - email_value = cleaned_data.get("email") + email_value = self.cleaned_data.get("email") if email_value: - cleaned_data["email"] = email_value.lower() + self.cleaned_data["email"] = email_value.lower() - ########################################## - # TODO: future ticket - # (invite new member) - ########################################## - # Check for an existing user (if there isn't any, send an invite) - # if email_value: - # try: - # existingUser = User.objects.get(email=email_value) - # except User.DoesNotExist: - # raise forms.ValidationError("User with this email does not exist.") + # Get the selected member access level + member_access_level = self.cleaned_data.get("member_access_level") - member_access_level = cleaned_data.get("member_access_level") - - # Intercept the error messages so that we don't validate hidden inputs + # If no member access level is selected, remove errors for hidden inputs if not member_access_level: - # If no member access level has been selected, delete error messages - # for all hidden inputs (which is everything except the e-mail input - # and member access selection) - for field in self.fields: - if field in self.errors and field != "email" and field != "member_access_level": - del self.errors[field] - return cleaned_data + self._remove_hidden_field_errors(exclude_fields=["email", "member_access_level"]) + return self.cleaned_data - basic_dom_req_error = "basic_org_domain_request_permissions" - admin_dom_req_error = "admin_org_domain_request_permissions" - admin_member_error = "admin_org_members_permissions" + # Define field names for validation cleanup + field_error_map = { + "organization_admin": ["basic_org_domain_request_permissions"], # Fields irrelevant to "admin" + "organization_member": ["admin_org_domain_request_permissions", "admin_org_members_permissions"], # Fields irrelevant to "basic" + } + + # Remove errors for irrelevant fields based on the selected access level + irrelevant_fields = field_error_map.get(member_access_level, []) + for field in irrelevant_fields: + if field in self.errors: + del self.errors[field] + + # Map roles and additional permissions to cleaned_data + self.cleaned_data["roles"] = [member_access_level] + additional_permissions = [ + self.cleaned_data.get("admin_org_domain_request_permissions"), + self.cleaned_data.get("basic_org_domain_request_permissions"), + self.cleaned_data.get("admin_org_members_permissions"), + ] + # Filter out None values + self.cleaned_data["additional_permissions"] = [perm for perm in additional_permissions if perm] + + return super().clean() + + def _remove_hidden_field_errors(self, exclude_fields=None): + """ + Helper method to remove errors for fields that are not relevant + (e.g., hidden inputs), except for explicitly excluded fields. + """ + exclude_fields = exclude_fields or [] + hidden_fields = [field for field in self.fields if field not in exclude_fields] + for field in hidden_fields: + if field in self.errors: + del self.errors[field] - if member_access_level == "admin" and basic_dom_req_error in self.errors: - # remove the error messages pertaining to basic permission inputs - del self.errors[basic_dom_req_error] - elif member_access_level == "basic": - # remove the error messages pertaining to admin permission inputs - if admin_dom_req_error in self.errors: - del self.errors[admin_dom_req_error] - if admin_member_error in self.errors: - del self.errors[admin_member_error] - return cleaned_data diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 92f527377..3a5103b17 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -111,11 +111,8 @@ class PortfolioInvitation(TimeStampedModel): user_portfolio_permission.additional_permissions = self.additional_permissions user_portfolio_permission.save() - def full_clean(self, exclude=None, validate_unique=True): - logger.info("portfolio invitation full clean") - super().full_clean(exclude, validate_unique) - def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" + print(f'portfolio invitation model clean') super().clean() validate_portfolio_invitation(self) diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 396e6ab69..1ec9f2109 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -155,7 +155,6 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ - logger.info("portfolio invitataion validation") PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() @@ -163,11 +162,21 @@ def validate_portfolio_invitation(portfolio_invitation): has_portfolio = bool(portfolio_invitation.portfolio_id) portfolio_permissions = set(portfolio_invitation.get_portfolio_permissions()) + print(f"has_portfolio {has_portfolio}") + + print(f"portfolio_permissions {portfolio_permissions}") + + print(f"roles {portfolio_invitation.roles}") + + print(f"additional permissions {portfolio_invitation.additional_permissions}") + # == Validate required fields == # if not has_portfolio and portfolio_permissions: + print(f"not has_portfolio and portfolio_permissions {portfolio_permissions}") raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.") if has_portfolio and not portfolio_permissions: + print(f"has_portfolio and not portfolio_permissions {portfolio_permissions}") raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") # == Validate role permissions. Compares existing permissions to forbidden ones. == # diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 655b01852..a441c15b1 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -134,7 +134,6 @@ id="invite-member-modal" aria-labelledby="invite-member-heading" aria-describedby="confirm-invite-description" - style="display: none;" >
    diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index 1c076493a..a32c8092f 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -114,7 +114,7 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio): if invite.status == PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED: raise AlreadyPortfolioMemberError(email) else: - raise AlreadyPortfolioInvitedError(email) + raise AlreadyPortfolioInvitedError(email, portfolio) except PortfolioInvitation.DoesNotExist: pass diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 2ad95a99c..ea825b367 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -53,8 +53,8 @@ class AlreadyPortfolioMemberError(InvitationError): class AlreadyPortfolioInvitedError(InvitationError): """Raised when the user has already been invited to the portfolio.""" - def __init__(self, email): - super().__init__(f"{email} has already been invited to this portfolio.") + def __init__(self, email, portfolio): + super().__init__(f"{email} has already been invited to {portfolio}.") class MissingEmailError(InvitationError): diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 84fa9682b..62320364e 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -469,82 +469,82 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): return render(request, "portfolio_members.html") -class PortfolioNewMemberView(PortfolioMembersPermissionView, FormMixin): +class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): template_name = "portfolio_members_add_new.html" form_class = portfolioForms.PortfolioNewMemberForm - # def get_object(self, queryset=None): - # """Get the portfolio object based on the session.""" - # portfolio = self.request.session.get("portfolio") - # if portfolio is None: - # raise Http404("No organization found for this user") - # return portfolio - - # def get_form_kwargs(self): - # """Include the instance in the form kwargs.""" - # kwargs = super().get_form_kwargs() - # kwargs["instance"] = self.get_object() - # return kwargs - def get(self, request, *args, **kwargs): """Handle GET requests to display the form.""" - self.object = self.request.session.get("portfolio") + self.object = None # No existing PortfolioInvitation instance form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) + + def is_ajax(self): + return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def form_invalid(self, form): + if self.is_ajax(): + return JsonResponse({"is_valid": False}) # Return a JSON response + else: + return super().form_invalid(form) # Handle non-AJAX requests normally + + def form_valid(self, form): + + if self.is_ajax(): + return JsonResponse({"is_valid": True}) # Return a JSON response + else: + return self.submit_new_member(form) def post(self, request, *args, **kwargs): """Handle POST requests to process form submission.""" - - # self.object = self.get_object() + self.object = None # For a new invitation, there's no existing model instance form = self.get_form() + print('before is_valid') if form.is_valid(): + print('form is_valid') return self.form_valid(form) else: + print('form NOT is_valid') return self.form_invalid(form) - # def is_ajax(self): - # return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" - - # def form_invalid(self, form): - # if self.is_ajax(): - # return JsonResponse({"is_valid": False}) # Return a JSON response - # else: - # return super().form_invalid(form) # Handle non-AJAX requests normally - - # def form_valid(self, form): - - # if self.is_ajax(): - # return JsonResponse({"is_valid": True}) # Return a JSON response - # else: - # return self.submit_new_member(form) - def get_success_url(self): """Redirect to members table.""" return reverse("members") - def form_valid(self, form): + def submit_new_member(self, form): """Add the specified user as a member for this portfolio.""" - requested_email = form.cleaned_data["email"] - requestor = self.request.user + # Retrieve the portfolio from the session portfolio = self.request.session.get("portfolio") + if not portfolio: + messages.error(self.request, "No portfolio found in session.") + return self.form_invalid(form) + + # Save the invitation instance + invitation = form.save(commit=False) + invitation.portfolio = portfolio + + # Send invitation email and show a success message + send_portfolio_invitation_email( + email=invitation.email, + requestor=self.request.user, + portfolio=portfolio, + ) + + # Use processed data from the form + invitation.roles = form.cleaned_data["roles"] + invitation.additional_permissions = form.cleaned_data["additional_permissions"] + invitation.save() + + messages.success(self.request, f"{invitation.email} has been invited.") - requested_user = User.objects.filter(email=requested_email).first() - permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() - try: - if not requested_user or not permission_exists: - send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) - ## NOTE : this is not yet accounting properly for roles and permissions - PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=portfolio) - messages.success(self.request, f"{requested_email} has been invited.") - else: - if permission_exists: - messages.warning(self.request, "User is already a member of this portfolio.") - except Exception as e: - self._handle_exceptions(e, portfolio, requested_email) return redirect(self.get_success_url()) + def get_success_url(self): + """Redirect to the members page.""" + return reverse("members") + def _handle_exceptions(self, exception, portfolio, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): From ae3ce1f9cd9d8c934f5dfad39a4979bdf4dfec27 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 16 Dec 2024 18:02:35 -0500 Subject: [PATCH 080/231] wip --- src/registrar/forms/portfolio.py | 23 +++++++++++++++++++- src/registrar/models/portfolio_invitation.py | 4 ++++ src/registrar/views/portfolios.py | 12 +++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index bb3da7f7d..26619ecf9 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -224,9 +224,30 @@ class PortfolioNewMemberForm(forms.ModelForm): class Meta: model = PortfolioInvitation - fields = ["email"] + fields = ["portfolio", "email", "roles", "additional_permissions"] + + def is_valid(self): + logger.info("is valid()") + return super().is_valid() + + def full_clean(self): + logger.info("full_clean()") + super().full_clean() + + def _clean_fields(self): + logger.info("clean fields") + logger.info(self.fields) + super()._clean_fields() + + def _post_clean(self): + logger.info("post clean") + logger.info(self.cleaned_data) + super()._post_clean() + logger.info(self.instance) def clean(self): + logger.info(self.cleaned_data) + logger.info(self.initial) # Lowercase the value of the 'email' field email_value = self.cleaned_data.get("email") if email_value: diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 3a5103b17..7988dcd88 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -111,6 +111,10 @@ class PortfolioInvitation(TimeStampedModel): user_portfolio_permission.additional_permissions = self.additional_permissions user_portfolio_permission.save() + def full_clean(self, exclude=True, validate_unique=False): + logger.info("full clean") + super().full_clean(exclude=exclude, validate_unique=validate_unique) + def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" print(f'portfolio invitation model clean') diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 62320364e..6f88f8d21 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -499,7 +499,17 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): def post(self, request, *args, **kwargs): """Handle POST requests to process form submission.""" self.object = None # For a new invitation, there's no existing model instance - form = self.get_form() + + data = request.POST.copy() + + # Override the 'portfolio' field value + if not data.get("portfolio"): + data["portfolio"] = self.request.session.get("portfolio").id + + # Pass the modified data to the form + form = portfolioForms.PortfolioNewMemberForm(data) + #form = self.get_form() + #logger.info(form.fields["portfolio"]) print('before is_valid') if form.is_valid(): From 5504308766cca39ddf2b6ee9b500563f796ee5e4 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 16 Dec 2024 19:18:02 -0700 Subject: [PATCH 081/231] Fix navbar padding (it was getting overridden, so use !important) --- src/registrar/templates/includes/header_extended.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 69e050725..5086a1ad3 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -2,13 +2,13 @@ {% load custom_filters %}
    -
    +
    {% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
    {% block usa_nav %} {% endif %} {% endblock breadcrumb %} + + {% include "includes/form_errors.html" with form=form %} +

    Add a domain manager

    {% if has_organization_feature_flag %}

    From c3480893dab229ce6a9f8e5ef65cfa052126c2d3 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:06:40 -0700 Subject: [PATCH 095/231] Readd modelforms --- src/registrar/forms/portfolio.py | 276 +++++++++++++++++++------------ 1 file changed, 166 insertions(+), 110 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 34d334a3b..935c7c019 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -5,12 +5,14 @@ from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator from django.utils.safestring import mark_safe + from registrar.models import ( - User, + PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, + User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -109,6 +111,169 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data +class PortfolioMemberForm(forms.ModelForm): + """ + Form for updating a portfolio member. + """ + + roles = forms.MultipleChoiceField( + choices=UserPortfolioRoleChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Roles", + ) + + additional_permissions = forms.MultipleChoiceField( + choices=UserPortfolioPermissionChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Additional Permissions", + ) + + class Meta: + model = UserPortfolioPermission + fields = [ + "roles", + "additional_permissions", + ] + + +class PortfolioInvitedMemberForm(forms.ModelForm): + """ + Form for updating a portfolio invited member. + """ + + roles = forms.MultipleChoiceField( + choices=UserPortfolioRoleChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Roles", + ) + + additional_permissions = forms.MultipleChoiceField( + choices=UserPortfolioPermissionChoices.choices, + widget=forms.SelectMultiple(attrs={"class": "usa-select"}), + required=False, + label="Additional Permissions", + ) + + class Meta: + model = PortfolioInvitation + fields = [ + "roles", + "additional_permissions", + ] + + +class NewMemberForm(forms.ModelForm): + member_access_level = forms.ChoiceField( + label="Select permission", + choices=[("admin", "Admin Access"), ("basic", "Basic Access")], + widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + admin_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + admin_org_members_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin member permission is required", + }, + ) + basic_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[ + ("view_only", "View all requests"), + ("view_and_create", "View all requests plus create requests"), + ("no_access", "No access"), + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Basic member permission is required", + }, + ) + + email = forms.EmailField( + label="Enter the email of the member you'd like to invite", + max_length=None, + error_messages={ + "invalid": ("Enter an email address in the required format, like name@example.com."), + "required": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + ) + + class Meta: + model = User + fields = ["email"] + + def clean(self): + cleaned_data = super().clean() + + # Lowercase the value of the 'email' field + email_value = cleaned_data.get("email") + if email_value: + cleaned_data["email"] = email_value.lower() + + ########################################## + # TODO: future ticket + # (invite new member) + ########################################## + # Check for an existing user (if there isn't any, send an invite) + # if email_value: + # try: + # existingUser = User.objects.get(email=email_value) + # except User.DoesNotExist: + # raise forms.ValidationError("User with this email does not exist.") + + member_access_level = cleaned_data.get("member_access_level") + + # Intercept the error messages so that we don't validate hidden inputs + if not member_access_level: + # If no member access level has been selected, delete error messages + # for all hidden inputs (which is everything except the e-mail input + # and member access selection) + for field in self.fields: + if field in self.errors and field != "email" and field != "member_access_level": + del self.errors[field] + return cleaned_data + + basic_dom_req_error = "basic_org_domain_request_permissions" + admin_dom_req_error = "admin_org_domain_request_permissions" + admin_member_error = "admin_org_members_permissions" + + if member_access_level == "admin" and basic_dom_req_error in self.errors: + # remove the error messages pertaining to basic permission inputs + del self.errors[basic_dom_req_error] + elif member_access_level == "basic": + # remove the error messages pertaining to admin permission inputs + if admin_dom_req_error in self.errors: + del self.errors[admin_dom_req_error] + if admin_member_error in self.errors: + del self.errors[admin_member_error] + return cleaned_data + + class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( @@ -332,112 +497,3 @@ class BasePortfolioMemberForm(forms.Form): role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False) instance.additional_permissions = list(additional_permissions - role_permissions) return instance - - -class NewMemberForm(forms.ModelForm): - member_access_level = forms.ChoiceField( - label="Select permission", - choices=[("admin", "Admin Access"), ("basic", "Basic Access")], - widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), - required=True, - error_messages={ - "required": "Member access level is required", - }, - ) - admin_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin domain request permission is required", - }, - ) - admin_org_members_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin member permission is required", - }, - ) - basic_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[ - ("view_only", "View all requests"), - ("view_and_create", "View all requests plus create requests"), - ("no_access", "No access"), - ], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Basic member permission is required", - }, - ) - - email = forms.EmailField( - label="Enter the email of the member you'd like to invite", - max_length=None, - error_messages={ - "invalid": ("Enter an email address in the required format, like name@example.com."), - "required": ("Enter an email address in the required format, like name@example.com."), - }, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], - required=True, - ) - - class Meta: - model = User - fields = ["email"] - - def clean(self): - cleaned_data = super().clean() - - # Lowercase the value of the 'email' field - email_value = cleaned_data.get("email") - if email_value: - cleaned_data["email"] = email_value.lower() - - ########################################## - # TODO: future ticket - # (invite new member) - ########################################## - # Check for an existing user (if there isn't any, send an invite) - # if email_value: - # try: - # existingUser = User.objects.get(email=email_value) - # except User.DoesNotExist: - # raise forms.ValidationError("User with this email does not exist.") - - member_access_level = cleaned_data.get("member_access_level") - - # Intercept the error messages so that we don't validate hidden inputs - if not member_access_level: - # If no member access level has been selected, delete error messages - # for all hidden inputs (which is everything except the e-mail input - # and member access selection) - for field in self.fields: - if field in self.errors and field != "email" and field != "member_access_level": - del self.errors[field] - return cleaned_data - - basic_dom_req_error = "basic_org_domain_request_permissions" - admin_dom_req_error = "admin_org_domain_request_permissions" - admin_member_error = "admin_org_members_permissions" - - if member_access_level == "admin" and basic_dom_req_error in self.errors: - # remove the error messages pertaining to basic permission inputs - del self.errors[basic_dom_req_error] - elif member_access_level == "basic": - # remove the error messages pertaining to admin permission inputs - if admin_dom_req_error in self.errors: - del self.errors[admin_dom_req_error] - if admin_member_error in self.errors: - del self.errors[admin_member_error] - return cleaned_data From a9923f4cc951022ac5f4068bf6d9ae106c7f9fd3 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:09:01 -0700 Subject: [PATCH 096/231] Update portfolio.py --- src/registrar/forms/portfolio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 935c7c019..5e3a7b324 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -273,7 +273,6 @@ class NewMemberForm(forms.ModelForm): del self.errors[admin_member_error] return cleaned_data - class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( From d84c120f8aaeede642c989d67ed4d1612cf5a73a Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 18 Dec 2024 13:16:46 -0600 Subject: [PATCH 097/231] remove duplicate sentence from senior official page --- src/registrar/templates/includes/senior_official.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/templates/includes/senior_official.html b/src/registrar/templates/includes/senior_official.html index 0302bc71f..19b443fcd 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -26,7 +26,6 @@ {% elif not form.full_name.value and not form.title.value and not form.email.value %}

    - Your senior official is a person within your organization who can authorize domain requests. We don't have information about your organization's senior official. To suggest an update, email help@get.gov.

    {% else %} From f19ff3cd66e5ea4d9b1ac671c2c69d3c689929c7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:25:21 -0700 Subject: [PATCH 098/231] PR suggestions --- src/registrar/admin.py | 4 -- src/registrar/forms/__init__.py | 1 + src/registrar/forms/portfolio.py | 43 ++++++------------- .../models/user_portfolio_permission.py | 7 ++- .../views/utility/permission_views.py | 1 + 5 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 72eff6d79..4465b7098 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4004,10 +4004,6 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") - # Normally you have to first enable the org feature then navigate to an org before you see these. - # Lets just auto-populate it on page load to make development easier. - extra_context["organization_members"] = flag_is_active_for_user(request.user, "organization_members") - extra_context["organization_requests"] = flag_is_active_for_user(request.user, "organization_requests") return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 033e955ed..121e2b3f7 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -13,4 +13,5 @@ from .domain import ( ) from .portfolio import ( PortfolioOrgAddressForm, + PortfolioMemberForm, ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5e3a7b324..ecd21b8ee 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -273,7 +273,11 @@ class NewMemberForm(forms.ModelForm): del self.errors[admin_member_error] return cleaned_data + class BasePortfolioMemberForm(forms.Form): + """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" + + # The label for each of these has a red "required" star. We can just embed that here for simplicity. required_star = '*' role = forms.ChoiceField( choices=[ @@ -354,25 +358,11 @@ class BasePortfolioMemberForm(forms.Form): } def clean(self): - """ - Validates form data based on selected role and its required fields. - - Since form fields are dynamically shown/hidden via JavaScript based on role selection, - we only validate fields that are relevant to the selected role: - - organization_admin: ["member_permission_admin", "domain_request_permission_admin"] - - organization_member: ["domain_request_permission_member"] - This ensures users aren't required to fill out hidden fields and maintains - proper validation based on their role selection. - - NOTE: This page uses ROLE_REQUIRED_FIELDS for the aforementioned mapping. - Raises: - ValueError: If ROLE_REQUIRED_FIELDS references a non-existent form field - """ + """Validates form data based on selected role and its required fields.""" cleaned_data = super().clean() role = cleaned_data.get("role") - # Get required fields for the selected role. - # Then validate all required fields for the role. + # Get required fields for the selected role. Then validate all required fields for the role. required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) for field_name in required_fields: # Helpful error for if this breaks @@ -394,15 +384,17 @@ class BasePortfolioMemberForm(forms.Form): self.instance.save() return self.instance + # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: + # map_instance_to_form => called on init to set self.instance. + # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) + # into a dictionary representation for the form to use automatically. + + # map_cleaned_data_to_instance => called on save() to save the instance to the db. + # Takes the self.cleaned_data dict, and converts this dict back to the object. + def map_instance_to_form(self, instance): """ Maps user instance to form fields, handling roles and permissions. - - Determines: - - User's role (admin vs member) - - Domain request permissions (EDIT_REQUESTS, VIEW_ALL_REQUESTS, or "no_access") - - Member management permissions (EDIT_MEMBERS or VIEW_MEMBERS) - Returns form data dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", @@ -462,13 +454,6 @@ class BasePortfolioMemberForm(forms.Form): def map_cleaned_data_to_instance(self, cleaned_data, instance): """ Maps cleaned data to a member instance, setting roles and permissions. - - Additional permissions logic: - - For org admins: Adds domain request and member admin permissions if selected - - For other roles: Adds domain request member permissions if not 'no_access' - - Automatically adds VIEW permissions when EDIT permissions are granted - - Filters out permissions already granted by base role - Args: cleaned_data (dict): Cleaned data containing role and permission choices instance: Instance to update diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index f312f3dd0..25abb6748 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -111,7 +111,12 @@ class UserPortfolioPermission(TimeStampedModel): @classmethod def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True): - """Class method to return a list of permissions based on roles and addtl permissions""" + """Class method to return a list of permissions based on roles and addtl permissions. + Params: + roles => An array of roles + additional_permissions => An array of additional_permissions + get_list => If true, returns a list of perms. If false, returns a set of perms. + """ # Use a set to avoid duplicate permissions portfolio_permissions = set() if roles: diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index c49f2daa1..a3067d3a2 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -1,6 +1,7 @@ """View classes that enforce authorization.""" import abc # abstract base class + from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models.user import User From 0e1d07d59bcadbe81d1c60b527a92f3482ef1718 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:32:47 -0700 Subject: [PATCH 099/231] Update portfolio.py --- src/registrar/forms/portfolio.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ecd21b8ee..6e1e7d43c 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -331,7 +331,9 @@ class BasePortfolioMemberForm(forms.Form): }, ) - # Tracks what form elements are required for a given role choice + # Tracks what form elements are required for a given role choice. + # All of the fields included here have "required=False" by default as they are conditionally required. + # see def clean() for more details. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ "domain_request_permission_admin", From a314649de2e42c209b02e7565ab19f3736443545 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:33:34 -0700 Subject: [PATCH 100/231] Update portfolio.py --- src/registrar/forms/portfolio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 6e1e7d43c..ddfa93bc1 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -387,7 +387,7 @@ class BasePortfolioMemberForm(forms.Form): return self.instance # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: - # map_instance_to_form => called on init to set self.instance. + # map_instance_to_form => called on init to set self.initial. # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) # into a dictionary representation for the form to use automatically. From dec9e3362dc2534b6953b122a46bc599585ef87e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:02:27 -0700 Subject: [PATCH 101/231] Condense logic and change names --- src/registrar/forms/portfolio.py | 89 +++++++++++++++----------------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ddfa93bc1..ce164607e 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -281,6 +281,7 @@ class BasePortfolioMemberForm(forms.Form): required_star = '*' role = forms.ChoiceField( choices=[ + # Uses .value because the choice has a different label (on /admin) (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), ], @@ -345,10 +346,12 @@ class BasePortfolioMemberForm(forms.Form): } def __init__(self, *args, instance=None, **kwargs): + """Initialize self.instance, self.initial, and descriptions under each radio button. + Uses map_instance_to_initial to set the initial dictionary.""" super().__init__(*args, **kwargs) if instance: self.instance = instance - self.initial = self.map_instance_to_form(self.instance) + self.initial = self.map_instance_to_initial(self.instance) # Adds a

    description beneath each role option self.fields["role"].descriptions = { "organization_admin": UserPortfolioRoleChoices.get_role_description( @@ -359,6 +362,14 @@ class BasePortfolioMemberForm(forms.Form): ), } + def save(self): + """Saves self.instance by grabbing data from self.cleaned_data. + Uses map_cleaned_data_to_instance. + """ + self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) + self.instance.save() + return self.instance + def clean(self): """Validates form data based on selected role and its required fields.""" cleaned_data = super().clean() @@ -380,23 +391,17 @@ class BasePortfolioMemberForm(forms.Form): return cleaned_data - def save(self): - """Save the form data to the instance""" - self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) - self.instance.save() - return self.instance - - # Explanation of how map_instance_to_form / map_cleaned_data_to_instance work: - # map_instance_to_form => called on init to set self.initial. + # Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work: + # map_instance_to_initial => called on init to set self.initial. # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) # into a dictionary representation for the form to use automatically. # map_cleaned_data_to_instance => called on save() to save the instance to the db. # Takes the self.cleaned_data dict, and converts this dict back to the object. - def map_instance_to_form(self, instance): + def map_instance_to_initial(self, instance): """ - Maps user instance to form fields, handling roles and permissions. + Maps self.instance to self.initial, handling roles and permissions. Returns form data dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", @@ -405,57 +410,47 @@ class BasePortfolioMemberForm(forms.Form): "domain_request_permission_member": permission level if member } """ - if not instance: - return {} - # Function variables form_data = {} perms = UserPortfolioPermission.get_portfolio_permissions( instance.roles, instance.additional_permissions, get_list=False ) - # Explanation of this logic pattern: we can only display one item in the list at a time. - # But how do we determine what is most important to display in a list? Order-based hierarchy. - # Example: print(instance.roles) => (output) ["organization_admin", "organization_member"] - # If we can only pick one item in this list, we should pick organization_admin. - - # Get role - roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - role = next((role for role in roles if role in instance.roles), None) - is_admin = role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN - - # Get domain request permission level - # First we get permissions we expect to display (ordered hierarchically). - # Then we check if this item exists in the list and return the first instance of it. - domain_permissions = [ + # Get the available options for roles, domains, and member. + roles = [ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + ] + domain_perms = [ UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] - domain_request_permission = next((perm for perm in domain_permissions if perm in perms), None) + member_perms = [ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] - # Get member permission level. - member_permissions = [UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS] - member_permission = next((perm for perm in member_permissions if perm in perms), None) - - # Build form data based on role. - form_data = { - "role": role, - "member_permission_admin": getattr(member_permission, "value", None) if is_admin else None, - "domain_request_permission_admin": getattr(domain_request_permission, "value", None) if is_admin else None, - "domain_request_permission_member": ( - getattr(domain_request_permission, "value", None) if not is_admin else None - ), - } - - # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. - if domain_request_permission is None and not is_admin: - form_data["domain_request_permission_member"] = "no_access" + # Build form data based on role (which options are available). + # Get which one should be "selected" by assuming that EDIT takes precedence over view, + # and ADMIN takes precedence over MEMBER. + selected_role = next((role for role in roles if role in instance.roles), None) + form_data = {"role": selected_role} + is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN + if is_admin: + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) + selected_member_permission = next((perm for perm in member_perms if perm in perms), None) + form_data["domain_request_permission_admin"] = selected_domain_permission + form_data["member_permission_admin"] = selected_member_permission + else: + # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") + form_data["domain_request_permission_member"] = selected_domain_permission return form_data def map_cleaned_data_to_instance(self, cleaned_data, instance): """ - Maps cleaned data to a member instance, setting roles and permissions. + Maps self.cleaned_data to self.instance, setting roles and permissions. Args: cleaned_data (dict): Cleaned data containing role and permission choices instance: Instance to update From 57c7c6f709051d41ed392124d732d670d06257fb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:56:29 -0700 Subject: [PATCH 102/231] Revert "revert csv-export" This reverts commit b0d1bc26da85e0e6ac8bae4746d529089a6b61de. --- src/registrar/tests/test_reports.py | 140 ++++---- src/registrar/utility/csv_export.py | 523 ++++++++++++++++++++++------ 2 files changed, 479 insertions(+), 184 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index f91c5b299..cafaff7b1 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,32 +251,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," - "SO email,Security contact email,Domain managers,Invited domain managers\n" - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," - "meoward@rocks.com,\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," - ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," - "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," - "security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency," + "Organization name,City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," "meoward@rocks.com,squeaker@rocks.com\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "squeaker@rocks.com\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal," + "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -312,20 +315,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," - '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," + "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" - "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" - "zdomain12.govInterstateReady(blank)\n" + "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" + "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,7 +611,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" - self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -646,7 +645,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -683,7 +681,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -721,10 +718,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) - @less_console_noise_decorator + # @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -766,35 +762,34 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() + expected_content = ( # Header - "Domain request,Status,Domain type,Federal type," - "Federal agency,Organization name,Election office,City,State/territory," - "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," - "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," - "SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," + "City,State/territory,Region,Creator first name,Creator last name,Creator email," + "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," + "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," - "testy@town.com," - "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' - 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' - "CISA-last-name " - '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' - 'testy2@town.com"' - ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com" - ",cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com," + "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," + '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' + 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' + 'Testy Tester testy2@town.com",' + 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," + "Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," + "Testy Tester testy2@town.com," + "cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -862,7 +857,6 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user - self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index a03e51de5..97feae20c 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -414,8 +414,11 @@ class MemberExport(BaseExport): ) .values(*shared_columns) ) - - return convert_queryset_to_dict(permissions.union(invitations), is_model=False) + # Adding a order_by increases output predictability. + # Doesn't matter as much for normal use, but makes tests easier. + # We should also just be ordering by default anyway. + members = permissions.union(invitations).order_by("email_display") + return convert_queryset_to_dict(members, is_model=False) @classmethod def get_invited_by_query(cls, object_id_query): @@ -525,6 +528,115 @@ class DomainExport(BaseExport): # Return the model class that this export handles return DomainInformation + @classmethod + def get_computed_fields(cls, **kwargs): + """ + Get a dict of computed fields. + """ + # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. + # This is for performance purposes. Since we are working with dictionary values and not + # model objects as we export data, trying to reinstate model objects in order to grab @property + # values negatively impacts performance. Therefore, we will follow best practice and use annotations + return { + "converted_generic_org_type": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + output_field=CharField(), + ), + "converted_federal_agency": Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__agency"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__agency"), + output_field=CharField(), + ), + "converted_federal_type": Case( + # When portfolio is present, use its value instead + # NOTE: this is an @Property funciton in portfolio. + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_type"), + output_field=CharField(), + ), + "converted_organization_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_name")), + # Otherwise, return the natively assigned value + default=F("organization_name"), + output_field=CharField(), + ), + "converted_city": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__city")), + # Otherwise, return the natively assigned value + default=F("city"), + output_field=CharField(), + ), + "converted_state_territory": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__state_territory")), + # Otherwise, return the natively assigned value + default=F("state_territory"), + output_field=CharField(), + ), + "converted_so_email": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__email"), + output_field=CharField(), + ), + "converted_senior_official_last_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__last_name"), + output_field=CharField(), + ), + "converted_senior_official_first_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__first_name"), + output_field=CharField(), + ), + "converted_senior_official_title": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__title"), + output_field=CharField(), + ), + "converted_so_name": Case( + # When portfolio is present, use that senior official instead + When( + Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), + then=Concat( + Coalesce(F("portfolio__senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("portfolio__senior_official__last_name"), Value("")), + output_field=CharField(), + ), + ), + # Otherwise, return the natively assigned senior official + default=Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + output_field=CharField(), + ), + } + @classmethod def update_queryset(cls, queryset, **kwargs): """ @@ -614,10 +726,10 @@ class DomainExport(BaseExport): if first_ready_on is None: first_ready_on = "(blank)" - # organization_type has generic_org_type AND is_election - domain_org_type = model.get("organization_type") + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("federal_type") + domain_federal_type = model.get("converted_federal_type") human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) domain_type = human_readable_domain_org_type if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: @@ -640,12 +752,12 @@ class DomainExport(BaseExport): "First ready on": first_ready_on, "Expiration date": expiration_date, "Domain type": domain_type, - "Agency": model.get("federal_agency__agency"), - "Organization name": model.get("organization_name"), - "City": model.get("city"), - "State": model.get("state_territory"), - "SO": model.get("so_name"), - "SO email": model.get("senior_official__email"), + "Agency": model.get("converted_federal_agency"), + "Organization name": model.get("converted_organization_name"), + "City": model.get("converted_city"), + "State": model.get("converted_state_territory"), + "SO": model.get("converted_so_name"), + "SO email": model.get("converted_so_email"), "Security contact email": security_contact_email, "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), @@ -654,8 +766,23 @@ class DomainExport(BaseExport): } row = [FIELDS.get(column, "") for column in columns] + return row + def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): + """Returns a list of Domain Requests that has been filtered by the given organization value.""" + + annotated_queryset = domain_infos_to_filter.annotate( + converted_generic_org_type=Case( + # Recreate the logic of the converted_generic_org_type property + # here in annotations + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + default=F("generic_org_type"), + output_field=CharField(), + ) + ) + return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) + @classmethod def get_sliced_domains(cls, filter_condition): """Get filtered domains counts sliced by org type and election office. @@ -663,23 +790,51 @@ class DomainExport(BaseExport): when a domain has more that one manager. """ - domains = DomainInformation.objects.all().filter(**filter_condition).distinct() - domains_count = domains.count() - federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() - interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count() - state_or_territory = ( - domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() + domain_informations = DomainInformation.objects.all().filter(**filter_condition).distinct() + domains_count = domain_informations.count() + federal = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.FEDERAL) + .distinct() + .count() + ) + interstate = cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.INTERSTATE + ).count() + state_or_territory = ( + cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY + ) + .distinct() + .count() + ) + tribal = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.TRIBAL) + .distinct() + .count() + ) + county = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.COUNTY) + .distinct() + .count() + ) + city = ( + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.CITY) + .distinct() + .count() ) - tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() - county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() - city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count() special_district = ( - domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() + cls.get_filtered_domain_infos_by_org( + domain_informations, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT + ) + .distinct() + .count() ) school_district = ( - domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + cls.get_filtered_domain_infos_by_org(domain_informations, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) + .distinct() + .count() ) - election_board = domains.filter(is_election_board=True).distinct().count() + election_board = domain_informations.filter(is_election_board=True).distinct().count() return [ domains_count, @@ -706,6 +861,7 @@ class DomainDataType(DomainExport): """ Overrides the columns for CSV export specific to DomainExport. """ + return [ "Domain name", "Status", @@ -723,6 +879,13 @@ class DomainDataType(DomainExport): "Invited domain managers", ] + @classmethod + def get_annotations_for_sort(cls): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -730,9 +893,9 @@ class DomainDataType(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -773,20 +936,6 @@ class DomainDataType(DomainExport): """ return ["domain__permissions"] - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -892,7 +1041,7 @@ class DomainRequestsDataType: cls.safe_get(getattr(request, "region_field", None)), request.status, cls.safe_get(getattr(request, "election_office", None)), - request.federal_type, + request.converted_federal_type, cls.safe_get(getattr(request, "domain_type", None)), cls.safe_get(getattr(request, "additional_details", None)), cls.safe_get(getattr(request, "creator_approved_domains_count", None)), @@ -943,6 +1092,13 @@ class DomainDataFull(DomainExport): "Security contact email", ] + @classmethod + def get_annotations_for_sort(cls, delimiter=", "): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -950,9 +1106,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -990,20 +1146,6 @@ class DomainDataFull(DomainExport): ], ) - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -1037,6 +1179,13 @@ class DomainDataFederal(DomainExport): "Security contact email", ] + @classmethod + def get_annotations_for_sort(cls, delimiter=", "): + """ + Get a dict of annotations to make available for sorting. + """ + return cls.get_computed_fields() + @classmethod def get_sort_fields(cls): """ @@ -1044,9 +1193,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", + "converted_generic_org_type", + Coalesce("converted_federal_type", Value("ZZZZZ")), + "converted_federal_agency", "domain__name", ] @@ -1085,20 +1234,6 @@ class DomainDataFederal(DomainExport): ], ) - @classmethod - def get_computed_fields(cls, delimiter=", ", **kwargs): - """ - Get a dict of computed fields. - """ - return { - "so_name": Concat( - Coalesce(F("senior_official__first_name"), Value("")), - Value(" "), - Coalesce(F("senior_official__last_name"), Value("")), - output_field=CharField(), - ), - } - @classmethod def get_related_table_fields(cls): """ @@ -1476,24 +1611,180 @@ class DomainRequestExport(BaseExport): # Return the model class that this export handles return DomainRequest + def get_filtered_domain_requests_by_org(domain_requests_to_filter, org_to_filter_by): + """Returns a list of Domain Requests that has been filtered by the given organization value""" + annotated_queryset = domain_requests_to_filter.annotate( + converted_generic_org_type=Case( + # Recreate the logic of the converted_generic_org_type property + # here in annotations + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + default=F("generic_org_type"), + output_field=CharField(), + ) + ) + return annotated_queryset.filter(converted_generic_org_type=org_to_filter_by) + + # return domain_requests_to_filter.filter( + # # Filter based on the generic org value returned by converted_generic_org_type + # id__in=[ + # domainRequest.id + # for domainRequest in domain_requests_to_filter + # if domainRequest.converted_generic_org_type + # and domainRequest.converted_generic_org_type == org_to_filter_by + # ] + # ) + + @classmethod + def get_computed_fields(cls, delimiter=", ", **kwargs): + """ + Get a dict of computed fields. + """ + # NOTE: These computed fields imitate @Property functions in the Domain model and Portfolio model where needed. + # This is for performance purposes. Since we are working with dictionary values and not + # model objects as we export data, trying to reinstate model objects in order to grab @property + # values negatively impacts performance. Therefore, we will follow best practice and use annotations + return { + "converted_generic_org_type": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + output_field=CharField(), + ), + "converted_federal_agency": Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__agency"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__agency"), + output_field=CharField(), + ), + "converted_federal_type": Case( + # When portfolio is present, use its value instead + # NOTE: this is an @Property funciton in portfolio. + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_type"), + output_field=CharField(), + ), + "converted_organization_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_name")), + # Otherwise, return the natively assigned value + default=F("organization_name"), + output_field=CharField(), + ), + "converted_city": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__city")), + # Otherwise, return the natively assigned value + default=F("city"), + output_field=CharField(), + ), + "converted_state_territory": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__state_territory")), + # Otherwise, return the natively assigned value + default=F("state_territory"), + output_field=CharField(), + ), + "converted_so_email": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__email"), + output_field=CharField(), + ), + "converted_senior_official_last_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__last_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__last_name"), + output_field=CharField(), + ), + "converted_senior_official_first_name": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__first_name")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__first_name"), + output_field=CharField(), + ), + "converted_senior_official_title": Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__senior_official__title")), + # Otherwise, return the natively assigned senior official + default=F("senior_official__title"), + output_field=CharField(), + ), + "converted_so_name": Case( + # When portfolio is present, use that senior official instead + When( + Q(portfolio__isnull=False) & Q(portfolio__senior_official__isnull=False), + then=Concat( + Coalesce(F("portfolio__senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("portfolio__senior_official__last_name"), Value("")), + output_field=CharField(), + ), + ), + # Otherwise, return the natively assigned senior official + default=Concat( + Coalesce(F("senior_official__first_name"), Value("")), + Value(" "), + Coalesce(F("senior_official__last_name"), Value("")), + output_field=CharField(), + ), + output_field=CharField(), + ), + } + @classmethod def get_sliced_requests(cls, filter_condition): """Get filtered requests counts sliced by org type and election office.""" requests = DomainRequest.objects.all().filter(**filter_condition).distinct() requests_count = requests.count() - federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() - interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count() - state_or_territory = ( - requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() + federal = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.FEDERAL) + .distinct() + .count() + ) + interstate = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.INTERSTATE) + .distinct() + .count() + ) + state_or_territory = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.STATE_OR_TERRITORY) + .distinct() + .count() + ) + tribal = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.TRIBAL) + .distinct() + .count() + ) + county = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.COUNTY) + .distinct() + .count() + ) + city = ( + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.CITY).distinct().count() ) - tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() - county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() - city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count() special_district = ( - requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SPECIAL_DISTRICT) + .distinct() + .count() ) school_district = ( - requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + cls.get_filtered_domain_requests_by_org(requests, DomainRequest.OrganizationChoices.SCHOOL_DISTRICT) + .distinct() + .count() ) election_board = requests.filter(is_election_board=True).distinct().count() @@ -1517,11 +1808,11 @@ class DomainRequestExport(BaseExport): """ # Handle the federal_type field. Defaults to the wrong format. - federal_type = model.get("federal_type") + federal_type = model.get("converted_federal_type") human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None # Handle the org_type field - org_type = model.get("generic_org_type") or model.get("organization_type") + org_type = model.get("converted_generic_org_type") human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None # Handle the status field. Defaults to the wrong format. @@ -1569,19 +1860,19 @@ class DomainRequestExport(BaseExport): "Other contacts": model.get("all_other_contacts"), "Current websites": model.get("all_current_websites"), # Untouched FK fields - passed into the request dict. - "Federal agency": model.get("federal_agency__agency"), - "SO first name": model.get("senior_official__first_name"), - "SO last name": model.get("senior_official__last_name"), - "SO email": model.get("senior_official__email"), - "SO title/role": model.get("senior_official__title"), + "Federal agency": model.get("converted_federal_agency"), + "SO first name": model.get("converted_senior_official_first_name"), + "SO last name": model.get("converted_senior_official_last_name"), + "SO email": model.get("converted_so_email"), + "SO title/role": model.get("converted_senior_official_title"), "Creator first name": model.get("creator__first_name"), "Creator last name": model.get("creator__last_name"), "Creator email": model.get("creator__email"), "Investigator": model.get("investigator__email"), # Untouched fields - "Organization name": model.get("organization_name"), - "City": model.get("city"), - "State/territory": model.get("state_territory"), + "Organization name": model.get("converted_organization_name"), + "City": model.get("converted_city"), + "State/territory": model.get("converted_state_territory"), "Request purpose": model.get("purpose"), "CISA regional representative": model.get("cisa_representative_email"), "Last submitted date": model.get("last_submitted_date"), @@ -1724,24 +2015,34 @@ class DomainRequestDataFull(DomainRequestExport): """ Get a dict of computed fields. """ - return { - "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), - "creator_active_requests_count": cls.get_creator_active_requests_count_query(), - "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), - "all_alternative_domains": StringAgg("alternative_domains__website", delimiter=delimiter, distinct=True), - # Coerce the other contacts object to "{first_name} {last_name} {email}" - "all_other_contacts": StringAgg( - Concat( - "other_contacts__first_name", - Value(" "), - "other_contacts__last_name", - Value(" "), - "other_contacts__email", + # Get computed fields from the parent class + computed_fields = super().get_computed_fields() + + # Add additional computed fields + computed_fields.update( + { + "creator_approved_domains_count": cls.get_creator_approved_domains_count_query(), + "creator_active_requests_count": cls.get_creator_active_requests_count_query(), + "all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True), + "all_alternative_domains": StringAgg( + "alternative_domains__website", delimiter=delimiter, distinct=True ), - delimiter=delimiter, - distinct=True, - ), - } + # Coerce the other contacts object to "{first_name} {last_name} {email}" + "all_other_contacts": StringAgg( + Concat( + "other_contacts__first_name", + Value(" "), + "other_contacts__last_name", + Value(" "), + "other_contacts__email", + ), + delimiter=delimiter, + distinct=True, + ), + } + ) + + return computed_fields @classmethod def get_related_table_fields(cls): From 8bfeedf6ef43eb1d8df216834a9f0da9804554d1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:09:09 -0700 Subject: [PATCH 103/231] remove converted fields --- src/registrar/utility/csv_export.py | 122 ++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 97feae20c..310bfd8c3 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1077,6 +1077,67 @@ class DomainDataFull(DomainExport): Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def parse_row(cls, columns, model): + """ + Given a set of columns and a model dictionary, generate a new row from cleaned column data. + """ + + status = model.get("domain__state") + human_readable_status = Domain.State.get_state_label(status) + + expiration_date = model.get("domain__expiration_date") + if expiration_date is None: + expiration_date = "(blank)" + + first_ready_on = model.get("domain__first_ready") + if first_ready_on is None: + first_ready_on = "(blank)" + + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") + human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) + domain_federal_type = model.get("converted_federal_type") + human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) + domain_type = human_readable_domain_org_type + if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: + domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" + + security_contact_email = model.get("security_contact_email") + invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + if ( + not security_contact_email + or not isinstance(security_contact_email, str) + or security_contact_email.lower().strip() in invalid_emails + ): + security_contact_email = "(blank)" + + # create a dictionary of fields which can be included in output. + # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": human_readable_status, + "First ready on": first_ready_on, + "Expiration date": expiration_date, + "Domain type": domain_type, + "Agency": model.get("federal_agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("so_email"), + "Security contact email": security_contact_email, + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + + row = [FIELDS.get(column, "") for column in columns] + + return row + @classmethod def get_columns(cls): """ @@ -1164,6 +1225,67 @@ class DomainDataFederal(DomainExport): Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def parse_row(cls, columns, model): + """ + Given a set of columns and a model dictionary, generate a new row from cleaned column data. + """ + + status = model.get("domain__state") + human_readable_status = Domain.State.get_state_label(status) + + expiration_date = model.get("domain__expiration_date") + if expiration_date is None: + expiration_date = "(blank)" + + first_ready_on = model.get("domain__first_ready") + if first_ready_on is None: + first_ready_on = "(blank)" + + # organization_type has organization_type AND is_election + domain_org_type = model.get("converted_generic_org_type") + human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) + domain_federal_type = model.get("converted_federal_type") + human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) + domain_type = human_readable_domain_org_type + if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: + domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" + + security_contact_email = model.get("security_contact_email") + invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + if ( + not security_contact_email + or not isinstance(security_contact_email, str) + or security_contact_email.lower().strip() in invalid_emails + ): + security_contact_email = "(blank)" + + # create a dictionary of fields which can be included in output. + # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": human_readable_status, + "First ready on": first_ready_on, + "Expiration date": expiration_date, + "Domain type": domain_type, + "Agency": model.get("federal_agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("so_email"), + "Security contact email": security_contact_email, + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + + row = [FIELDS.get(column, "") for column in columns] + + return row + @classmethod def get_columns(cls): """ From 0cd504eb781bf47160f4188b77e7e6196246431d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:16:17 -0700 Subject: [PATCH 104/231] Update csv_export.py --- src/registrar/utility/csv_export.py | 133 +++++++--------------------- 1 file changed, 34 insertions(+), 99 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 310bfd8c3..07014f185 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -744,30 +744,41 @@ class DomainExport(BaseExport): ): security_contact_email = "(blank)" + model["status"] = human_readable_status + model["first_ready_on"] = first_ready_on + model["expiration_date"] = expiration_date + model["domain_type"] = domain_type + model["security_contact_email"] = security_contact_email # create a dictionary of fields which can be included in output. # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = cls.get_fields(model) + + row = [FIELDS.get(column, "") for column in columns] + + return row + + # NOTE - this override is temporary. Delete this after we consolidate these @property fields. + @classmethod + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("converted_federal_agency"), "Organization name": model.get("converted_organization_name"), "City": model.get("converted_city"), "State": model.get("converted_state_territory"), "SO": model.get("converted_so_name"), "SO email": model.get("converted_so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): """Returns a list of Domain Requests that has been filtered by the given organization value.""" @@ -1079,64 +1090,26 @@ class DomainDataFull(DomainExport): # NOTE - this override is temporary. Delete this after we consolidate these @property fields. @classmethod - def parse_row(cls, columns, model): - """ - Given a set of columns and a model dictionary, generate a new row from cleaned column data. - """ - - status = model.get("domain__state") - human_readable_status = Domain.State.get_state_label(status) - - expiration_date = model.get("domain__expiration_date") - if expiration_date is None: - expiration_date = "(blank)" - - first_ready_on = model.get("domain__first_ready") - if first_ready_on is None: - first_ready_on = "(blank)" - - # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") - human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("converted_federal_type") - human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) - domain_type = human_readable_domain_org_type - if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: - domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" - - security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} - if ( - not security_contact_email - or not isinstance(security_contact_email, str) - or security_contact_email.lower().strip() in invalid_emails - ): - security_contact_email = "(blank)" - - # create a dictionary of fields which can be included in output. - # "extra_fields" are precomputed fields (generated in the DB or parsed). + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("federal_agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), "SO email": model.get("so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS @classmethod def get_columns(cls): @@ -1227,64 +1200,26 @@ class DomainDataFederal(DomainExport): # NOTE - this override is temporary. Delete this after we consolidate these @property fields. @classmethod - def parse_row(cls, columns, model): - """ - Given a set of columns and a model dictionary, generate a new row from cleaned column data. - """ - - status = model.get("domain__state") - human_readable_status = Domain.State.get_state_label(status) - - expiration_date = model.get("domain__expiration_date") - if expiration_date is None: - expiration_date = "(blank)" - - first_ready_on = model.get("domain__first_ready") - if first_ready_on is None: - first_ready_on = "(blank)" - - # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") - human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) - domain_federal_type = model.get("converted_federal_type") - human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) - domain_type = human_readable_domain_org_type - if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL: - domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" - - security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} - if ( - not security_contact_email - or not isinstance(security_contact_email, str) - or security_contact_email.lower().strip() in invalid_emails - ): - security_contact_email = "(blank)" - - # create a dictionary of fields which can be included in output. - # "extra_fields" are precomputed fields (generated in the DB or parsed). + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), "Agency": model.get("federal_agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), "SO email": model.get("so_email"), - "Security contact email": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS @classmethod def get_columns(cls): From 26fd19ffe80e15c7381ed52802be42da02075880 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:21:06 -0700 Subject: [PATCH 105/231] Update test_reports.py --- src/registrar/tests/test_reports.py | 138 +++++++++++++++------------- 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cafaff7b1..f91c5b299 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,35 +251,32 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency," - "Organization name,City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - "meoward@rocks.com,squeaker@rocks.com\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," - "World War I Centennial Commission,,,, ,,(blank)," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," + "SO email,Security contact email,Domain managers,Invited domain managers\n" + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," "meoward@rocks.com,\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," + ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal," - "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," + "security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "meoward@rocks.com,squeaker@rocks.com\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -315,17 +312,20 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + "City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," + '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," - '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" - "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" - "zdomain12.gov,Interstate,Ready,(blank)\n" + "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" + "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" + "zdomain12.govInterstateReady(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,6 +611,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" + self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -645,6 +646,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -681,6 +683,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -718,9 +721,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.assertEqual(csv_content, expected_content) - # @less_console_noise_decorator + @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -762,34 +766,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() - expected_content = ( # Header - "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," - "City,State/territory,Region,Creator first name,Creator last name,Creator email," - "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," - "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type," + "Federal agency,Organization name,Election office,City,State/territory," + "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," + "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," + "SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," - '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' - 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' - 'Testy Tester testy2@town.com",' - 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," - "Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," - "Testy Tester testy2@town.com," - "cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," - "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," + "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," + "testy@town.com," + "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' + 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' + "CISA-last-name " + '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' + 'testy2@town.com"' + ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com" + ",cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " + "testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -857,6 +862,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user + self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 From 431b0e80cf316558c3bd1eea72aba142ae379f5b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:21:52 -0700 Subject: [PATCH 106/231] Revert "Update test_reports.py" This reverts commit 26fd19ffe80e15c7381ed52802be42da02075880. --- src/registrar/tests/test_reports.py | 140 +++++++++++++--------------- 1 file changed, 67 insertions(+), 73 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index f91c5b299..cafaff7b1 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), + call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), - call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -251,32 +251,35 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect READY domains, # sorted alphabetially by domain name expected_content = ( - "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO," - "SO email,Security contact email,Domain managers,Invited domain managers\n" - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," - "meoward@rocks.com,\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," - ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," - "squeaker@rocks.com\n" - "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,," - "security@mail.gov,,\n" - "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,," + "Domain name,Status,First ready on,Expiration date,Domain type,Agency," + "Organization name,City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," "meoward@rocks.com,squeaker@rocks.com\n" - "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," + "Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' + "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" + "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," + "squeaker@rocks.com\n" + "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "ddomain3.gov,On hold,(blank),2023-11-15,Federal," + "Armed Forces Retirement Home,,,, ,,security@mail.gov,,\n" + "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -312,20 +315,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # We expect only domains associated with the user expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," - "City,State,SO,SO email," - "Security contact email,Domain managers,Invited domain managers\n" - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," - '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' - "woofwardthethird@rocks.com\n" - "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," + "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" + "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" + "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" + "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -587,13 +587,13 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,(blank)\n" - "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,(blank)\n" - "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady(blank)\n" - "zdomain12.govInterstateReady(blank)\n" + "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" + "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" + "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" - "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,(blank),2024-04-02\n" - "xdomain7.gov,FederalArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" + "xdomain7.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -611,7 +611,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" - self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions @@ -646,7 +645,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -683,7 +681,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -721,10 +718,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() - self.assertEqual(csv_content, expected_content) - @less_console_noise_decorator + # @less_console_noise_decorator def test_domain_request_data_full(self): """Tests the full domain request report.""" # Remove "Submitted at" because we can't guess this immutable, dynamically generated test data @@ -766,35 +762,34 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): csv_file.seek(0) # Read the content into a variable csv_content = csv_file.read() + expected_content = ( # Header - "Domain request,Status,Domain type,Federal type," - "Federal agency,Organization name,Election office,City,State/territory," - "Region,Creator first name,Creator last name,Creator email,Creator approved domains count," - "Creator active requests count,Alternative domains,SO first name,SO last name,SO email," - "SO title/role,Request purpose,Request additional details,Other contacts," + "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," + "City,State/territory,Region,Creator first name,Creator last name,Creator email," + "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," + "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," + "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester," - "testy@town.com," - "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - 'city3.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"cheeseville.gov, city1.gov,' - 'igorville.gov",Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,CISA-first-name ' - "CISA-last-name " - '| There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, Testy Tester ' - 'testy2@town.com"' - ',test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com" - ",cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com," - "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester " - "testy2@town.com," + "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" + "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," + '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' + 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' + 'Testy Tester testy2@town.com",' + 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' + "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," + "Tester,testy@town.com," + "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," + "Testy Tester testy2@town.com," + "cisaRep@igorville.gov,city.com,\n" + "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) + # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() @@ -862,7 +857,6 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): # Create a request and add the user to the request request = self.factory.get("/") request.user = self.user - self.maxDiff = None # Add portfolio to session request = GenericTestHelper._mock_user_request_for_factory(request) request.session["portfolio"] = self.portfolio_1 From d2d787c8eaf8562c35a791f2f22825ccaaca15a7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:24:59 -0700 Subject: [PATCH 107/231] Revert relevant tests back to normal --- src/registrar/tests/test_reports.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index cafaff7b1..995782eea 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator From 297be33e645bbf454fe43084099db661912c3286 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:42:44 -0700 Subject: [PATCH 108/231] remove log --- src/registrar/assets/src/js/getgov/portfolio-member-page.js | 1 - src/registrar/forms/portfolio.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 83fee661c..02d927438 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -175,7 +175,6 @@ export function initAddNewMemberPageListeners() { // Initalize the radio for the member pages export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { - console.log("new content 2") let memberForm = document.getElementById("member_form"); let newMemberForm = document.getElementById("add_member_form") if (memberForm) { diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index ce164607e..eaa885a85 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -433,7 +433,8 @@ class BasePortfolioMemberForm(forms.Form): # Build form data based on role (which options are available). # Get which one should be "selected" by assuming that EDIT takes precedence over view, # and ADMIN takes precedence over MEMBER. - selected_role = next((role for role in roles if role in instance.roles), None) + roles = instance.roles or [] + selected_role = next((role for role in roles if role in roles), None) form_data = {"role": selected_role} is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN if is_admin: From 8b72c654b8a3fb5c234b03e3a7bec334664dc8f1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:49:28 -0700 Subject: [PATCH 109/231] Update csv_export.py --- src/registrar/utility/csv_export.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 07014f185..93fcaaf84 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1097,12 +1097,12 @@ class DomainDataFull(DomainExport): "First ready on": model.get("first_ready_on"), "Expiration date": model.get("expiration_date"), "Domain type": model.get("domain_type"), - "Agency": model.get("federal_agency"), + "Agency": model.get("federal_agency__agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), - "SO email": model.get("so_email"), + "SO email": model.get("senior_official__email"), "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), @@ -1207,12 +1207,12 @@ class DomainDataFederal(DomainExport): "First ready on": model.get("first_ready_on"), "Expiration date": model.get("expiration_date"), "Domain type": model.get("domain_type"), - "Agency": model.get("federal_agency"), + "Agency": model.get("federal_agency__agency"), "Organization name": model.get("organization_name"), "City": model.get("city"), "State": model.get("state_territory"), "SO": model.get("so_name"), - "SO email": model.get("so_email"), + "SO email": model.get("senior_official__email"), "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), From fcdc0f0f0fc4f5d319530619260ab9ee5aaf287d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:53:17 -0700 Subject: [PATCH 110/231] Fix different ordering --- src/registrar/utility/csv_export.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 93fcaaf84..4de947594 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1140,9 +1140,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1250,9 +1250,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] From b0cf5df7984800fa1283301f67d61883e58dee1f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:06:47 -0700 Subject: [PATCH 111/231] add zap --- src/zap.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zap.conf b/src/zap.conf index 65468773a..782eaa0e4 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -75,6 +75,7 @@ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/prototype-dns +10038 OUTOFSCOPE http://app:8080/suborganization # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers From fb4ea590667ea0a913b99956f6c3bde44c7916b8 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 18 Dec 2024 17:07:46 -0500 Subject: [PATCH 112/231] member domains submit --- .../js/getgov/table-edit-member-domains.js | 139 +++++++++++++++++- .../src/sass/_theme/_register-form.scss | 11 +- .../assets/src/sass/_theme/_typography.scss | 8 + .../domain_request_status_manage.html | 4 +- .../portfolio_request_review_steps.html | 2 +- .../includes/request_review_steps.html | 6 +- .../includes/request_status_manage.html | 4 +- .../templates/includes/summary_item.html | 2 +- .../portfolio_member_domains_edit.html | 137 +++++++++++------ src/registrar/views/member_domains_json.py | 2 +- src/registrar/views/portfolios.py | 128 ++++++++++++++++ 11 files changed, 377 insertions(+), 66 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js index 95492d46f..567387bd5 100644 --- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -1,5 +1,6 @@ import { BaseTable } from './table-base.js'; +import { hideElement, showElement } from './helpers.js'; /** * EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember @@ -18,8 +19,14 @@ export class EditMemberDomainsTable extends BaseTable { this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly this.addedDomains = []; // list of domains added to member this.removedDomains = []; // list of domains removed from member + this.editModeContainer = document.getElementById('domain-assignments-edit-view'); + this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view'); + this.reviewButton = document.getElementById('review-domain-assignments'); + this.backButton = document.querySelectorAll('.back-to-edit-domain-assignments'); + this.saveButton = document.getElementById('save-domain-assignments'); this.initializeDomainAssignments(); this.initCancelEditDomainAssignmentButton(); + this.initEventListeners(); } getBaseUrl() { return document.getElementById("get_member_domains_json_url"); @@ -55,6 +62,14 @@ export class EditMemberDomainsTable extends BaseTable { getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); // Add checkedDomains to searchParams + let checkedDomains = this.getCheckedDomains(); + // Append updated checkedDomain IDs to searchParams + if (checkedDomains.length > 0) { + searchParams.append("checkedDomainIds", checkedDomains.join(",")); + } + return searchParams; + } + getCheckedDomains() { // Clone the initial domains to avoid mutating them let checkedDomains = [...this.initialDomainAssignments]; // Add IDs from addedDomains that are not already in checkedDomains @@ -70,11 +85,7 @@ export class EditMemberDomainsTable extends BaseTable { checkedDomains.splice(index, 1); } }); - // Append updated checkedDomain IDs to searchParams - if (checkedDomains.length > 0) { - searchParams.append("checkedDomainIds", checkedDomains.join(",")); - } - return searchParams; + return checkedDomains } addRow(dataObject, tbody, customTableOptions) { const domain = dataObject; @@ -217,7 +228,125 @@ export class EditMemberDomainsTable extends BaseTable { } }); } + + updateReadonlyDisplay() { + let totalAssignedDomains = this.getCheckedDomains().length; + + // Create unassigned domains list + const unassignedDomainsList = document.createElement('ul'); + unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.removedDomains.forEach(removedDomain => { + const removedDomainListItem = document.createElement('li'); + removedDomainListItem.textContent = removedDomain.name; // Use textContent for security + unassignedDomainsList.appendChild(removedDomainListItem); + }); + + // Create assigned domains list + const assignedDomainsList = document.createElement('ul'); + assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.addedDomains.forEach(addedDomain => { + const addedDomainListItem = document.createElement('li'); + addedDomainListItem.textContent = addedDomain.name; // Use textContent for security + assignedDomainsList.appendChild(addedDomainListItem); + }); + + // Get the summary container + const domainAssignmentSummary = document.getElementById('domain-assignments-summary'); + // Clear existing content + domainAssignmentSummary.innerHTML = ''; + + // Append unassigned domains section + if (this.removedDomains.length) { + const unassignedHeader = document.createElement('h3'); + unassignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + unassignedHeader.textContent = 'Unassigned domains'; + domainAssignmentSummary.appendChild(unassignedHeader); + domainAssignmentSummary.appendChild(unassignedDomainsList); + } + + // Append assigned domains section + if (this.addedDomains.length) { + const assignedHeader = document.createElement('h3'); + assignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + assignedHeader.textContent = 'Assigned domains'; + domainAssignmentSummary.appendChild(assignedHeader); + domainAssignmentSummary.appendChild(assignedDomainsList); + } + + // Append total assigned domains section + const totalHeader = document.createElement('h3'); + totalHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + totalHeader.textContent = 'Total assigned domains'; + domainAssignmentSummary.appendChild(totalHeader); + const totalCount = document.createElement('p'); + totalCount.classList.add('margin-y-0'); + totalCount.textContent = totalAssignedDomains; + domainAssignmentSummary.appendChild(totalCount); + } + + showReadonlyMode() { + this.updateReadonlyDisplay(); + hideElement(this.editModeContainer); + showElement(this.readonlyModeContainer); + } + + showEditMode() { + hideElement(this.readonlyModeContainer); + showElement(this.editModeContainer); + } + + submitChanges() { + let memberDomainsEditForm = document.getElementById("member-domains-edit-form"); + if (memberDomainsEditForm) { + // Serialize data to send + const addedDomainIds = this.addedDomains.map(domain => domain.id); + const addedDomainsInput = document.createElement('input'); + addedDomainsInput.type = 'hidden'; + addedDomainsInput.name = 'added_domains'; // Backend will use this key to retrieve data + addedDomainsInput.value = JSON.stringify(addedDomainIds); // Stringify the array + + const removedDomainsIds = this.removedDomains.map(domain => domain.id); + const removedDomainsInput = document.createElement('input'); + removedDomainsInput.type = 'hidden'; + removedDomainsInput.name = 'removed_domains'; // Backend will use this key to retrieve data + removedDomainsInput.value = JSON.stringify(removedDomainsIds); // Stringify the array + + // Append input to the form + memberDomainsEditForm.appendChild(addedDomainsInput); + memberDomainsEditForm.appendChild(removedDomainsInput); + + memberDomainsEditForm.submit(); + } + } + + initEventListeners() { + if (this.reviewButton) { + this.reviewButton.addEventListener('click', () => { + this.showReadonlyMode(); + }); + } else { + console.warn('Missing DOM element. Expected element with id review-domain-assignments') + } + + if (this.backButton.length == 2) { + this.backButton.forEach(backbutton => { + backbutton.addEventListener('click', () => { + this.showEditMode(); + }); + }); + } else { + console.warn('Missing one or more DOM element. Expected 2 elements with class back-to-edit-domain-assignments') + } + + if (this.saveButton) { + this.saveButton.addEventListener('click', () => { + this.submitChanges(); + }); + } else { + console.warn('Missing DOM element. Expected element with id save-domain-assignments') + } + } } export function initEditMemberDomainsTable() { diff --git a/src/registrar/assets/src/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss index 41d2980e3..fcc5b5ae6 100644 --- a/src/registrar/assets/src/sass/_theme/_register-form.scss +++ b/src/registrar/assets/src/sass/_theme/_register-form.scss @@ -12,7 +12,7 @@ margin-top: units(1); } -// register-form-review-header is used on the summary page and +// header--body is used on the summary page and // should not be styled like the register form headers .register-form-step h3 { color: color('primary-dark'); @@ -25,15 +25,6 @@ } } -.register-form-review-header { - color: color('primary-dark'); - margin-top: units(2); - margin-bottom: 0; - font-weight: font-weight('semibold'); - // The units mixin can only get us close, so it's between - // hardcoding the value and using in markup - font-size: 16.96px; -} .register-form-step h4 { margin-bottom: 0; diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index 466b6f975..4d89b245d 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -23,6 +23,14 @@ h2 { color: color('primary-darker'); } +.header--body { + margin-top: units(2); + font-weight: font-weight('semibold'); + // The units mixin can only get us close, so it's between + // hardcoding the value and using in markup + font-size: 16.96px; +} + .h4--sm-05 { font-size: size('body', 'sm'); font-weight: normal; diff --git a/src/registrar/templates/includes/domain_request_status_manage.html b/src/registrar/templates/includes/domain_request_status_manage.html index 2a254df4b..382574b31 100644 --- a/src/registrar/templates/includes/domain_request_status_manage.html +++ b/src/registrar/templates/includes/domain_request_status_manage.html @@ -213,7 +213,7 @@ {# We always show this field even if None #} {% if DomainRequest %} -

    CISA Regional Representative

    +

    CISA Regional Representative

      {% if DomainRequest.cisa_representative_first_name %} {{ DomainRequest.get_formatted_cisa_rep_name }} @@ -221,7 +221,7 @@ No {% endif %}
    -

    Anything else

    +

    Anything else

      {% if DomainRequest.anything_else %} {{DomainRequest.anything_else}} diff --git a/src/registrar/templates/includes/portfolio_request_review_steps.html b/src/registrar/templates/includes/portfolio_request_review_steps.html index fcb087090..eecc5005a 100644 --- a/src/registrar/templates/includes/portfolio_request_review_steps.html +++ b/src/registrar/templates/includes/portfolio_request_review_steps.html @@ -46,7 +46,7 @@ {% endwith %} {% if domain_request.alternative_domains.all %} -

      Alternative domains

      +

      Alternative domains

        {% for site in domain_request.alternative_domains.all %}
      • {{ site.website }}
      • diff --git a/src/registrar/templates/includes/request_review_steps.html b/src/registrar/templates/includes/request_review_steps.html index 73b71d536..dada2dffb 100644 --- a/src/registrar/templates/includes/request_review_steps.html +++ b/src/registrar/templates/includes/request_review_steps.html @@ -88,7 +88,7 @@ {% endwith %} {% if domain_request.alternative_domains.all %} -

        Alternative domains

        +

        Alternative domains

          {% for site in domain_request.alternative_domains.all %}
        • {{ site.website }}
        • @@ -132,7 +132,7 @@ {% with title=form_titles|get_item:step %} {% if domain_request.has_additional_details %} {% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %} -

          CISA Regional Representative

          +

          CISA Regional Representative

            {% if domain_request.cisa_representative_first_name %}
          • {{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}
          • @@ -144,7 +144,7 @@ {% endif %}
          -

          Anything else

          +

          Anything else

            {% if domain_request.anything_else %} {{domain_request.anything_else}} diff --git a/src/registrar/templates/includes/request_status_manage.html b/src/registrar/templates/includes/request_status_manage.html index fc2fd8f12..71c6b1321 100644 --- a/src/registrar/templates/includes/request_status_manage.html +++ b/src/registrar/templates/includes/request_status_manage.html @@ -216,7 +216,7 @@ {# We always show this field even if None #} {% if DomainRequest %} -

            CISA Regional Representative

            +

            CISA Regional Representative

              {% if DomainRequest.cisa_representative_first_name %} {{ DomainRequest.get_formatted_cisa_rep_name }} @@ -224,7 +224,7 @@ No {% endif %}
            -

            Anything else

            +

            Anything else

              {% if DomainRequest.anything_else %} {{DomainRequest.anything_else}} diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 15cc0f67f..bbdfc8dee 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -22,7 +22,7 @@ {% endif %} {% if sub_header_text %} -

              {{ sub_header_text }}

              +

              {{ sub_header_text }}

              {% endif %} {% if permissions %} {% include "includes/member_permissions.html" with permissions=value %} diff --git a/src/registrar/templates/portfolio_member_domains_edit.html b/src/registrar/templates/portfolio_member_domains_edit.html index d430e7572..49aff41c8 100644 --- a/src/registrar/templates/portfolio_member_domains_edit.html +++ b/src/registrar/templates/portfolio_member_domains_edit.html @@ -17,53 +17,108 @@ {% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %} {% endif %} -

              Edit domain assignments

              +
              +

              Edit domain assignments

              -

              - A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains. -

              -

              - When you save this form the member will get an email to notify them of any changes. -

              +

              + A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains. +

              +

              + When you save this form the member will get an email to notify them of any changes. +

              - {% include "includes/member_domains_edit_table.html" %} + {% include "includes/member_domains_edit_table.html" %} -
                -
              • - +
                  +
                • + -
                • -
                • - -
                • -
                +
              • +
              • + +
              • +
              +
              + + + +
              {% csrf_token %}
    {% endblock %} diff --git a/src/registrar/views/member_domains_json.py b/src/registrar/views/member_domains_json.py index 125059692..007730166 100644 --- a/src/registrar/views/member_domains_json.py +++ b/src/registrar/views/member_domains_json.py @@ -90,7 +90,7 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View): domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list( "domain_id", flat=True ) - domain_invitations = DomainInvitation.objects.filter(email=email).values_list("domain_id", flat=True) + domain_invitations = DomainInvitation.objects.filter(email=email, status=DomainInvitation.DomainInvitationStatus.INVITED).values_list("domain_id", flat=True) return domain_info_ids.intersection(domain_invitations) else: domain_infos = DomainInformation.objects.filter(portfolio=portfolio) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 90313339b..8482f2b01 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,3 +1,4 @@ +import json import logging from django.conf import settings @@ -9,7 +10,9 @@ from django.contrib import messages from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User +from registrar.models.domain_invitation import DomainInvitation from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.utility.email import EmailSendingError @@ -27,6 +30,7 @@ from registrar.views.utility.permission_views import ( ) from django.views.generic import View from django.views.generic.edit import FormMixin +from django.db import IntegrityError logger = logging.getLogger(__name__) @@ -215,6 +219,58 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V "member": member, }, ) + + def post(self, request, pk): + """ + Handles adding and removing domains for a portfolio member. + """ + added_domains = request.POST.get("added_domains") + removed_domains = request.POST.get("removed_domains") + portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + member = portfolio_permission.user + + try: + added_domain_ids = json.loads(added_domains) if added_domains else [] + except json.JSONDecodeError: + messages.error(request, "Invalid data for added domains.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + try: + removed_domain_ids = json.loads(removed_domains) if removed_domains else [] + except json.JSONDecodeError: + messages.error(request, "Invalid data for removed domains.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + if added_domain_ids or removed_domain_ids: + try: + if added_domain_ids: + # Bulk create UserDomainRole instances for added domains + UserDomainRole.objects.bulk_create( + [ + UserDomainRole(domain_id=domain_id, user=member) + for domain_id in added_domain_ids + ], + ignore_conflicts=True, # Avoid duplicate entries + ) + + if removed_domain_ids: + # Delete UserDomainRole instances for removed domains + UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete() + + messages.success(request, "The domain assignment changes have been saved.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + except IntegrityError: + messages.error(request, "A database error occurred while saving changes.") + return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) + + except Exception as e: + messages.error(request, f"An unexpected error occurred: {str(e)}") + return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) + else: + messages.info(request, "No changes detected.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): @@ -340,6 +396,78 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission "portfolio_invitation": portfolio_invitation, }, ) + + def post(self, request, pk): + """ + Handles adding and removing domains for a portfolio invitee. + + Instead of deleting DomainInvitations, we move their status to CANCELED. + Instead of adding DomainIncitations, we first do a lookup and if the invite exists + we change its status to INVITED, otherwise we do a create. + """ + added_domains = request.POST.get("added_domains") + removed_domains = request.POST.get("removed_domains") + portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) + email = portfolio_invitation.email + + try: + added_domain_ids = json.loads(added_domains) if added_domains else [] + except json.JSONDecodeError: + messages.error(request, "Invalid data for added domains.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + try: + removed_domain_ids = json.loads(removed_domains) if removed_domains else [] + except json.JSONDecodeError: + messages.error(request, "Invalid data for removed domains.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + if added_domain_ids or removed_domain_ids: + try: + if added_domain_ids: + # Get existing invitations for the added domains + existing_invitations = DomainInvitation.objects.filter( + domain_id__in=added_domain_ids, email=email + ) + + # Update existing invitations from CANCELED to INVITED + existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) + + # Determine which domains need new invitations + existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) + new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) + + # Bulk create new invitations for domains without existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=domain_id, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ) + for domain_id in new_domain_ids + ] + ) + + if removed_domain_ids: + # Bulk update invitations for removed domains from INVITED to CANCELED + DomainInvitation.objects.filter( + domain_id__in=removed_domain_ids, email=email, status=DomainInvitation.DomainInvitationStatus.INVITED + ).update(status=DomainInvitation.DomainInvitationStatus.CANCELED) + + messages.success(request, "The domain assignment changes have been saved.") + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + + except IntegrityError: + messages.error(request, "A database error occurred while saving changes.") + return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) + + except Exception as e: + messages.error(request, f"An unexpected error occurred: {str(e)}") + return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) + else: + messages.info(request, "No changes detected.") + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): From 6b2552bdbc3f4ee093dcf9edfa5bd1d428781c30 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:08:05 -0700 Subject: [PATCH 113/231] Revert "add zap" This reverts commit b0cf5df7984800fa1283301f67d61883e58dee1f. --- src/zap.conf | 1 - 1 file changed, 1 deletion(-) diff --git a/src/zap.conf b/src/zap.conf index 782eaa0e4..65468773a 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -75,7 +75,6 @@ 10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/prototype-dns -10038 OUTOFSCOPE http://app:8080/suborganization # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers From 14d6196b4ab10fd62ce84560e146b9639c930a5e Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 18 Dec 2024 18:07:43 -0500 Subject: [PATCH 114/231] lint --- .../tests/test_views_member_domains_json.py | 6 + src/registrar/tests/test_views_portfolio.py | 291 +++++++++++++++++- src/registrar/views/member_domains_json.py | 4 +- src/registrar/views/portfolios.py | 169 +++++----- 4 files changed, 395 insertions(+), 75 deletions(-) diff --git a/src/registrar/tests/test_views_member_domains_json.py b/src/registrar/tests/test_views_member_domains_json.py index c9f1e38cc..45b115842 100644 --- a/src/registrar/tests/test_views_member_domains_json.py +++ b/src/registrar/tests/test_views_member_domains_json.py @@ -94,6 +94,12 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest): DomainInvitation.objects.create( email=cls.invited_member_email, domain=cls.domain2, status=DomainInvitation.DomainInvitationStatus.INVITED ) + DomainInvitation.objects.create( + email=cls.invited_member_email, domain=cls.domain3, status=DomainInvitation.DomainInvitationStatus.CANCELED + ) + DomainInvitation.objects.create( + email=cls.invited_member_email, domain=cls.domain4, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) @classmethod def tearDownClass(cls): diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index de27b7059..0ebb485c9 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -14,6 +14,7 @@ from registrar.models import ( Suborganization, AllowedEmail, ) +from registrar.models.domain_invitation import DomainInvitation from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_group import UserGroup from registrar.models.user_portfolio_permission import UserPortfolioPermission @@ -25,6 +26,7 @@ from django.contrib.sessions.middleware import SessionMiddleware import boto3_mocking # type: ignore from django.test import Client import logging +import json logger = logging.getLogger(__name__) @@ -1927,7 +1929,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest): cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") # Assign permissions to the user making requests - UserPortfolioPermission.objects.create( + cls.portfolio_permission = UserPortfolioPermission.objects.create( user=cls.user, portfolio=cls.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], @@ -2106,10 +2108,15 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): @classmethod def setUpClass(cls): super().setUpClass() + cls.url = reverse("member-domains-edit", kwargs={"pk": cls.portfolio_permission.pk}) + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) @classmethod def tearDownClass(cls): super().tearDownClass() + UserDomainRole.objects.all().delete() + Domain.objects.all().delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -2162,15 +2169,132 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): # Make sure the response is not found self.assertEqual(response.status_code, 404) + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_added_domains(self): + """Test that domains can be successfully added.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([1, 2, 3]), # Mock domain IDs + } + response = self.client.post(self.url, data) + + # Check that the UserDomainRole objects were created + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 3) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_removed_domains(self): + """Test that domains can be successfully removed.""" + self.client.force_login(self.user) + + # Create some UserDomainRole objects + domains = [1, 2, 3] + UserDomainRole.objects.bulk_create([UserDomainRole(domain_id=domain, user=self.user) for domain in domains]) + + data = { + "removed_domains": json.dumps([1, 2]), + } + response = self.client.post(self.url, data) + + # Check that the UserDomainRole objects were deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserDomainRole.objects.filter(domain_id=3, user=self.user).count(), 1) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + UserDomainRole.objects.all().delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_added_domains_data(self): + """Test that an error is returned for invalid added domains data.""" + self.client.force_login(self.user) + + data = { + "added_domains": "json-statham", + } + response = self.client.post(self.url, data) + + # Check that no UserDomainRole objects were created + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Invalid data for added domains.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_removed_domains_data(self): + """Test that an error is returned for invalid removed domains data.""" + self.client.force_login(self.user) + + data = { + "removed_domains": "not-a-json", + } + response = self.client.post(self.url, data) + + # Check that no UserDomainRole objects were deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Invalid data for removed domains.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_no_changes(self): + """Test that no changes message is displayed when no changes are made.""" + self.client.force_login(self.user) + + response = self.client.post(self.url, {}) + + # Check that no UserDomainRole objects were created or deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an info message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "No changes detected.") + class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView): @classmethod def setUpClass(cls): super().setUpClass() + cls.url = reverse("invitedmember-domains-edit", kwargs={"pk": cls.invitation.pk}) + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) @classmethod def tearDownClass(cls): super().tearDownClass() + Domain.objects.all().delete() + + def tearDown(self): + return super().tearDown() + DomainInvitation.objects.all().delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -2222,6 +2346,171 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain # Make sure the response is not found self.assertEqual(response.status_code, 404) + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_added_domains(self): + """Test adding new domains successfully.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([1, 2, 3]), # Mock domain IDs + } + response = self.client.post(self.url, data) + + # Check that the DomainInvitation objects were created + self.assertEqual( + DomainInvitation.objects.filter( + email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ).count(), + 3, + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_existing_and_new_added_domains(self): + """Test updating existing and adding new invitations.""" + self.client.force_login(self.user) + + # Create existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.CANCELED + ), + DomainInvitation( + domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + ] + ) + + data = { + "added_domains": json.dumps([1, 2, 3]), + } + response = self.client.post(self.url, data) + + # Check that status for domain_id=1 was updated to INVITED + self.assertEqual( + DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.INVITED, + ) + + # Check that domain_id=3 was created as INVITED + self.assertTrue( + DomainInvitation.objects.filter( + domain_id=3, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ).exists() + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_removed_domains(self): + """Test removing domains successfully.""" + self.client.force_login(self.user) + + # Create existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + DomainInvitation( + domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + ] + ) + + data = { + "removed_domains": json.dumps([1]), + } + response = self.client.post(self.url, data) + + # Check that the status for domain_id=1 was updated to CANCELED + self.assertEqual( + DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.CANCELED, + ) + + # Check that domain_id=2 remains INVITED + self.assertEqual( + DomainInvitation.objects.get(domain_id=2, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.INVITED, + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_added_domains_data(self): + """Test handling of invalid JSON for added domains.""" + self.client.force_login(self.user) + + data = { + "added_domains": "not-a-json", + } + response = self.client.post(self.url, data) + + # Check that no DomainInvitation objects were created + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Invalid data for added domains.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_removed_domains_data(self): + """Test handling of invalid JSON for removed domains.""" + self.client.force_login(self.user) + + data = { + "removed_domains": "json-sudeikis", + } + response = self.client.post(self.url, data) + + # Check that no DomainInvitation objects were updated + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Invalid data for removed domains.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_no_changes(self): + """Test the case where no changes are made.""" + self.client.force_login(self.user) + + response = self.client.post(self.url, {}) + + # Check that no DomainInvitation objects were created or updated + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an info message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "No changes detected.") + class TestRequestingEntity(WebTest): """The requesting entity page is a domain request form that only exists diff --git a/src/registrar/views/member_domains_json.py b/src/registrar/views/member_domains_json.py index 007730166..3d24336bb 100644 --- a/src/registrar/views/member_domains_json.py +++ b/src/registrar/views/member_domains_json.py @@ -90,7 +90,9 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View): domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list( "domain_id", flat=True ) - domain_invitations = DomainInvitation.objects.filter(email=email, status=DomainInvitation.DomainInvitationStatus.INVITED).values_list("domain_id", flat=True) + domain_invitations = DomainInvitation.objects.filter( + email=email, status=DomainInvitation.DomainInvitationStatus.INVITED + ).values_list("domain_id", flat=True) return domain_info_ids.intersection(domain_invitations) else: domain_infos = DomainInformation.objects.filter(portfolio=portfolio) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 8482f2b01..043d1e4a3 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -219,7 +219,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V "member": member, }, ) - + def post(self, request, pk): """ Handles adding and removing domains for a portfolio member. @@ -229,41 +229,23 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) member = portfolio_permission.user - try: - added_domain_ids = json.loads(added_domains) if added_domains else [] - except json.JSONDecodeError: - messages.error(request, "Invalid data for added domains.") + added_domain_ids = self._parse_domain_ids(added_domains, "added domains") + if added_domain_ids is None: return redirect(reverse("member-domains", kwargs={"pk": pk})) - try: - removed_domain_ids = json.loads(removed_domains) if removed_domains else [] - except json.JSONDecodeError: - messages.error(request, "Invalid data for removed domains.") + removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains") + if removed_domain_ids is None: return redirect(reverse("member-domains", kwargs={"pk": pk})) if added_domain_ids or removed_domain_ids: try: - if added_domain_ids: - # Bulk create UserDomainRole instances for added domains - UserDomainRole.objects.bulk_create( - [ - UserDomainRole(domain_id=domain_id, user=member) - for domain_id in added_domain_ids - ], - ignore_conflicts=True, # Avoid duplicate entries - ) - - if removed_domain_ids: - # Delete UserDomainRole instances for removed domains - UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete() - + self._process_added_domains(added_domain_ids, member) + self._process_removed_domains(removed_domain_ids, member) messages.success(request, "The domain assignment changes have been saved.") return redirect(reverse("member-domains", kwargs={"pk": pk})) - except IntegrityError: messages.error(request, "A database error occurred while saving changes.") return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) - except Exception as e: messages.error(request, f"An unexpected error occurred: {str(e)}") return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) @@ -271,6 +253,34 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V messages.info(request, "No changes detected.") return redirect(reverse("member-domains", kwargs={"pk": pk})) + def _parse_domain_ids(self, domain_data, domain_type): + """ + Parses the domain IDs from the request and handles JSON errors. + """ + try: + return json.loads(domain_data) if domain_data else [] + except json.JSONDecodeError: + messages.error(self.request, f"Invalid data for {domain_type}.") + return None + + def _process_added_domains(self, added_domain_ids, member): + """ + Processes added domains by bulk creating UserDomainRole instances. + """ + if added_domain_ids: + # Bulk create UserDomainRole instances for added domains + UserDomainRole.objects.bulk_create( + [UserDomainRole(domain_id=domain_id, user=member) for domain_id in added_domain_ids], + ignore_conflicts=True, # Avoid duplicate entries + ) + + def _process_removed_domains(self, removed_domain_ids, member): + """ + Processes removed domains by deleting corresponding UserDomainRole instances. + """ + if removed_domain_ids: + # Delete UserDomainRole instances for removed domains + UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete() class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): @@ -396,72 +406,33 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission "portfolio_invitation": portfolio_invitation, }, ) - + def post(self, request, pk): """ Handles adding and removing domains for a portfolio invitee. - - Instead of deleting DomainInvitations, we move their status to CANCELED. - Instead of adding DomainIncitations, we first do a lookup and if the invite exists - we change its status to INVITED, otherwise we do a create. """ added_domains = request.POST.get("added_domains") removed_domains = request.POST.get("removed_domains") portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) email = portfolio_invitation.email - try: - added_domain_ids = json.loads(added_domains) if added_domains else [] - except json.JSONDecodeError: - messages.error(request, "Invalid data for added domains.") - return redirect(reverse("member-domains", kwargs={"pk": pk})) + added_domain_ids = self._parse_domain_ids(added_domains, "added domains") + if added_domain_ids is None: + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) - try: - removed_domain_ids = json.loads(removed_domains) if removed_domains else [] - except json.JSONDecodeError: - messages.error(request, "Invalid data for removed domains.") - return redirect(reverse("member-domains", kwargs={"pk": pk})) + removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains") + if removed_domain_ids is None: + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) if added_domain_ids or removed_domain_ids: try: - if added_domain_ids: - # Get existing invitations for the added domains - existing_invitations = DomainInvitation.objects.filter( - domain_id__in=added_domain_ids, email=email - ) - - # Update existing invitations from CANCELED to INVITED - existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) - - # Determine which domains need new invitations - existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) - new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) - - # Bulk create new invitations for domains without existing invitations - DomainInvitation.objects.bulk_create( - [ - DomainInvitation( - domain_id=domain_id, - email=email, - status=DomainInvitation.DomainInvitationStatus.INVITED, - ) - for domain_id in new_domain_ids - ] - ) - - if removed_domain_ids: - # Bulk update invitations for removed domains from INVITED to CANCELED - DomainInvitation.objects.filter( - domain_id__in=removed_domain_ids, email=email, status=DomainInvitation.DomainInvitationStatus.INVITED - ).update(status=DomainInvitation.DomainInvitationStatus.CANCELED) - + self._process_added_domains(added_domain_ids, email) + self._process_removed_domains(removed_domain_ids, email) messages.success(request, "The domain assignment changes have been saved.") return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) - except IntegrityError: messages.error(request, "A database error occurred while saving changes.") return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) - except Exception as e: messages.error(request, f"An unexpected error occurred: {str(e)}") return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) @@ -469,6 +440,58 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission messages.info(request, "No changes detected.") return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + def _parse_domain_ids(self, domain_data, domain_type): + """ + Parses the domain IDs from the request and handles JSON errors. + """ + try: + return json.loads(domain_data) if domain_data else [] + except json.JSONDecodeError: + messages.error(self.request, f"Invalid data for {domain_type}.") + return None + + def _process_added_domains(self, added_domain_ids, email): + """ + Processes added domain invitations by updating existing invitations + or creating new ones. + """ + if not added_domain_ids: + return + + # Update existing invitations from CANCELED to INVITED + existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email) + existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) + + # Determine which domains need new invitations + existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) + new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) + + # Bulk create new invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=domain_id, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ) + for domain_id in new_domain_ids + ] + ) + + def _process_removed_domains(self, removed_domain_ids, email): + """ + Processes removed domain invitations by updating their status to CANCELED. + """ + if not removed_domain_ids: + return + + # Update invitations from INVITED to CANCELED + DomainInvitation.objects.filter( + domain_id__in=removed_domain_ids, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ).update(status=DomainInvitation.DomainInvitationStatus.CANCELED) + class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): """Some users have access to the underlying portfolio, but not any domains. From 4a73133cb7836883785b7ac46ea64488203f5fb9 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 18 Dec 2024 18:25:34 -0500 Subject: [PATCH 115/231] test setup revision --- .../tests/test_views_member_domains_json.py | 3 ++- src/registrar/tests/test_views_portfolio.py | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/registrar/tests/test_views_member_domains_json.py b/src/registrar/tests/test_views_member_domains_json.py index 45b115842..091ad6151 100644 --- a/src/registrar/tests/test_views_member_domains_json.py +++ b/src/registrar/tests/test_views_member_domains_json.py @@ -144,7 +144,8 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) def test_get_portfolio_invitedmember_domains_json_authenticated(self): - """Test that portfolio invitedmember's domains are returned properly for an authenticated user.""" + """Test that portfolio invitedmember's domains are returned properly for an authenticated user. + CANCELED and RETRIEVED invites should be ignored.""" response = self.app.get( reverse("get_member_domains_json"), params={"portfolio": self.portfolio.id, "email": self.invited_member_email, "member_only": "true"}, diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 0ebb485c9..8a1171baf 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2109,12 +2109,18 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): def setUpClass(cls): super().setUpClass() cls.url = reverse("member-domains-edit", kwargs={"pk": cls.portfolio_permission.pk}) - names = ["1.gov", "2.gov", "3.gov"] - Domain.objects.bulk_create([Domain(name=name) for name in names]) @classmethod def tearDownClass(cls): super().tearDownClass() + + def setUp(self): + super().setUp() + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) + + def tearDown(self): + super().tearDown() UserDomainRole.objects.all().delete() Domain.objects.all().delete() @@ -2284,17 +2290,19 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain def setUpClass(cls): super().setUpClass() cls.url = reverse("invitedmember-domains-edit", kwargs={"pk": cls.invitation.pk}) - names = ["1.gov", "2.gov", "3.gov"] - Domain.objects.bulk_create([Domain(name=name) for name in names]) @classmethod def tearDownClass(cls): super().tearDownClass() - Domain.objects.all().delete() - + + def setUp(self): + super().setUp() + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) + def tearDown(self): - return super().tearDown() - DomainInvitation.objects.all().delete() + super().tearDown() + Domain.objects.all().delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) From 0cb2ddb468a4b6785a4f618d082674b03274dbdd Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 18 Dec 2024 18:32:47 -0500 Subject: [PATCH 116/231] teak test setup --- src/registrar/tests/test_views_portfolio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 8a1171baf..18ceb34a6 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2303,6 +2303,7 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain def tearDown(self): super().tearDown() Domain.objects.all().delete() + DomainInvitation.objects.all().delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) From ff769df1bdbe56fd32f3574b8835aefe62b11b6e Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 18 Dec 2024 18:34:28 -0500 Subject: [PATCH 117/231] lint --- src/registrar/tests/test_views_portfolio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 18ceb34a6..a60c7adee 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2294,12 +2294,12 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain @classmethod def tearDownClass(cls): super().tearDownClass() - + def setUp(self): super().setUp() names = ["1.gov", "2.gov", "3.gov"] Domain.objects.bulk_create([Domain(name=name) for name in names]) - + def tearDown(self): super().tearDown() Domain.objects.all().delete() From f9e28871f406a801ebddf49aa5e387d5198c6982 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 18 Dec 2024 16:53:25 -0700 Subject: [PATCH 118/231] Fix spacing (code housekeeping) --- src/registrar/templates/401.html | 2 +- src/registrar/templates/403.html | 2 +- src/registrar/templates/404.html | 2 +- src/registrar/templates/500.html | 2 +- src/registrar/templates/home.html | 2 +- src/registrar/templates/portfolio_base.html | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index d7c7f83ae..20ca0420e 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index 999d5f98e..ef910a191 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Forbidden | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index 471575558..024c2803b 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Page not found | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index a0663816b..95c17e069 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Server error | " %}{% endblock %} {% block content %} -
    +

    diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index b1c3775df..b00c57b5c 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -5,7 +5,7 @@ {% block title %} Home | {% endblock %} {% block content %} -
    +
    {% if user.is_authenticated %} {# the entire logged in page goes here #} diff --git a/src/registrar/templates/portfolio_base.html b/src/registrar/templates/portfolio_base.html index c2a60b7ba..1963d7cca 100644 --- a/src/registrar/templates/portfolio_base.html +++ b/src/registrar/templates/portfolio_base.html @@ -4,7 +4,7 @@
    {% block content %} -
    +
    {% if user.is_authenticated %} {# the entire logged in page goes here #} From 55fbbb355b83cc2c7717caa849a551dea4ecd019 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 18 Dec 2024 16:53:58 -0700 Subject: [PATCH 119/231] changed from desktop: to tablet: --- src/registrar/templates/includes/footer.html | 8 ++++---- src/registrar/templates/includes/header_basic.html | 2 +- src/registrar/templates/includes/header_extended.html | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/registrar/templates/includes/footer.html b/src/registrar/templates/includes/footer.html index 95b25ba66..5fa576ccc 100644 --- a/src/registrar/templates/includes/footer.html +++ b/src/registrar/templates/includes/footer.html @@ -3,7 +3,7 @@

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

    Organization members

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

    Organization domain requests

    {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} - {% input_with_errors form.basic_org_domain_request_permissions %} + {% input_with_errors form.domain_request_permission_member %} {% endwith %}
    From de8d252136abd911dd3d4439f0f086dacc0a9660 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 19 Dec 2024 14:05:06 -0700 Subject: [PATCH 133/231] unit tests --- src/registrar/models/domain_request.py | 2 +- src/registrar/tests/common.py | 16 +++ src/registrar/tests/test_models_requests.py | 142 ++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index d78cd587f..c0d4c7e57 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -810,7 +810,7 @@ class DomainRequest(TimeStampedModel): except Exception as err: logger.error(err) logger.error(f"Can't query an approved domain while attempting {called_from}") - + # Delete the suborg as long as this is the only place it is used self._cleanup_dangling_suborg() diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index e1f4f5a27..1c345b83b 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1034,6 +1034,10 @@ def completed_domain_request( # noqa action_needed_reason=None, portfolio=None, organization_name=None, + sub_organization=None, + requested_suborganization=None, + suborganization_city=None, + suborganization_state_territory=None, ): """A completed domain request.""" if not user: @@ -1098,6 +1102,18 @@ def completed_domain_request( # noqa if portfolio: domain_request_kwargs["portfolio"] = portfolio + if sub_organization: + domain_request_kwargs["sub_organization"] = sub_organization + + if requested_suborganization: + domain_request_kwargs["requested_suborganization"] = requested_suborganization + + if suborganization_city: + domain_request_kwargs["suborganization_city"] = suborganization_city + + if suborganization_state_territory: + domain_request_kwargs["suborganization_state_territory"] = suborganization_state_territory + domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py index da474224c..983a12b3c 100644 --- a/src/registrar/tests/test_models_requests.py +++ b/src/registrar/tests/test_models_requests.py @@ -15,6 +15,7 @@ from registrar.models import ( FederalAgency, AllowedEmail, Portfolio, + Suborganization, ) import boto3_mocking @@ -23,6 +24,8 @@ from registrar.utility.errors import FSMDomainRequestError from .common import ( MockSESClient, + create_user, + create_superuser, less_console_noise, completed_domain_request, set_domain_request_investigators, @@ -1070,3 +1073,142 @@ class TestDomainRequest(TestCase): ) self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type) self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency) + + +class TestDomainRequestSuborganization(TestCase): + """Tests for the suborganization fields on domain requests""" + + def setUp(self): + super().setUp() + self.user = create_user() + self.superuser = create_superuser() + + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() + + @less_console_noise_decorator + def test_approve_creates_requested_suborganization(self): + """Test that approving a domain request with a requested suborganization creates it""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + requested_suborganization="Boom", + suborganization_city="Explody town", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO, + ) + domain_request.investigator = self.superuser + domain_request.save() + + domain_request.approve() + + created_suborg = Suborganization.objects.filter( + name="Boom", + city="Explody town", + state_territory=DomainRequest.StateTerritoryChoices.OHIO, + portfolio=portfolio, + ).first() + + self.assertIsNotNone(created_suborg) + self.assertEqual(domain_request.sub_organization, created_suborg) + + @less_console_noise_decorator + def test_approve_without_requested_suborganization_makes_no_changes(self): + """Test that approving without a requested suborganization doesn't create one""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + ) + domain_request.investigator = self.superuser + domain_request.save() + + initial_suborg_count = Suborganization.objects.count() + domain_request.approve() + + self.assertEqual(Suborganization.objects.count(), initial_suborg_count) + self.assertIsNone(domain_request.sub_organization) + + @less_console_noise_decorator + def test_approve_with_existing_suborganization_makes_no_changes(self): + """Test that approving with an existing suborganization doesn't create a new one""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + existing_suborg = Suborganization.objects.create(name="Existing Division", portfolio=portfolio) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=existing_suborg, + ) + domain_request.investigator = self.superuser + domain_request.save() + + initial_suborg_count = Suborganization.objects.count() + domain_request.approve() + + self.assertEqual(Suborganization.objects.count(), initial_suborg_count) + self.assertEqual(domain_request.sub_organization, existing_suborg) + + @less_console_noise_decorator + def test_cleanup_dangling_suborg_with_single_reference(self): + """Test that a suborganization is deleted when it's only referenced once""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + domain_request.approve() + + # set it back to in review + domain_request.in_review() + domain_request.refresh_from_db() + + # Verify the suborganization was deleted + self.assertFalse(Suborganization.objects.filter(id=suborg.id).exists()) + self.assertIsNone(domain_request.sub_organization) + + @less_console_noise_decorator + def test_cleanup_dangling_suborg_with_multiple_references(self): + """Test that a suborganization is preserved when it has multiple references""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio) + + # Create two domain requests using the same suborganization + domain_request1 = completed_domain_request( + name="test1.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + domain_request2 = completed_domain_request( + name="test2.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + + domain_request1.approve() + domain_request2.approve() + + # set one back to in review + domain_request1.in_review() + domain_request1.refresh_from_db() + + # Verify the suborganization still exists + self.assertTrue(Suborganization.objects.filter(id=suborg.id).exists()) + self.assertEqual(domain_request1.sub_organization, suborg) + self.assertEqual(domain_request2.sub_organization, suborg) From 4e4e2c0d8eba85553883cd8392086db7e9f07f3d Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 19 Dec 2024 16:57:48 -0500 Subject: [PATCH 134/231] Fix background color bug --- src/registrar/assets/src/sass/_theme/_base.scss | 5 +++-- src/registrar/assets/src/sass/_theme/_forms.scss | 4 ++++ src/registrar/templates/portfolio_member_permissions.html | 8 ++++---- src/registrar/templates/portfolio_members_add_new.html | 8 ++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 8d475270b..193b71c58 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -39,7 +39,8 @@ body { padding-top: units(5)!important; } -#wrapper.dashboard--grey-1 { +#wrapper.dashboard--grey-1, +.bg-gray-1 { background-color: color('gray-1'); } @@ -260,4 +261,4 @@ abbr[title] { margin: 0; height: 1.5em; width: 1.5em; -} \ No newline at end of file +} diff --git a/src/registrar/assets/src/sass/_theme/_forms.scss b/src/registrar/assets/src/sass/_theme/_forms.scss index 9158de174..4138c5878 100644 --- a/src/registrar/assets/src/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -78,3 +78,7 @@ legend.float-left-tablet + button.float-right-tablet { .read-only-value { margin-top: units(0); } + +.bg-gray-1 .usa-radio { + background: color('gray-1'); +} diff --git a/src/registrar/templates/portfolio_member_permissions.html b/src/registrar/templates/portfolio_member_permissions.html index 66becaa9e..f0983b4f7 100644 --- a/src/registrar/templates/portfolio_member_permissions.html +++ b/src/registrar/templates/portfolio_member_permissions.html @@ -12,7 +12,7 @@ {% include "includes/form_errors.html" with form=form %} -

    - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.member_permission_admin %} {% endwith %}
    @@ -110,7 +110,7 @@

    Member permissions available for basic-level acccess.

    Organization domain requests

    - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.domain_request_permission_member %} {% endwith %}
    diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 792ef3ce2..3ef822be7 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -17,7 +17,7 @@ {% endblock messages%} -

    - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.member_permission_admin %} {% endwith %}
    @@ -89,7 +89,7 @@

    Member permissions available for basic-level acccess.

    Organization domain requests

    - {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.domain_request_permission_member %} {% endwith %}
    From e1ad261e9c7eb939827254f7a068f18fa705c4ef Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:23:49 -0700 Subject: [PATCH 135/231] Validation logic for /admin --- src/registrar/forms/domain_request_wizard.py | 2 +- src/registrar/models/domain_request.py | 40 ++++++++++++++++++++ src/registrar/models/suborganization.py | 1 - 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index eed0866f3..d84ceeb15 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -90,7 +90,7 @@ class RequestingEntityForm(RegistrarForm): raise ValidationError( "This suborganization already exists. " "Choose a new name, or select it directly if you would like to use it." - ) + ) return name def full_clean(self): diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index c0d4c7e57..ff3af829b 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -672,6 +672,46 @@ class DomainRequest(TimeStampedModel): # Store original values for caching purposes. Used to compare them on save. self._cache_status_and_status_reasons() + def clean(self): + super().clean() + # Validation logic for a suborganization request + if self.is_requesting_new_suborganization(): + # Raise an error if this suborganization already exists + Suborganization = apps.get_model("registrar.Suborganization") + if ( + self.requested_suborganization + and Suborganization.objects.filter( + name__iexact=self.requested_suborganization, + portfolio=self.portfolio, + name__isnull=False, + portfolio__isnull=False, + ).exists() + ): + raise ValidationError( + "This suborganization already exists. " + "Choose a new name, or select it directly if you would like to use it." + ) + elif self.portfolio and not self.sub_organization: + # You cannot create a new suborganization without these fields + required_suborg_fields = { + "requested_suborganization": self.requested_suborganization, + "suborganization_city": self.suborganization_city, + "suborganization_state_territory": self.suborganization_state_territory, + } + + if any(bool(value) for value in required_suborg_fields.values()): + # Find which fields are empty + errors_dict = { + field_name: [f"This field is required when creating a new suborganization."] + for field_name, value in required_suborg_fields.items() + if not value + } + # Adds a validation error to each missing field + raise ValidationError({ + k: ValidationError(v, code='required') + for k, v in errors_dict.items() + }) + def save(self, *args, **kwargs): """Save override for custom properties""" self.sync_organization_type() diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py index 087490244..78689799c 100644 --- a/src/registrar/models/suborganization.py +++ b/src/registrar/models/suborganization.py @@ -1,5 +1,4 @@ from django.db import models - from registrar.models.domain_request import DomainRequest from .utility.time_stamped_model import TimeStampedModel From 1fe97eb54ea712606b64c05b8acba6b3e1d17223 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 19 Dec 2024 19:13:29 -0500 Subject: [PATCH 136/231] a little bit of linting --- src/registrar/admin.py | 17 ++++++++--- src/registrar/forms/portfolio.py | 13 ++++----- src/registrar/models/domain.py | 1 - src/registrar/models/portfolio.py | 3 +- .../models/utility/portfolio_helper.py | 1 + src/registrar/utility/email_invitations.py | 4 +-- src/registrar/views/domain.py | 29 ++++++++++++------- src/registrar/views/portfolios.py | 22 +++++++------- 8 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5c1ab37bf..2707a1dff 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1483,7 +1483,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): extra_context["tabtitle"] = "Portfolio invitations" # Get the filtered values return super().changelist_view(request, extra_context=extra_context) - + def save_model(self, request, obj, form, change): """ Override the save_model method to send an email only on creation of the PortfolioInvitation object. @@ -1494,7 +1494,9 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): requestor = request.user requested_user = User.objects.filter(email=requested_email).first() - permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() + permission_exists = UserPortfolioPermission.objects.filter( + user=requested_user, portfolio=portfolio + ).exists() try: if not requested_user or not permission_exists: send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) @@ -1511,14 +1513,21 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): def _handle_exceptions(self, exception, request, obj): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", obj.email, obj.portfolio, exc_info=True) + logger.warning( + "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", + obj.email, + obj.portfolio, + exc_info=True, + ) messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") elif isinstance(exception, MissingEmailError): messages.error(request, str(exception)) logger.error( - f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. No email exists for the requestor.", + f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. " + f"No email exists for the requestor.", exc_info=True, ) + else: logger.warning("Could not send email invitation (Other Exception)", obj.portfolio, exc_info=True) messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 888340d40..b055985d1 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -12,7 +12,6 @@ from registrar.models import ( DomainInformation, Portfolio, SeniorOfficial, - User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -111,9 +110,9 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data - class BasePortfolioMemberForm(forms.ModelForm): """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" + required_star = '*' role = forms.ChoiceField( choices=[ @@ -180,7 +179,7 @@ class BasePortfolioMemberForm(forms.ModelForm): class Meta: model = None - fields = ["roles", "additional_permissions" ] + fields = ["roles", "additional_permissions"] def __init__(self, *args, **kwargs): """ @@ -242,7 +241,7 @@ class BasePortfolioMemberForm(forms.ModelForm): logger.info(cleaned_data) return cleaned_data - + def map_instance_to_initial(self): """ Maps self.instance to self.initial, handling roles and permissions. @@ -301,7 +300,7 @@ class PortfolioMemberForm(BasePortfolioMemberForm): class Meta: model = UserPortfolioPermission - fields = ["roles", "additional_permissions" ] + fields = ["roles", "additional_permissions"] class PortfolioInvitedMemberForm(BasePortfolioMemberForm): @@ -311,8 +310,7 @@ class PortfolioInvitedMemberForm(BasePortfolioMemberForm): class Meta: model = PortfolioInvitation - fields = ["roles", "additional_permissions" ] - + fields = ["roles", "additional_permissions"] class PortfolioNewMemberForm(BasePortfolioMemberForm): @@ -336,4 +334,3 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): class Meta: model = PortfolioInvitation fields = ["portfolio", "email", "roles", "additional_permissions"] - diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 5252ed605..19e96719f 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -7,7 +7,6 @@ from typing import Optional from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models -from django.urls import reverse from django.utils import timezone from typing import Any from registrar.models.host import Host diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 633f27126..e7730230e 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -10,6 +10,7 @@ import logging logger = logging.getLogger(__name__) + class Portfolio(TimeStampedModel): """ Portfolio is used for organizing domains/domain-requests into @@ -165,7 +166,7 @@ class Portfolio(TimeStampedModel): def get_suborganizations(self): """Returns all suborganizations associated with this portfolio""" return self.portfolio_suborganizations.all() - + def full_clean(self, exclude=None, validate_unique=True): logger.info("portfolio full clean") super().full_clean(exclude, validate_unique) diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 24c1a7810..cde28e4bd 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -8,6 +8,7 @@ import logging logger = logging.getLogger(__name__) + class UserPortfolioRoleChoices(models.TextChoices): """ Roles make it easier for admins to look at diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index c0077c196..a3b93f5d5 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -1,7 +1,5 @@ from django.conf import settings from registrar.models import DomainInvitation -from registrar.models.portfolio_invitation import PortfolioInvitation -from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.utility.errors import ( AlreadyDomainInvitedError, AlreadyDomainManagerError, @@ -9,7 +7,7 @@ from registrar.utility.errors import ( OutsideOrgMemberError, ) from registrar.utility.waffle import flag_is_active_for_user -from registrar.utility.email import send_templated_email, EmailSendingError +from registrar.utility.email import send_templated_email import logging logger = logging.getLogger(__name__) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index f52688e1a..60fb9b7b1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1128,7 +1128,10 @@ class DomainUsersView(DomainBaseView): for portfolio_invitation in portfolio_invitations: logger.info(portfolio_invitation) logger.info(portfolio_invitation.roles) - if portfolio_invitation.roles and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles: + if ( + portfolio_invitation.roles + and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles + ): has_admin_flag = True break # Once we find one match, no need to check further @@ -1210,18 +1213,16 @@ class DomainAddUserView(DomainFormBaseView): # Determine membership in a different organization member_of_a_different_org = ( - (existing_org_permission and existing_org_permission.portfolio != requestor_org) or - (existing_org_invitation and existing_org_invitation.portfolio != requestor_org) - ) + existing_org_permission and existing_org_permission.portfolio != requestor_org + ) or (existing_org_invitation and existing_org_invitation.portfolio != requestor_org) # Determine membership in the same organization - member_of_this_org = ( - (existing_org_permission and existing_org_permission.portfolio == requestor_org) or - (existing_org_invitation and existing_org_invitation.portfolio == requestor_org) + member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == requestor_org) or ( + existing_org_invitation and existing_org_invitation.portfolio == requestor_org ) return member_of_a_different_org, member_of_this_org - + def form_valid(self, form): """Add the specified user to this domain.""" requested_email = form.cleaned_data["email"] @@ -1232,7 +1233,9 @@ class DomainAddUserView(DomainFormBaseView): # Get the requestor's organization requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio - member_of_a_different_org, member_of_this_org = self._get_org_membership(requestor_org, requested_email, requested_user) + member_of_a_different_org, member_of_this_org = self._get_org_membership( + requestor_org, requested_email, requested_user + ) # determine portfolio of the domain (code currently is looking at requestor's portfolio) # if requested_email/user is not member or invited member of this portfolio @@ -1299,7 +1302,12 @@ class DomainAddUserView(DomainFormBaseView): def _handle_exceptions(self, exception, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warning("Could not send email invitation to %s for domain %s (EmailSendingError)", email, self.object, exc_info=True) + logger.warning( + "Could not send email invitation to %s for domain %s (EmailSendingError)", + email, + self.object, + exc_info=True, + ) messages.warning(self.request, "Could not send email invitation.") elif isinstance(exception, OutsideOrgMemberError): logger.warning( @@ -1342,6 +1350,7 @@ class DomainAddUserView(DomainFormBaseView): logger.warning("Could not send email invitation (Other Exception)", portfolio, exc_info=True) messages.warning(self.request, "Could not send email invitation.") + class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): object: DomainInvitation fields = [] diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index a0e21532f..bce668665 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,5 +1,4 @@ import logging -from django.conf import settings from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -299,7 +298,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): "invitation": portfolio_invitation, }, ) - + def post(self, request, pk): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) form = self.form_class(request.POST, instance=portfolio_invitation) @@ -520,7 +519,7 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): self.object = None # No existing PortfolioInvitation instance form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) - + def post(self, request, *args, **kwargs): """Handle POST requests to process form submission.""" self.object = None # For a new invitation, there's no existing model instance @@ -536,16 +535,16 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): return self.form_valid(form) else: return self.form_invalid(form) - + def is_ajax(self): return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" - + def form_invalid(self, form): if self.is_ajax(): return JsonResponse({"is_valid": False}) # Return a JSON response else: return super().form_invalid(form) # Handle non-AJAX requests normally - + def form_valid(self, form): super().form_valid(form) if self.is_ajax(): @@ -577,14 +576,15 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): self._handle_exceptions(e, portfolio, requested_email) return redirect(self.get_success_url()) - def get_success_url(self): - """Redirect to the members page.""" - return reverse("members") - def _handle_exceptions(self, exception, portfolio, email): """Handle exceptions raised during the process.""" if isinstance(exception, EmailSendingError): - logger.warning("Could not sent email invitation to %s for portfolio %s (EmailSendingError)", email, portfolio, exc_info=True) + logger.warning( + "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", + email, + portfolio, + exc_info=True, + ) messages.warning(self.request, "Could not send email invitation.") elif isinstance(exception, MissingEmailError): messages.error(self.request, str(exception)) From 955d0a79ed3b5af89c700292e85acba650846bc9 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 19 Dec 2024 19:51:25 -0500 Subject: [PATCH 137/231] cleanup and comments --- src/registrar/admin.py | 25 ++++++++++++++++++------- src/registrar/forms/portfolio.py | 29 ++++++++++++++++++----------- src/registrar/models/portfolio.py | 7 ------- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 2707a1dff..168c27c01 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1486,7 +1486,11 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): def save_model(self, request, obj, form, change): """ - Override the save_model method to send an email only on creation of the PortfolioInvitation object. + Override the save_model method. + + Only send email on creation of the PortfolioInvitation object. Not on updates. + Emails sent to requested user / email. + When exceptions are raised, return without saving model. """ if not change: # Only send email if this is a new PortfolioInvitation(creation) portfolio = obj.portfolio @@ -1499,19 +1503,23 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): ).exists() try: if not requested_user or not permission_exists: + # if requested user does not exist or permission does not exist, send email send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) messages.success(request, f"{requested_email} has been invited.") - else: - if permission_exists: - messages.warning(request, "User is already a member of this portfolio.") + elif permission_exists: + messages.warning(request, "User is already a member of this portfolio.") except Exception as e: + # when exception is raised, handle and do not save the model self._handle_exceptions(e, request, obj) return # Call the parent save method to save the object super().save_model(request, obj, form, change) def _handle_exceptions(self, exception, request, obj): - """Handle exceptions raised during the process.""" + """Handle exceptions raised during the process. + + Log warnings / errors, and message errors to the user. + """ if isinstance(exception, EmailSendingError): logger.warning( "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", @@ -1534,7 +1542,10 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): def response_add(self, request, obj, post_url_continue=None): """ - Override response_add to handle redirection when exceptions are raised. + Override response_add to handle rendering when exceptions are raised during add model. + + Normal flow on successful save_model on add is to redirect to changelist_view. + If there are errors, flow is modified to instead render change form. """ # Check if there are any error or warning messages in the `messages` framework storage = get_messages(request) @@ -1576,7 +1587,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): "obj": obj, "adminform": admin_form, # Pass the AdminForm instance "media": media, - "errors": None, # You can use this to pass custom form errors + "errors": None, } return self.render_change_form( request, diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b055985d1..0a8c4d623 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -113,6 +113,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): class BasePortfolioMemberForm(forms.ModelForm): """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" + # The label for each of these has a red "required" star. We can just embed that here for simplicity. required_star = '*' role = forms.ChoiceField( choices=[ @@ -167,6 +168,9 @@ class BasePortfolioMemberForm(forms.ModelForm): }, ) + # Tracks what form elements are required for a given role choice. + # All of the fields included here have "required=False" by default as they are conditionally required. + # see def clean() for more details. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ "domain_request_permission_admin", @@ -183,10 +187,13 @@ class BasePortfolioMemberForm(forms.ModelForm): def __init__(self, *args, **kwargs): """ - Override the form's initialization to map existing model values - to custom form fields. + Override the form's initialization. + + Map existing model values to custom form fields. + Update field descriptions. """ super().__init__(*args, **kwargs) + # Adds a

    description beneath each role option self.fields["role"].descriptions = { "organization_admin": UserPortfolioRoleChoices.get_role_description( UserPortfolioRoleChoices.ORGANIZATION_ADMIN @@ -196,14 +203,14 @@ class BasePortfolioMemberForm(forms.ModelForm): ), } # Map model instance values to custom form fields - logger.info(self.instance) - logger.info(self.initial) if self.instance: self.map_instance_to_initial() def clean(self): - """Validates form data based on selected role and its required fields.""" - logger.info("clean") + """Validates form data based on selected role and its required fields. + Updates roles and additional_permissions in cleaned_data so they can be properly + mapped to the model. + """ cleaned_data = super().clean() role = cleaned_data.get("role") @@ -239,13 +246,12 @@ class BasePortfolioMemberForm(forms.ModelForm): role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False) cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions) - logger.info(cleaned_data) return cleaned_data def map_instance_to_initial(self): """ Maps self.instance to self.initial, handling roles and permissions. - Returns form data dictionary with appropriate permission levels based on user role: + Updates self.initial dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", "member_permission_admin": permission level if admin, @@ -253,10 +259,9 @@ class BasePortfolioMemberForm(forms.ModelForm): "domain_request_permission_member": permission level if member } """ - logger.info(self.instance) - # Function variables if self.initial is None: self.initial = {} + # Function variables perms = UserPortfolioPermission.get_portfolio_permissions( self.instance.roles, self.instance.additional_permissions, get_list=False ) @@ -290,7 +295,6 @@ class BasePortfolioMemberForm(forms.ModelForm): # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") self.initial["domain_request_permission_member"] = selected_domain_permission - logger.info(self.initial) class PortfolioMemberForm(BasePortfolioMemberForm): @@ -314,6 +318,9 @@ class PortfolioInvitedMemberForm(BasePortfolioMemberForm): class PortfolioNewMemberForm(BasePortfolioMemberForm): + """ + Form for adding a portfolio invited member. + """ email = forms.EmailField( label="Enter the email of the member you'd like to invite", diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index e7730230e..82afcd4d6 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -6,9 +6,6 @@ from registrar.models.user import User from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel -import logging - -logger = logging.getLogger(__name__) class Portfolio(TimeStampedModel): @@ -166,7 +163,3 @@ class Portfolio(TimeStampedModel): def get_suborganizations(self): """Returns all suborganizations associated with this portfolio""" return self.portfolio_suborganizations.all() - - def full_clean(self, exclude=None, validate_unique=True): - logger.info("portfolio full clean") - super().full_clean(exclude, validate_unique) From 5d0301567de6054cde2bc1847727fb62f5a5e3bc Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 19 Dec 2024 21:18:45 -0500 Subject: [PATCH 138/231] account for the members table --- src/registrar/assets/src/sass/_theme/_accordions.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index 261492dd5..ac3025028 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -41,7 +41,7 @@ } // Special positioning for the kabob menu popup in the last row on a given page -tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { +tr:last-of-type:not(.show-more-content) .usa-accordion--more-actions .usa-accordion__content { top: auto; bottom: -10px; right: 30px; From 01b01ba696297cf5f1e7acc5c8ec61ea4ed59387 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 19 Dec 2024 21:23:38 -0500 Subject: [PATCH 139/231] account for the collapsed / uncollapsed content --- src/registrar/assets/src/sass/_theme/_accordions.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index ac3025028..0afe03e81 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -41,7 +41,8 @@ } // Special positioning for the kabob menu popup in the last row on a given page -tr:last-of-type:not(.show-more-content) .usa-accordion--more-actions .usa-accordion__content { +// Account for the Members table rows with the collapsed expandable content +tr:last-of-type:not(.show-more-content.display-none) .usa-accordion--more-actions .usa-accordion__content { top: auto; bottom: -10px; right: 30px; From 2386b38cc2229e07492331d1d5b1f4423caa9c4f Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 19 Dec 2024 21:55:50 -0500 Subject: [PATCH 140/231] cleanup --- src/registrar/assets/src/sass/_theme/_accordions.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index 0afe03e81..762618415 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -41,8 +41,10 @@ } // Special positioning for the kabob menu popup in the last row on a given page -// Account for the Members table rows with the collapsed expandable content -tr:last-of-type:not(.show-more-content.display-none) .usa-accordion--more-actions .usa-accordion__content { +// This won't work on the Members table rows because that table has show-more rows +// Currently, that's not an issue since that Members table is not wrapped in the +// reponsive wrapper. +tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { top: auto; bottom: -10px; right: 30px; From fde44c246c7b4af727df8fad2e050734209eb885 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 19 Dec 2024 21:47:48 -0700 Subject: [PATCH 141/231] Refactored to check for centered pages instead of left-justified pages. Simplified global application of widescreen in context processor. Fixed unformatted pages. --- .../assets/src/sass/_theme/_containers.scss | 2 +- src/registrar/config/settings.py | 2 +- src/registrar/context_processors.py | 60 +-- src/registrar/templates/401.html | 2 +- src/registrar/templates/403.html | 2 +- src/registrar/templates/404.html | 2 +- src/registrar/templates/500.html | 2 +- src/registrar/templates/domain_base.html | 2 +- .../templates/domain_request_form.html | 2 +- .../templates/domain_request_intro.html | 63 +-- .../domain_request_withdraw_confirmation.html | 18 +- src/registrar/templates/home.html | 2 +- .../domain_request_status_manage.html | 432 ++++++++--------- .../includes/request_status_manage.html | 448 +++++++++--------- src/registrar/templates/portfolio_base.html | 4 +- src/registrar/templates/profile.html | 2 +- 16 files changed, 507 insertions(+), 538 deletions(-) diff --git a/src/registrar/assets/src/sass/_theme/_containers.scss b/src/registrar/assets/src/sass/_theme/_containers.scss index 740c0f998..02a338b5d 100644 --- a/src/registrar/assets/src/sass/_theme/_containers.scss +++ b/src/registrar/assets/src/sass/_theme/_containers.scss @@ -19,7 +19,7 @@ // matches max-width to equal the max-width of .grid-container // used to trick the eye into thinking we have left-aligned a // regular grid-container within a widescreen (see instances -// where is_widescreen_left_justified is used in the html). +// where is_widescreen_centered is used in the html). .max-width--grid-container { max-width: 64rem; } \ No newline at end of file diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index f5d158ad3..8f8ad1530 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -252,7 +252,7 @@ TEMPLATES = [ "registrar.context_processors.add_path_to_context", "registrar.context_processors.portfolio_permissions", "registrar.context_processors.is_widescreen_mode", - "registrar.context_processors.is_widescreen_left_justified", + "registrar.context_processors.is_widescreen_centered", ], }, }, diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 66ba90218..b30719cd0 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -99,68 +99,30 @@ def portfolio_permissions(request): def is_widescreen_mode(request): - widescreen_paths = [ - "/domain-request/", - ] # If this list is meant to include specific paths, populate it. - portfolio_widescreen_paths = [ - "/domains/", - "/requests/", - "/no-organization-requests/", - "/no-organization-domains/", - "/members/", - ] - # widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out. - exclude_paths = ["/domains/edit", "/admin/"] - - # Check if the current path matches a widescreen path or the root path. - is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" - - # Check if the user is an organization user and the path matches portfolio paths. - is_portfolio_widescreen = ( - hasattr(request.user, "is_org_user") - and request.user.is_org_user(request) - and any(path in request.path for path in portfolio_widescreen_paths) - ) + exclude_paths = ["/admin/"] + # Widescreen is now global for all pages EXCEPT admin is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths) # Return a dictionary with the widescreen mode status. return { - "is_widescreen_mode": (is_widescreen or is_portfolio_widescreen or get_is_widescreen_left_justified(request)) - and not is_excluded + "is_widescreen_mode": not is_excluded } - -def get_is_widescreen_left_justified(request): +def is_widescreen_centered(request): include_paths = [ - "/user-profile", - "/request/", - "/domain/", + "/domains/", + "/requests/", + "/members/", ] - portfolio_include_paths = [ - "/organization/", - "/senior-official/", - "/member/", - "/members/new-member", + exclude_paths = [ + "/domains/edit", ] - exclude_paths = [] is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths) # Check if the current path matches a path in included_paths or the root path. - is_widescreen_left_justified = any(path in request.path for path in include_paths) - - # Check if the user is an organization user and the path matches portfolio_only paths. - is_portfolio_widescreen_left_justified = ( - hasattr(request.user, "is_org_user") - and request.user.is_org_user(request) - and any(path in request.path for path in portfolio_include_paths) - ) - - return (is_widescreen_left_justified or is_portfolio_widescreen_left_justified) and not is_excluded - - -def is_widescreen_left_justified(request): + is_widescreen_centered = any(path in request.path for path in include_paths) or request.path=="/" # Return a dictionary with the widescreen mode status. - return {"is_widescreen_left_justified": get_is_widescreen_left_justified(request)} + return {"is_widescreen_centered": is_widescreen_centered and not is_excluded} diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index 23e40f649..9ea4a5397 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -6,7 +6,7 @@ {% block content %}

    -
    +

    {% translate "You are not authorized to view this page" %} diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index 524e51c94..9e37e29a8 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -6,7 +6,7 @@ {% block content %}
    -
    +

    {% translate "You're not authorized to view this page." %} diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index bd8604a3a..11f2b982f 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -6,7 +6,7 @@ {% block content %}
    -
    +

    {% translate "We couldn’t find that page" %} diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index 465e3a637..f37b6f94f 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -6,7 +6,7 @@ {% block content %}
    -
    +

    {% translate "We're having some trouble." %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 3769bf08f..5f22c60a7 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -6,7 +6,7 @@ {% block content %}
    -
    +

    -

    +
    {% include 'domain_request_sidebar.html' %}
    diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html index dd5b7ec6e..4f0103449 100644 --- a/src/registrar/templates/domain_request_intro.html +++ b/src/registrar/templates/domain_request_intro.html @@ -4,41 +4,42 @@ {% block title %} Start a request | {% endblock %} {% block content %} -
    -
    +
    +
    +
    -
    - {% csrf_token %} + + {% csrf_token %} -

    You’re about to start your .gov domain request.

    -

    You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.

    - {% if portfolio %} -

    We’ll use the information you provide to verify your domain request meets our guidelines.

    - {% else %} -

    We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

    - {% endif %} -

    Time to complete the form

    -

    If you have all the information you need, - completing your domain request might take around 15 minutes.

    -

    How we’ll reach you

    -

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review. If the contact information below is not correct, visit your profile to make updates.

    - {% include "includes/profile_information.html" with user=user%} - +

    You’re about to start your .gov domain request.

    +

    You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.

    + {% if portfolio %} +

    We’ll use the information you provide to verify your domain request meets our guidelines.

    + {% else %} +

    We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

    + {% endif %} +

    Time to complete the form

    +

    If you have all the information you need, + completing your domain request might take around 15 minutes.

    +

    How we’ll reach you

    +

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review. If the contact information below is not correct, visit your profile to make updates.

    + {% include "includes/profile_information.html" with user=user%} + -{% block form_buttons %} -
    - -
    -{% endblock %} + {% block form_buttons %} +
    + +
    + {% endblock %} -
    + -
    Paperwork Reduction Act statement (OMB control number: 1670-0049; expiration date: 10/31/2026)
    -
    +
    Paperwork Reduction Act statement (OMB control number: 1670-0049; expiration date: 10/31/2026)
    +
    {% endblock %} diff --git a/src/registrar/templates/domain_request_withdraw_confirmation.html b/src/registrar/templates/domain_request_withdraw_confirmation.html index e1a5f0c2a..d8a4bb977 100644 --- a/src/registrar/templates/domain_request_withdraw_confirmation.html +++ b/src/registrar/templates/domain_request_withdraw_confirmation.html @@ -8,18 +8,20 @@ {% endblock wrapperdiv %} {% block content %} -
    -
    - +
    +
    +
    + -

    Withdraw request for {{ DomainRequest.requested_domain.name }}?

    +

    Withdraw request for {{ DomainRequest.requested_domain.name }}?

    -

    If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again.

    +

    If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again.

    -

    Withdraw request - Cancel

    +

    Withdraw request + Cancel

    -
    +
    +
    {% endblock %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index b00c57b5c..d41e19cd5 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -10,7 +10,7 @@ {# the entire logged in page goes here #} {% block homepage_content %} -
    +
    {% block messages %} {% include "includes/form_messages.html" %} {% endblock %} diff --git a/src/registrar/templates/includes/domain_request_status_manage.html b/src/registrar/templates/includes/domain_request_status_manage.html index 2a254df4b..078d4e853 100644 --- a/src/registrar/templates/includes/domain_request_status_manage.html +++ b/src/registrar/templates/includes/domain_request_status_manage.html @@ -1,236 +1,238 @@ {% load custom_filters %} {% load static url_helpers %} -
    -
    - {% block breadcrumb %} - {% if portfolio %} - {% url 'domain-requests' as url %} - {% else %} - {% url 'home' as url %} - {% endif %} - - {% endblock breadcrumb %} - {% block header %} - {% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %} -

    New domain request

    - {% else %} -

    Domain request for {{ DomainRequest.requested_domain.name }}

    - {% endif %} - {% endblock header %} - - {% block status_summary %} -
    -
    -

    - - Status: - - {{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }} -

    -
    -
    -
    - {% endblock status_summary %} - - {% block status_metadata %} - - {% if portfolio %} - {% if DomainRequest.creator %} -

    - Created by: {{DomainRequest.creator.email|default:DomainRequest.creator.get_formatted_name }} -

    - {% else %} -

    - No creator found: this is an error, please email help@get.gov. -

    - {% endif %} - {% endif %} - - {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} - {% comment %} - These are intentionally seperated this way. - There is some code repetition, but it gives us more flexibility rather than a dense reduction. - Leave it this way until we've solidified our requirements. - {% endcomment %} - {% if DomainRequest.status == statuses.STARTED %} - {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} -

    + {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} {% comment %} - A newly created domain request will not have a value for last_status update. - This is because the status never really updated. - However, if this somehow goes back to started we can default to displaying that new date. + These are intentionally seperated this way. + There is some code repetition, but it gives us more flexibility rather than a dense reduction. + Leave it this way until we've solidified our requirements. {% endcomment %} - Started on: {{last_status_update|default:first_started_date}} -

    - {% endwith %} - {% elif DomainRequest.status == statuses.SUBMITTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.ACTION_NEEDED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.REJECTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Rejected on: {{last_status_update}} -

    - {% elif DomainRequest.status == statuses.WITHDRAWN %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Withdrawn on: {{last_status_update}} -

    - {% else %} - {% comment %} Shown for in_review, approved, ineligible {% endcomment %} -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% endif %} - {% endwith %} - {% endblock status_metadata %} - - {% block status_blurb %} - {% if DomainRequest.is_awaiting_review %} -

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    - {% endif %} - {% endblock status_blurb %} - - {% block modify_request %} - {% if DomainRequest.is_withdrawable %} -

    - Withdraw request + {% if DomainRequest.status == statuses.STARTED %} + {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} +

    + {% comment %} + A newly created domain request will not have a value for last_status update. + This is because the status never really updated. + However, if this somehow goes back to started we can default to displaying that new date. + {% endcomment %} + Started on: {{last_status_update|default:first_started_date}}

    - {% endif %} - {% endblock modify_request %} -
    - -
    - {% block request_summary_header %} -

    Summary of your domain request

    - {% endblock request_summary_header%} - - {% block request_summary %} - {% with heading_level='h3' %} - {% with org_type=DomainRequest.get_generic_org_type_display %} - {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} - {% endwith %} - - {% if DomainRequest.tribe_name %} - {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - - {% if DomainRequest.federally_recognized_tribe %} -

    Federally-recognized tribe

    - {% endif %} - - {% if DomainRequest.state_recognized_tribe %} -

    State-recognized tribe

    - {% endif %} - - {% endif %} - - {% if DomainRequest.get_federal_type_display %} - {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.is_election_board %} - {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} - {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} {% endwith %} - {% endif %} + {% elif DomainRequest.status == statuses.SUBMITTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.ACTION_NEEDED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.REJECTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Rejected on: {{last_status_update}} +

    + {% elif DomainRequest.status == statuses.WITHDRAWN %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Withdrawn on: {{last_status_update}} +

    + {% else %} + {% comment %} Shown for in_review, approved, ineligible {% endcomment %} +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% endif %} + {% endwith %} + {% endblock status_metadata %} - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} - {% endif %} + {% block status_blurb %} + {% if DomainRequest.is_awaiting_review %} +

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    + {% endif %} + {% endblock status_blurb %} - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} - {% endif %} + {% block modify_request %} + {% if DomainRequest.is_withdrawable %} +

    + Withdraw request +

    + {% endif %} + {% endblock modify_request %} +
    - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} +
    + {% block request_summary_header %} +

    Summary of your domain request

    + {% endblock request_summary_header%} - {% if DomainRequest.current_websites.all %} - {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} - {% endif %} + {% block request_summary %} + {% with heading_level='h3' %} + {% with org_type=DomainRequest.get_generic_org_type_display %} + {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} + {% endwith %} - {% if DomainRequest.requested_domain %} - {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} - {% endif %} + {% if DomainRequest.tribe_name %} + {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - {% if DomainRequest.alternative_domains.all %} - {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.purpose %} - {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.creator %} - {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.other_contacts.all %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} - {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} - {% endif %} - - {# We always show this field even if None #} - {% if DomainRequest %} -

    CISA Regional Representative

    -
      - {% if DomainRequest.cisa_representative_first_name %} - {{ DomainRequest.get_formatted_cisa_rep_name }} - {% else %} - No + {% if DomainRequest.federally_recognized_tribe %} +

      Federally-recognized tribe

      {% endif %} -
    -

    Anything else

    -
      - {% if DomainRequest.anything_else %} - {{DomainRequest.anything_else}} - {% else %} - No + + {% if DomainRequest.state_recognized_tribe %} +

      State-recognized tribe

      {% endif %} -
    - {% endif %} - {% endwith %} - {% endblock request_summary%} + + {% endif %} + + {% if DomainRequest.get_federal_type_display %} + {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.is_election_board %} + {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} + {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} + {% endwith %} + {% endif %} + + {% if DomainRequest.organization_name %} + {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.about_your_organization %} + {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.senior_official %} + {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.current_websites.all %} + {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.purpose %} + {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.creator %} + {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.other_contacts.all %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} + {% else %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} + {% endif %} + + {# We always show this field even if None #} + {% if DomainRequest %} +

    CISA Regional Representative

    +
      + {% if DomainRequest.cisa_representative_first_name %} + {{ DomainRequest.get_formatted_cisa_rep_name }} + {% else %} + No + {% endif %} +
    +

    Anything else

    +
      + {% if DomainRequest.anything_else %} + {{DomainRequest.anything_else}} + {% else %} + No + {% endif %} +
    + {% endif %} + {% endwith %} + {% endblock request_summary%} +
    \ No newline at end of file diff --git a/src/registrar/templates/includes/request_status_manage.html b/src/registrar/templates/includes/request_status_manage.html index fc2fd8f12..760d9e607 100644 --- a/src/registrar/templates/includes/request_status_manage.html +++ b/src/registrar/templates/includes/request_status_manage.html @@ -1,240 +1,242 @@ {% load custom_filters %} {% load static url_helpers %} -
    -
    - {% block breadcrumb %} - {% if portfolio %} - {% url 'domain-requests' as url %} - {% else %} - {% url 'home' as url %} - {% endif %} - - {% endblock breadcrumb %} - {% block header %} - {% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %} -

    New domain request

    - {% else %} -

    Domain request for {{ DomainRequest.requested_domain.name }}

    - {% endif %} - {% endblock header %} - - {% block status_summary %} -
    -
    -

    - - Status: - - {{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }} -

    -
    -
    -
    - {% endblock status_summary %} - - {% block status_metadata %} - - {% if portfolio %} - {% if DomainRequest.creator %} -

    - Created by: {{DomainRequest.creator.email|default:DomainRequest.creator.get_formatted_name }} -

    - {% else %} -

    - No creator found: this is an error, please email help@get.gov. -

    - {% endif %} - {% endif %} - - {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} - {% comment %} - These are intentionally seperated this way. - There is some code repetition, but it gives us more flexibility rather than a dense reduction. - Leave it this way until we've solidified our requirements. - {% endcomment %} - {% if DomainRequest.status == statuses.STARTED %} - {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} -

    + {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} {% comment %} - A newly created domain request will not have a value for last_status update. - This is because the status never really updated. - However, if this somehow goes back to started we can default to displaying that new date. + These are intentionally seperated this way. + There is some code repetition, but it gives us more flexibility rather than a dense reduction. + Leave it this way until we've solidified our requirements. {% endcomment %} - Started on: {{last_status_update|default:first_started_date}} -

    - {% endwith %} - {% elif DomainRequest.status == statuses.SUBMITTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.ACTION_NEEDED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.REJECTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Rejected on: {{last_status_update}} -

    - {% elif DomainRequest.status == statuses.WITHDRAWN %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Withdrawn on: {{last_status_update}} -

    - {% else %} - {% comment %} Shown for in_review, approved, ineligible {% endcomment %} -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% endif %} - {% endwith %} - {% endblock status_metadata %} - - {% block status_blurb %} - {% if DomainRequest.is_awaiting_review %} -

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    - {% endif %} - {% endblock status_blurb %} - - {% block modify_request %} - {% if DomainRequest.is_withdrawable %} -

    - Withdraw request + {% if DomainRequest.status == statuses.STARTED %} + {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} +

    + {% comment %} + A newly created domain request will not have a value for last_status update. + This is because the status never really updated. + However, if this somehow goes back to started we can default to displaying that new date. + {% endcomment %} + Started on: {{last_status_update|default:first_started_date}}

    - {% endif %} - {% endblock modify_request %} -
    - -
    - {% block request_summary_header %} -

    Summary of your domain request

    - {% endblock request_summary_header%} - - {% block request_summary %} - {% if portfolio %} - {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %} - {% else %} - {% with heading_level='h3' %} - {% with org_type=DomainRequest.get_generic_org_type_display %} - {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} {% endwith %} - - {% if DomainRequest.tribe_name %} - {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - - {% if DomainRequest.federally_recognized_tribe %} -

    Federally-recognized tribe

    - {% endif %} - - {% if DomainRequest.state_recognized_tribe %} -

    State-recognized tribe

    - {% endif %} - - {% endif %} - - {% if DomainRequest.get_federal_type_display %} - {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.is_election_board %} - {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} - {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} - {% endwith %} - {% endif %} - - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.current_websites.all %} - {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.requested_domain %} - {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.alternative_domains.all %} - {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.purpose %} - {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.creator %} - {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.other_contacts.all %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} + {% elif DomainRequest.status == statuses.SUBMITTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.ACTION_NEEDED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    + {% elif DomainRequest.status == statuses.REJECTED %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Rejected on: {{last_status_update}} +

    + {% elif DomainRequest.status == statuses.WITHDRAWN %} +

    + Submitted on: {{last_submitted|default:first_submitted }} +

    +

    + Withdrawn on: {{last_status_update}} +

    {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} + {% comment %} Shown for in_review, approved, ineligible {% endcomment %} +

    + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} +

    {% endif %} + {% endwith %} + {% endblock status_metadata %} - {# We always show this field even if None #} - {% if DomainRequest %} -

    CISA Regional Representative

    -
      - {% if DomainRequest.cisa_representative_first_name %} - {{ DomainRequest.get_formatted_cisa_rep_name }} - {% else %} - No - {% endif %} -
    -

    Anything else

    -
      - {% if DomainRequest.anything_else %} - {{DomainRequest.anything_else}} - {% else %} - No - {% endif %} -
    + {% block status_blurb %} + {% if DomainRequest.is_awaiting_review %} +

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    {% endif %} - {% endwith %} - {% endif %} - {% endblock request_summary%} + {% endblock status_blurb %} + + {% block modify_request %} + {% if DomainRequest.is_withdrawable %} +

    + Withdraw request +

    + {% endif %} + {% endblock modify_request %} +
    + +
    + {% block request_summary_header %} +

    Summary of your domain request

    + {% endblock request_summary_header%} + + {% block request_summary %} + {% if portfolio %} + {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %} + {% else %} + {% with heading_level='h3' %} + {% with org_type=DomainRequest.get_generic_org_type_display %} + {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} + {% endwith %} + + {% if DomainRequest.tribe_name %} + {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} + + {% if DomainRequest.federally_recognized_tribe %} +

    Federally-recognized tribe

    + {% endif %} + + {% if DomainRequest.state_recognized_tribe %} +

    State-recognized tribe

    + {% endif %} + + {% endif %} + + {% if DomainRequest.get_federal_type_display %} + {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.is_election_board %} + {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} + {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} + {% endwith %} + {% endif %} + + {% if DomainRequest.organization_name %} + {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.about_your_organization %} + {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.senior_official %} + {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.current_websites.all %} + {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.purpose %} + {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.creator %} + {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} + {% endif %} + + {% if DomainRequest.other_contacts.all %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} + {% else %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} + {% endif %} + + {# We always show this field even if None #} + {% if DomainRequest %} +

    CISA Regional Representative

    +
      + {% if DomainRequest.cisa_representative_first_name %} + {{ DomainRequest.get_formatted_cisa_rep_name }} + {% else %} + No + {% endif %} +
    +

    Anything else

    +
      + {% if DomainRequest.anything_else %} + {{DomainRequest.anything_else}} + {% else %} + No + {% endif %} +
    + {% endif %} + {% endwith %} + {% endif %} + {% endblock request_summary%} +

    \ No newline at end of file diff --git a/src/registrar/templates/portfolio_base.html b/src/registrar/templates/portfolio_base.html index c8d722ba6..4e8670eb2 100644 --- a/src/registrar/templates/portfolio_base.html +++ b/src/registrar/templates/portfolio_base.html @@ -8,8 +8,8 @@ {% if user.is_authenticated %} {# the entire logged in page goes here #} -
    -
    +
    +
    {% block messages %} {% include "includes/form_messages.html" %} {% endblock %} diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index 9426439b5..cfd6d3c5f 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -12,7 +12,7 @@ Edit your User Profile | {% block content %}
    -
    +
    {% if messages %} {% for message in messages %} From 0ca3a1bfa944e10ae7fe9934aa173bb8556c55b2 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 19 Dec 2024 21:56:46 -0700 Subject: [PATCH 142/231] strip out extra spaces in html css class if/else statements. Add member page is left-justified. --- src/registrar/context_processors.py | 1 + src/registrar/templates/401.html | 4 ++-- src/registrar/templates/403.html | 4 ++-- src/registrar/templates/404.html | 4 ++-- src/registrar/templates/500.html | 4 ++-- src/registrar/templates/admin/app_list.html | 2 +- src/registrar/templates/admin/fieldset.html | 2 +- src/registrar/templates/admin/transfer_user.html | 2 +- src/registrar/templates/base.html | 2 +- src/registrar/templates/dashboard_base.html | 2 +- .../templates/django/admin/includes/details_button.html | 2 +- .../templates/django/admin/includes/domain_fieldset.html | 2 +- .../django/admin/multiple_choice_list_filter.html | 4 ++-- src/registrar/templates/domain_base.html | 4 ++-- src/registrar/templates/domain_request_form.html | 4 ++-- src/registrar/templates/domain_request_intro.html | 6 +++--- .../templates/domain_request_withdraw_confirmation.html | 6 +++--- src/registrar/templates/domain_sidebar.html | 2 +- src/registrar/templates/home.html | 4 ++-- src/registrar/templates/includes/banner-error.html | 2 +- src/registrar/templates/includes/banner-info.html | 2 +- .../templates/includes/banner-non-production-alert.html | 2 +- .../templates/includes/banner-service-disruption.html | 2 +- src/registrar/templates/includes/banner-site-alert.html | 2 +- .../templates/includes/banner-system-outage.html | 2 +- src/registrar/templates/includes/banner-warning.html | 2 +- .../templates/includes/domain_request_status_manage.html | 8 ++++---- .../templates/includes/domain_requests_table.html | 6 +++--- src/registrar/templates/includes/domains_table.html | 2 +- src/registrar/templates/includes/members_table.html | 2 +- .../templates/includes/request_status_manage.html | 8 ++++---- src/registrar/templates/includes/required_fields.html | 2 +- src/registrar/templates/portfolio_base.html | 6 +++--- src/registrar/templates/portfolio_members.html | 2 +- src/registrar/templates/profile.html | 4 ++-- 35 files changed, 58 insertions(+), 57 deletions(-) diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index b30719cd0..2dbc30abf 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -117,6 +117,7 @@ def is_widescreen_centered(request): ] exclude_paths = [ "/domains/edit", + "members/new-member/", ] is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths) diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index 9ea4a5397..848d9901a 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block content %} -
    -
    +
    +

    {% translate "You are not authorized to view this page" %} diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index 9e37e29a8..5f0faa5a5 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Forbidden | " %}{% endblock %} {% block content %} -
    -
    +
    +

    {% translate "You're not authorized to view this page." %} diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index 11f2b982f..4747ae846 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Page not found | " %}{% endblock %} {% block content %} -
    -
    +
    +

    {% translate "We couldn’t find that page" %} diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index f37b6f94f..661bae9f9 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Server error | " %}{% endblock %} {% block content %} -
    -
    +
    +

    {% translate "We're having some trouble." %} diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index 49fb59e79..aaf3dc423 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -39,7 +39,7 @@ {% for model in app.models %} {% if model.admin_url %} - {{ model.name }} + {{ model.name }} {% else %} {{ model.name }} {% endif %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 40cd98ca8..20b76217b 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -61,7 +61,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/ {% if field.field.help_text %} {# .gov override #} {% block help_text %} -
    +
    {{ field.field.help_text|safe }}
    {% endblock help_text %} diff --git a/src/registrar/templates/admin/transfer_user.html b/src/registrar/templates/admin/transfer_user.html index 3ba136b93..61444b173 100644 --- a/src/registrar/templates/admin/transfer_user.html +++ b/src/registrar/templates/admin/transfer_user.html @@ -43,7 +43,7 @@ {% if steps.current == steps.first %} From f277efc6c43adf072ebe26091c50e66ffbcaf8c3 Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Mon, 23 Dec 2024 14:57:37 -0600 Subject: [PATCH 164/231] Update src/registrar/admin.py Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/admin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c04975cb9..117f689f8 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2442,10 +2442,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): obj_id = domain.id change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) - message = f"
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa - message += f"{domain}
  • " - - message_html = mark_safe(message) # nosec +message = format_html( + "
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status:" + "{}
  • ", + mark_safe(change_url), + escape(str(domain)) +) messages.warning( request, message_html, From ee2bff6492d87ccccc0e1ddbb1f0948f7a885108 Mon Sep 17 00:00:00 2001 From: Matthew Spence Date: Mon, 23 Dec 2024 15:31:04 -0600 Subject: [PATCH 165/231] fix warning html --- src/registrar/admin.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 117f689f8..c5aae7d2d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2442,16 +2442,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): obj_id = domain.id change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) -message = format_html( - "
  • The status of this domain request cannot be changed because it has been joined to a domain in Ready status:" - "{}
  • ", - mark_safe(change_url), - escape(str(domain)) -) - messages.warning( - request, - message_html, - ) + message = format_html( + "The status of this domain request cannot be changed because it has been joined to a domain in Ready status:" + "{}", + mark_safe(change_url), + escape(str(domain)) + ) + messages.warning( + request, + message, + ) obj = self.get_object(request, object_id) self.display_restricted_warning(request, obj) From 03835050cc348f58310f1d7114744b2f8086fd79 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 23 Dec 2024 18:14:05 -0700 Subject: [PATCH 166/231] more cleanup --- src/registrar/assets/src/sass/_theme/_containers.scss | 2 +- src/registrar/templates/domain_base.html | 4 ++-- src/registrar/templates/domain_detail.html | 2 +- src/registrar/templates/domain_request_form.html | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/assets/src/sass/_theme/_containers.scss b/src/registrar/assets/src/sass/_theme/_containers.scss index cdff8efe4..f7345c83e 100644 --- a/src/registrar/assets/src/sass/_theme/_containers.scss +++ b/src/registrar/assets/src/sass/_theme/_containers.scss @@ -22,5 +22,5 @@ // regular grid-container within a widescreen (see instances // where is_widescreen_centered is used in the html). .max-width--grid-container { - max-width: 64rem; + max-width: 960px; } \ No newline at end of file diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 35cd6409d..0ed4dd666 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -19,8 +19,8 @@ {% endif %}
    -
    -
    +
    +
    {% if not domain.domain_info %}
    diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 2c1108c0c..add7ca725 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -20,7 +20,7 @@ {% endblock breadcrumb %} {{ block.super }} -
    +

    {{ domain.name }}

    {% include 'domain_request_sidebar.html' %}
    -
    +
    {% if steps.current == steps.first %} From eb4cd2719ee1606fe495d4df6d584828c0b2910f Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 23 Dec 2024 23:56:38 -0700 Subject: [PATCH 167/231] reverted a few suggested commits -- fixed issues --- src/registrar/admin.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4456c9cdc..165f12b42 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1980,8 +1980,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) def queryset(self, request, queryset): - filter_for_portfolio = self.value() == "1" - return queryset.filter(portfolio__isnull=filter_for_portfolio) + if self.value() == "1": + return queryset.filter(Q(portfolio__isnull=False)) + if self.value() == "0": + return queryset.filter(Q(portfolio__isnull=True)) # ------ Custom fields ------ def custom_election_board(self, obj): @@ -1993,7 +1995,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): @admin.display(description=_("Requested Domain")) def custom_requested_domain(self, obj): # Example: Show different icons based on `status` - url = reverse("admin:registrar_domainrequest_changelist", args=[obj.id]) + url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}" text = obj.requested_domain if obj.portfolio: return format_html(' {}', url, text) From 76ebec01ce0e0220904b2d4705c5341e4b33c5fe Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 24 Dec 2024 12:46:02 -0500 Subject: [PATCH 168/231] refactored domain request form modal to remove hidden input --- .../src/js/getgov/domain-request-form.js | 12 ++++++++++ src/registrar/assets/src/js/getgov/helpers.js | 14 +++++++++++ src/registrar/assets/src/js/getgov/main.js | 3 +++ .../templates/domain_request_form.html | 14 ++++++++++- src/registrar/templates/includes/modal.html | 24 ++++++++++++++++--- src/registrar/views/domain_request.py | 13 ---------- 6 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 src/registrar/assets/src/js/getgov/domain-request-form.js diff --git a/src/registrar/assets/src/js/getgov/domain-request-form.js b/src/registrar/assets/src/js/getgov/domain-request-form.js new file mode 100644 index 000000000..d9b660a50 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-request-form.js @@ -0,0 +1,12 @@ +import { submitForm } from './helpers.js'; + +export function initDomainRequestForm() { + document.addEventListener('DOMContentLoaded', function() { + const button = document.getElementById("domain-request-form-submit-button"); + if (button) { + button.addEventListener("click", function () { + submitForm("submit-domain-request-form"); + }); + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 1afd84520..4b893ae7c 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -75,3 +75,17 @@ export function debounce(handler, cooldown=600) { export function getCsrfToken() { return document.querySelector('input[name="csrfmiddlewaretoken"]').value; } + +/** + * Helper function to submit a form + * @param {} form_id - the id of the form to be submitted + */ +export function submitForm(form_id) { + let form = document.getElementById(form_id); + if (form) { + console.log("submitting form"); + form.submit(); + } else { + console.error("Form '" + form_id + "' not found."); + } +} diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index f5ebc83a3..cee28e7a0 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -11,6 +11,7 @@ import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js'; +import { initDomainRequestForm } from './domain-request-form.js'; initDomainValidators(); @@ -36,6 +37,8 @@ initMembersTable(); initMemberDomainsTable(); initEditMemberDomainsTable(); +initDomainRequestForm(); + // Init the portfolio new member page initPortfolioMemberPageRadio(); initPortfolioNewMemberPageToggle(); diff --git a/src/registrar/templates/domain_request_form.html b/src/registrar/templates/domain_request_form.html index a076220cb..aeae9fb34 100644 --- a/src/registrar/templates/domain_request_form.html +++ b/src/registrar/templates/domain_request_form.html @@ -130,9 +130,21 @@ aria-describedby="Are you sure you want to submit a domain request?" data-force-action > - {% include 'includes/modal.html' with is_domain_request_form=True review_form_is_complete=review_form_is_complete modal_heading=modal_heading|safe modal_description=modal_description|safe modal_button=modal_button|safe %} + {% if review_form_is_complete %} + {% with modal_heading="You are about to submit a domain request for " domain_name_modal=requested_domain__name modal_description="Once you submit this request, you won’t be able to edit it until we review it. You’ll only be able to withdraw your request." modal_button_id="domain-request-form-submit-button" modal_button_text="Submit request" %} + {% include 'includes/modal.html' %} + {% endwith %} + {% else %} + {% with modal_heading="Your request form is incomplete" modal_description='This request cannot be submitted yet. Return to the request and visit the steps that are marked as "incomplete."' modal_button_text="Return to request" cancel_button_only=True %} + {% include 'includes/modal.html' %} + {% endwith %} + {% endif %}
    +
    + {% csrf_token %} +
    + {% block after_form_content %}{% endblock %}
    diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index 625044585..72a989484 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -24,8 +24,26 @@