diff --git a/src/registrar/admin.py b/src/registrar/admin.py index fa0dea885..ababbbaa1 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2741,6 +2741,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): portfolio_urbanization.short_description = "Urbanization" # type: ignore + # ------ FEB fields ------ + # This is just a placeholder. This field will be populated in the detail_table_fieldset view. # This is not a field that exists on the model. def status_history(self, obj): @@ -2821,7 +2823,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): ] }, ), - (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), + ( + ".gov domain", + { + "fields": [ + "requested_domain", + "alternative_domains", + "feb_naming_requirements_details", + ] + }, + ), ( "Contacts", { @@ -2833,10 +2844,24 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", + "eop_stakeholder_first_name", + "eop_stakeholder_last_name", + ] + }, + ), + ( + "Background info", + { + "fields": [ + "feb_purpose_choice", + "purpose", + "time_frame_details", + "interagency_initiative_details", + "anything_else", + "current_websites", ] }, ), - ("Background info", {"fields": ["purpose", "anything_else", "current_websites"]}), ( "Type of organization", { @@ -3033,23 +3058,41 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) + excluded_fields = set() + feb_fields = [ + "feb_naming_requirements_details", + "feb_purpose_choice", + "time_frame_details", + "interagency_initiative_details", + "eop_stakeholder_first_name", + "eop_stakeholder_last_name", + ] + + org_fields = [ + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ] + + org_flag = flag_is_active_for_user(request.user, "organization_requests") + # Hide FEB fields for non-FEB requests + if not (obj and obj.portfolio and obj.is_feb()): + excluded_fields.update(feb_fields) + # Hide certain portfolio and suborg fields behind the organization requests flag # if it is not enabled - if not flag_is_active_for_user(request.user, "organization_requests"): - excluded_fields = [ - "portfolio", - "sub_organization", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", - ] - modified_fieldsets = [] - for name, data in fieldsets: - fields = data.get("fields", []) - fields = tuple(field for field in fields if field not in excluded_fields) - modified_fieldsets.append((name, {**data, "fields": fields})) - return modified_fieldsets - return fieldsets + if not org_flag: + excluded_fields.update(org_fields) + excluded_fields.update(feb_fields) + + modified_fieldsets = [] + for name, data in fieldsets: + fields = data.get("fields", []) + fields = tuple(field for field in fields if field not in excluded_fields) + modified_fieldsets.append((name, {**data, "fields": fields})) + return modified_fieldsets # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): diff --git a/src/registrar/assets/src/js/getgov/domain-purpose-form.js b/src/registrar/assets/src/js/getgov/domain-purpose-form.js index 7cde5bc35..7934d5f68 100644 --- a/src/registrar/assets/src/js/getgov/domain-purpose-form.js +++ b/src/registrar/assets/src/js/getgov/domain-purpose-form.js @@ -1,41 +1,105 @@ import { showElement } from './helpers.js'; +// Flag to track if we're in the initial page load +let isInitialLoad = true; + export const domain_purpose_choice_callbacks = { 'new': { - callback: function(value, element) { + callback: function(value, element, event) { + // Only clear errors if this is a user-initiated event (not initial page load) + if (!isInitialLoad) { + clearErrors(element); + } + //show the purpose details container showElement(element); // change just the text inside the em tag - const labelElement = element.querySelector('.usa-label em'); - labelElement.innerHTML = 'Explain why a new domain is required and why a ' + - 'subdomain of an existing domain doesn\'t meet your needs.' + + const labelElement = element.querySelector('p em'); + labelElement.innerHTML = 'Explain why a new domain name is needed instead of using a ' + + 'subdomain of an existing website.' + '

' + // Adding double line break for spacing - 'Include any data that supports a clear public benefit or ' + - 'evidence user need for this new domain. ' + + 'Include any information or data that shows how the new domain would ' + + 'benefit the public or meet user needs. ' + '*'; + + // Mark that we're no longer in initial load + isInitialLoad = false; }, element: document.getElementById('purpose-details-container') }, 'redirect': { - callback: function(value, element) { + callback: function(value, element, event) { + // Only clear errors if this is a user-initiated event (not initial page load) + if (!isInitialLoad) { + clearErrors(element); + } + // show the purpose details container showElement(element); // change just the text inside the em tag - const labelElement = element.querySelector('.usa-label em'); + const labelElement = element.querySelector('p em'); labelElement.innerHTML = 'Explain why a redirect is necessary. ' + '*'; + + // Mark that we're no longer in initial load + isInitialLoad = false; }, element: document.getElementById('purpose-details-container') }, 'other': { - callback: function(value, element) { + callback: function(value, element, event) { + // Only clear errors if this is a user-initiated event (not initial page load) + if (!isInitialLoad) { + clearErrors(element); + } + // Show the purpose details container showElement(element); // change just the text inside the em tag - const labelElement = element.querySelector('.usa-label em'); + const labelElement = element.querySelector('p em'); labelElement.innerHTML = 'Describe how this domain will be used. ' + '*'; + + // Mark that we're no longer in initial load + isInitialLoad = false; }, element: document.getElementById('purpose-details-container') } +} + +// Helper function to clear error messages in a textarea +function clearErrors(element) { + // Find the error message div + const errorMessage = element.querySelector('#id_purpose-purpose__error-message'); + if (errorMessage) { + errorMessage.remove(); + } + + // Find the form group and remove error class + const formGroup = element.querySelector('.usa-form-group'); + if (formGroup) { + formGroup.classList.remove('usa-form-group--error'); + } + + // Find the textarea and remove error class + const textarea = element.querySelector('#id_purpose-purpose'); + if (textarea) { + textarea.classList.remove('usa-input--error'); + + // Also update aria attributes + textarea.setAttribute('aria-invalid', 'false'); + + // Remove error message from aria-describedby + const describedBy = textarea.getAttribute('aria-describedby'); + if (describedBy) { + const newDescribedBy = describedBy.replace('id_purpose-purpose__error-message', '').trim(); + textarea.setAttribute('aria-describedby', newDescribedBy); + } + } + + // Find the label and remove error class + const label = element.querySelector('label'); + if (label) { + label.classList.remove('usa-label--error'); + } } \ No newline at end of file diff --git a/src/registrar/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index fdaa1c135..a89d63cce 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -22,6 +22,13 @@ class UserFixture: """ ADMINS = [ + { + "username": "4aa78480-6272-42f9-ac29-a034ebdd9231", + "first_name": "Kaitlin", + "last_name": "Abbitt", + "email": "kaitlin.abbitt@cisa.dhs.gov", + "title": "Captain pirate", + }, { "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", "first_name": "Aditi", diff --git a/src/registrar/forms/feb.py b/src/registrar/forms/feb.py index 2dabbff0d..f0b311b02 100644 --- a/src/registrar/forms/feb.py +++ b/src/registrar/forms/feb.py @@ -1,6 +1,7 @@ from django import forms from django.core.validators import MaxLengthValidator from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm +from registrar.models.domain_request import DomainRequest class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): @@ -11,6 +12,8 @@ class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrar field_name = "feb_naming_requirements" + required_error_message = "Select “Yes” if your submission meets each domain naming requirement. Select “No” if it doesn’t meet each requirement." # noqa: E501 + @property def form_is_checked(self): """ @@ -25,7 +28,9 @@ class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): widget=forms.Textarea(attrs={"maxlength": "2000"}), max_length=2000, required=True, - error_messages={"required": ("This field is required.")}, + error_messages={ + "required": ("Provide details on why your submission does not meet each domain naming requirement.") + }, # noqa: E501 validators=[ MaxLengthValidator( 2000, @@ -41,18 +46,14 @@ class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): field_name = "feb_purpose_choice" - form_choices = ( - ("new", "Used for a new website"), - ("redirect", "Used as a redirect for an existing website"), - ("other", "Not for a website"), - ) + form_choices = DomainRequest.FEBPurposeChoices.choices feb_purpose_choice = forms.ChoiceField( required=True, choices=form_choices, widget=forms.RadioSelect, error_messages={ - "required": "This question is required.", + "required": "Select the purpose of your requested domain.", }, label="Select one", ) @@ -65,6 +66,10 @@ class FEBTimeFrameYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): """ field_name = "has_timeframe" + required_error_message = ( + "Select “Yes” if you have a target time frame for" + " launching this domain. Select “No” if you don’t have a target time frame." + ) @property def form_is_checked(self): @@ -79,7 +84,7 @@ class FEBTimeFrameDetailsForm(BaseDeletableRegistrarForm): label="time_frame_details", widget=forms.Textarea( attrs={ - "aria-label": "Provide details on your target timeframe. \ + "aria-label": "Provide details on your target time frame. \ Is there a special significance to this date (legal requirement, announcement, event, etc)?" } ), @@ -89,7 +94,7 @@ class FEBTimeFrameDetailsForm(BaseDeletableRegistrarForm): message="Response must be less than 2000 characters.", ) ], - error_messages={"required": "Provide details on your target timeframe."}, + error_messages={"required": "Provide details on your target time frame."}, ) @@ -100,6 +105,10 @@ class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoFor """ field_name = "is_interagency_initiative" + required_error_message = ( + "Select “Yes” if the domain will be used for an " + "interagency initiative. Select “No” if it won’t be used for an interagency initiative." + ) @property def form_is_checked(self): @@ -156,29 +165,12 @@ class EOPContactForm(BaseDeletableRegistrarForm): error_messages={"required": "Enter the last name / family name of this contact."}, required=True, ) - email = forms.EmailField( - label="Email", - max_length=None, - error_messages={ - "required": ("Enter an email address in the required format, like name@example.com."), - "invalid": ("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, - help_text="Enter an email address in the required format, like name@example.com.", - ) @classmethod def from_database(cls, obj): return { "first_name": obj.eop_stakeholder_first_name, "last_name": obj.eop_stakeholder_last_name, - "email": obj.eop_stakeholder_email, } def to_database(self, obj): @@ -192,7 +184,6 @@ class EOPContactForm(BaseDeletableRegistrarForm): return obj.eop_stakeholder_first_name = self.cleaned_data["first_name"] obj.eop_stakeholder_last_name = self.cleaned_data["last_name"] - obj.eop_stakeholder_email = self.cleaned_data["email"] obj.save() diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index eedf5839b..4245bed30 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -234,9 +234,12 @@ class BaseYesNoForm(RegistrarForm): # For instance, this could be "has_other_contacts" field_name: str + # This field can be overriden to show a custom error + # message. required_error_message = "This question is required." # Default form choice mapping. Default is suitable for most cases. + # Override for more complex scenarios. form_choices = ((True, "Yes"), (False, "No")) def __init__(self, *args, **kwargs): diff --git a/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py index b23b6c107..2fcc86aa0 100644 --- a/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py +++ b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py @@ -13,17 +13,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name="domainrequest", name="eop_stakeholder_email", - field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="EOP Stakeholder Email"), + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="EOP contact email"), ), migrations.AddField( model_name="domainrequest", name="eop_stakeholder_first_name", - field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder First Name"), + field=models.CharField(blank=True, null=True, verbose_name="EOP contact first name"), ), migrations.AddField( model_name="domainrequest", name="eop_stakeholder_last_name", - field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder Last Name"), + field=models.CharField(blank=True, null=True, verbose_name="EOP contact last name"), ), migrations.AddField( model_name="domainrequest", diff --git a/src/registrar/migrations/0145_remove_domainrequest_eop_stakeholder_email_and_more.py b/src/registrar/migrations/0145_remove_domainrequest_eop_stakeholder_email_and_more.py new file mode 100644 index 000000000..a89d977b5 --- /dev/null +++ b/src/registrar/migrations/0145_remove_domainrequest_eop_stakeholder_email_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.20 on 2025-03-26 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0145_create_groups_v19"), + ] + + operations = [ + migrations.RemoveField( + model_name="domainrequest", + name="eop_stakeholder_email", + ), + migrations.AlterField( + model_name="domainrequest", + name="feb_naming_requirements", + field=models.BooleanField(blank=True, null=True, verbose_name="Meets naming requirements"), + ), + migrations.AlterField( + model_name="domainrequest", + name="feb_naming_requirements_details", + field=models.TextField( + blank=True, + help_text="Required if requested domain that doesn't meet naming requirements", + null=True, + verbose_name="Domain name rationale", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="feb_purpose_choice", + field=models.CharField( + blank=True, + choices=[ + ("new", "Used for a new website"), + ("redirect", "Used as a redirect for an existing website"), + ("other", "Not for a website"), + ], + null=True, + verbose_name="Purpose type", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="interagency_initiative_details", + field=models.TextField(blank=True, null=True, verbose_name="Interagency initiative"), + ), + migrations.AlterField( + model_name="domainrequest", + name="time_frame_details", + field=models.TextField(blank=True, null=True, verbose_name="Target time frame"), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index d68a29ab1..287712516 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -55,9 +55,14 @@ class DomainRequest(TimeStampedModel): return cls(status_name).label if status_name else None class FEBPurposeChoices(models.TextChoices): - WEBSITE = "website" - REDIRECT = "redirect" - OTHER = "other" + WEBSITE = "new", "Used for a new website" + REDIRECT = "redirect", "Used as a redirect for an existing website" + OTHER = "other", "Not for a website" + + @classmethod + def get_purpose_label(cls, purpose_name: str | None): + """Returns the associated label for a given purpose name""" + return cls(purpose_name).label if purpose_name else None class StateTerritoryChoices(models.TextChoices): ALABAMA = "AL", "Alabama (AL)" @@ -510,17 +515,21 @@ class DomainRequest(TimeStampedModel): feb_naming_requirements = models.BooleanField( null=True, blank=True, + verbose_name="Meets naming requirements", ) feb_naming_requirements_details = models.TextField( null=True, blank=True, + help_text="Required if requested domain that doesn't meet naming requirements", + verbose_name="Domain name rationale", ) feb_purpose_choice = models.CharField( null=True, blank=True, choices=FEBPurposeChoices.choices, + verbose_name="Purpose type", ) working_with_eop = models.BooleanField( @@ -531,24 +540,17 @@ class DomainRequest(TimeStampedModel): eop_stakeholder_first_name = models.CharField( null=True, blank=True, - verbose_name="EOP Stakeholder First Name", + verbose_name="EOP contact first name", ) eop_stakeholder_last_name = models.CharField( null=True, blank=True, - verbose_name="EOP Stakeholder Last Name", - ) - - eop_stakeholder_email = models.EmailField( - null=True, - blank=True, - verbose_name="EOP Stakeholder Email", + verbose_name="EOP contact last name", ) # This field is alternately used for generic domain purpose explanations # and for explanations of the specific purpose chosen with feb_purpose_choice - # by a Federal Executive Branch agency. purpose = models.TextField( null=True, blank=True, @@ -562,6 +564,7 @@ class DomainRequest(TimeStampedModel): time_frame_details = models.TextField( null=True, blank=True, + verbose_name="Target time frame", ) is_interagency_initiative = models.BooleanField( @@ -572,6 +575,7 @@ class DomainRequest(TimeStampedModel): interagency_initiative_details = models.TextField( null=True, blank=True, + verbose_name="Interagency initiative", ) alternative_domains = models.ManyToManyField( @@ -1015,11 +1019,15 @@ class DomainRequest(TimeStampedModel): if not context: has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature") is_org_user = has_organization_feature_flag and recipient.has_view_portfolio_permission(self.portfolio) + requires_feb_questions = self.is_feb() and is_org_user + purpose_label = DomainRequest.FEBPurposeChoices.get_purpose_label(self.feb_purpose_choice) context = { "domain_request": self, # This is the user that we refer to in the email "recipient": recipient, "is_org_user": is_org_user, + "requires_feb_questions": requires_feb_questions, + "purpose_label": purpose_label, } if custom_email_content: diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 57df80cb1..60923a48b 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -2,19 +2,25 @@ {% load static field_helpers url_helpers %} {% block form_instructions %} -

Before requesting a .gov domain, please make sure it meets our naming requirements. Your domain name must: +

+ Before requesting a .gov domain, please make sure it meets + {% if requires_feb_questions %} + our naming requirements for executive branch agencies. Your domain name must: + {% else %} + our naming requirements. Your domain name must: + {% endif %}

Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. - {% if not is_federal %}In most instances, this requires including your state's two-letter abbreviation.{% endif %}

+ {% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}

{% if not portfolio %} -

Requests for your organization's initials or an abbreviated name might not be approved, but we encourage you to request the name you want.

+

Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.

{% endif %}

Note that only federal agencies can request generic terms like @@ -29,7 +35,7 @@ {% block form_required_fields_help_text %} -{# empty this block so it doesn't show on this page #} +{# empty this block so it doesn’t show on this page #} {% endblock %} @@ -42,8 +48,8 @@

What .gov domain do you want?

- After you enter your domain, we'll make sure it's available and that it meets some of our naming requirements. - If your domain passes these initial checks, we'll verify that it meets all our requirements after you complete the rest of this form. + After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. + If your domain passes these initial checks, we’ll verify that it meets all our requirements after you complete the rest of this form.

{% with attr_aria_labelledby="domain_instructions domain_instructions2" attr_aria_describedby="id_dotgov_domain-requested_domain--toast" %} {# attr_validate / validate="domain" invokes code in getgov.min.js #} @@ -65,7 +71,7 @@

Alternative domains (optional)

- Are there other domains you'd like if we can't give you your first choice? + Are there other domains you’d like if we can’t give you your first choice?

{% with attr_aria_labelledby="alt_domain_instructions" %} {# Will probably want to remove blank-ok and do related cleanup when we implement delete #} @@ -99,7 +105,7 @@ >Check availability

- If you're not sure this is the domain you want, that's ok. You can change the domain later. + If you’re not sure this is the domain you want, that’s ok. You can change the domain later.

@@ -114,9 +120,10 @@

OMB will review each request against the domain - naming requirements for executive branch agencies - . - Agency submissions are expected to meet each requirement. + naming requirements for executive branch agencies. Agency submissions are expected to meet each requirement. +

+

+ Select one. *

{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% input_with_errors forms.2.feb_naming_requirements %} @@ -124,13 +131,12 @@ {# Conditional Details Field – only shown when the executive naming requirements radio is "False" #} {% endif %} diff --git a/src/registrar/templates/domain_request_purpose.html b/src/registrar/templates/domain_request_purpose.html index 9c6754f22..5e1bd5b76 100644 --- a/src/registrar/templates/domain_request_purpose.html +++ b/src/registrar/templates/domain_request_purpose.html @@ -12,14 +12,12 @@ {% block form_fields %} {% if requires_feb_questions %} -
+
{{forms.0.management_form}} {{forms.1.management_form}} - {{forms.2.management_form}} - {{forms.3.management_form}} - {{forms.4.management_form}} - {{forms.5.management_form}} -

What is the purpose of your requested domain?

+ +

What is the purpose of your requested domain?

+

Select one. *

@@ -28,51 +26,58 @@ {% endwith %} - -

Do you have a target time frame for launching this domain?

-

- Select one. * -

- {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} - {% input_with_errors forms.2.has_timeframe %} - {% endwith %} - -
+
+
+ {{forms.2.management_form}} + {{forms.3.management_form}} + +

Do you have a target time frame for launching this domain?

+

- Provide details below. * + Select one. *

- {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %} - {% input_with_errors forms.3.time_frame_details %} + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.has_timeframe %} {% endwith %} -

Maximum 2000 characters allowed.

- -

Will the domain name be used for an interagency initiative?

-

- Select one. * -

- {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} - {% input_with_errors forms.4.is_interagency_initiative %} - {% endwith %} - -
+
+

+ Provide details on your target time frame. Is there special significance of this day (legal requirement, announcement, event, etc.)? * +

+ {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %} + {% input_with_errors forms.3.time_frame_details %} + {% endwith %} +
+
+
+ {{forms.4.management_form}} + {{forms.5.management_form}} + +

Will the domain name be used for an interagency initiative?

+

- Provide details below. * + Select one. *

- {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %} - {% input_with_errors forms.5.interagency_initiative_details %} + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.4.is_interagency_initiative %} {% endwith %} -

Maximum 2000 characters allowed.

- -
+ +
+

+ Name the agencies that will be involved in this initiative. * +

+ {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %} + {% input_with_errors forms.5.interagency_initiative_details %} + {% endwith %} +
+
{% else %}

What is the purpose of your requested domain?

Describe how you’ll use your .gov domain. Will it be used for a website, email, or something else?

diff --git a/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt index 866fde50f..63a32fa11 100644 --- a/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt +++ b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt @@ -8,12 +8,47 @@ Current websites: {% for site in domain_request.current_websites.all %} {% endfor %}{% endif %} .gov domain: {{ domain_request.requested_domain.name }} +{% if requires_feb_questions %} + Meets naming requirements + {% if domain_request.feb_naming_requirements %} + {{ domain_request.feb_naming_requirements }} + {% else %} + No + {{ domain_request.feb_naming_requirements_details }} + {% endif %} +{% endif %} {% if domain_request.alternative_domains.all %} Alternative domains: {% for site in domain_request.alternative_domains.all %}{% spaceless %}{{ site.website }}{% endspaceless %} {% endfor %}{% endif %} Purpose of your domain: +{% if requires_feb_questions %} +{{ purpose_label }} {{ domain_request.purpose }} +Target time frame: +{% if domain_request.has_target_time_frame %} +{{ domain_request.time_frame_details }} +{% else %} +No +{% endif %} +Interagency initiative: +{% if domain_request.is_interagency_initiative %} +{{ domain_request.interagency_initiative_details }} +{% else %} +No +{% endif %} +{% else %} +{{ domain_request.purpose }} +{% endif %} +{% if requires_feb_questions %} +EOP contact: +{% if domain_request.working_with_eop %} +{{ domain_request.eop_contact.first_name }} {{ domain_request.eop_contact.last_name }} +{{ domain_request.eop_contact.email }} +{% else %} +No +{% endif %} +{% endif %} {% if domain_request.anything_else %} Additional details: {{ domain_request.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 53ad36a3f..51b1ce7cd 100644 --- a/src/registrar/templates/includes/portfolio_request_review_steps.html +++ b/src/registrar/templates/includes/portfolio_request_review_steps.html @@ -10,19 +10,19 @@ {% if step == Step.REQUESTING_ENTITY %} {% with title=form_titles|get_item:step %} {% if domain_request.sub_organization %} - {% include "includes/summary_item.html" with value=domain_request.sub_organization edit_link=domain_request_url %} + {% include "includes/summary_item.html" with title=title value=domain_request.sub_organization editable=is_editable edit_link=domain_request_url %} {% comment %} We don't have city or state_territory for suborganizations yet, so no data should display {% endcomment %} {% elif domain_request.requesting_entity_is_suborganization %} - {% include "includes/summary_item.html" with value=domain_request.requested_suborganization edit_link=domain_request_url %} + {% include "includes/summary_item.html" with title=title value=domain_request.requested_suborganization editable=is_editable edit_link=domain_request_url %}

{{domain_request.suborganization_city}}, {{domain_request.suborganization_state_territory}}

{% elif domain_request.requesting_entity_is_portfolio %} - {% include "includes/summary_item.html" with value=domain_request.portfolio.organization_name edit_link=domain_request_url %} + {% include "includes/summary_item.html" with title=title value=domain_request.portfolio.organization_name editable=is_editable edit_link=domain_request_url %} {% if domain_request.portfolio.city and domain_request.portfolio.state_territory %}

{{domain_request.portfolio.city}}, {{domain_request.portfolio.state_territory}}

{% endif %} {% else %} {% with value="Incomplete"|safe %} - {% include "includes/summary_item.html" with edit_link=domain_request_url %} + {% include "includes/summary_item.html" with title=title editable=is_editable edit_link=domain_request_url %} {% endwith %} {% endif %} {% endwith %} @@ -53,18 +53,78 @@ {% endfor %} {% endif %} + {% if requires_feb_questions %} +

Meets naming requirements

+ {% if domain_request.feb_naming_requirements is None %} +

Incomplete

+ {% elif domain_request.feb_naming_requirements %} +

Yes

+ {% else %} +

No

+

{{domain_request.feb_naming_requirements_details}}

+ {% endif %} + {% endif %} {% endif %} {% if step == Step.PURPOSE %} - {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} - {% endwith %} + {% if requires_feb_questions %} + {% with title=form_titles|get_item:step %} + {% include "includes/summary_item.html" with title=title value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %} + {% endwith %} +

Purpose

+ {% if domain_request.feb_purpose_choice %} +

{{purpose_label}}

+

{{domain_request.purpose}}

+ {% else %} +

Incomplete

+ {% endif %} +

Target time frame

+ {% if domain_request.has_timeframe is None %} +

Incomplete

+ {% elif domain_request.has_timeframe %} +

{{domain_request.time_frame_details}}

+ {% else %} +

No

+ {% endif %} +

Interagency initiative

+ {% if domain_request.is_interagency_initiative is None %} +

Incomplete

+ {% elif domain_request.is_interagency_initiative %} +

{{domain_request.interagency_initiative_details}}

+ {% else %} +

No

+ {% endif %} + {% else %} + {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} + {% endwith %} + {% endif %} {% endif %} {% if step == Step.ADDITIONAL_DETAILS %} - {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"None" %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} - {% endwith %} + {% if requires_feb_questions %} + {% with title=form_titles|get_item:step %} + {% include "includes/summary_item.html" with title=title value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %} + {% endwith %} +

EOP contact

+ {% if domain_request.working_with_eop is None %} +

Incomplete

+ {% elif domain_request.working_with_eop %} +

{{domain_request.eop_stakeholder_first_name}} {{domain_request.eop_stakeholder_last_name}}

+ {% else %} +

No

+ {% endif %} +

Anything else

+ {% if domain_request.anything_else %} +

{{domain_request.anything_else}}

+ {% else %} +

None

+ {% endif %} + {% else %} + {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"None" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} + {% endwith %} + {% endif %} {% endif %} {% if step == Step.REQUIREMENTS %} diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index d7d53dd1a..9f005692e 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,60 +6,62 @@ {% endblock %} {% block form_fields %} - {% if requires_feb_questions %} + {% include "includes/required_fields.html" %} + {% if requires_feb_questions %} +
+ {{forms.0.management_form}} + {{forms.1.management_form}} +

Are you working with someone in the Executive Office of the President (EOP) on this request?

+

Working with the EOP is not required to request a .gov domain.

+

+ Select one. * +

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" add_aria_describedby="working-with-eop--requirement" add_legend_heading="Are you working with someone in the Executive Office of the President (EOP) on this request?" %} + {% input_with_errors forms.0.working_with_eop %} + {% endwith %} + + +
+ +
{{forms.2.management_form}} {{forms.3.management_form}} - {{forms.4.management_form}} - {{forms.5.management_form}} -
-

Are you working with someone in the Executive Office of the President (EOP) on this request?

- -

- Select one. * -

- {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} - {% input_with_errors forms.0.working_with_eop %} - {% endwith %} +

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

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" %} - {% input_with_errors forms.2.has_anything_else_text %} - {% endwith %} - - -
- {% else %} -
-

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" %} - {% input_with_errors forms.0.anything_else %} - {% endwith %} + - {% endif %} +
+ {% else %} +
+

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" %} + {% input_with_errors forms.3.anything_else %} + {% endwith %} +
+ {% endif %} {% endblock %} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 69d3f228e..ddf34211f 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -23,8 +23,10 @@ def input_with_errors(context, field=None): # noqa: C901 add_required_class: like `add_class` but only if field is required add_label_class: append to input element's label's `class` attribute add_legend_class: append to input element's legend's `class` attribute + add_legend_heading: sets the text for the legend associated with this element add_group_class: append to input element's surrounding tag's `class` attribute add_aria_label: append to input element's `aria_label` attribute + add_aria_describedby: appends to input element's `aria-describedby` attribute attr_* - adds or replaces any single html attribute for the input add_error_attr_* - like `attr_*` but only if field.errors is not empty toggleable_input: shows a simple edit button, and adds display-none to the input field. @@ -106,6 +108,8 @@ def input_with_errors(context, field=None): # noqa: C901 elif key == "add_aria_label": aria_labels.append(value) + elif key == "add_aria_describedby": + described_by.append(value) elif key == "sublabel_text": sublabel_text.append(value) diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 68fbf26e7..fd54631ff 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -2064,7 +2064,6 @@ class TestDomainRequestAdmin(MockEppLib): "working_with_eop", "eop_stakeholder_first_name", "eop_stakeholder_last_name", - "eop_stakeholder_email", "purpose", "has_timeframe", "time_frame_details", diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 062c81f45..b1d1b73be 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2615,8 +2615,8 @@ class DomainRequestTests(TestWithUser, WebTest): domain_form = dotgov_page.forms[0] domain = "test.gov" domain_form["dotgov_domain-requested_domain"] = domain - domain_form["dotgov_domain-feb_naming_requirements"] = "True" - domain_form["dotgov_domain-feb_naming_requirements_details"] = "test" + domain_form["dotgov_domain-feb_naming_requirements"] = "False" + domain_form["dotgov_domain-feb_naming_requirements_details"] = "Because this is a test" with patch( "registrar.forms.domain_request_wizard.DotGovDomainForm.clean_requested_domain", return_value=domain ): # noqa @@ -2631,11 +2631,11 @@ class DomainRequestTests(TestWithUser, WebTest): purpose_form = purpose_page.forms[0] purpose_form["purpose-feb_purpose_choice"] = "redirect" - purpose_form["purpose-purpose"] = "test" + purpose_form["purpose-purpose"] = "testPurpose123" purpose_form["purpose-has_timeframe"] = "True" - purpose_form["purpose-time_frame_details"] = "test" + purpose_form["purpose-time_frame_details"] = "1/2/2025 - 1/2/2026" purpose_form["purpose-is_interagency_initiative"] = "True" - purpose_form["purpose-interagency_initiative_details"] = "test" + purpose_form["purpose-interagency_initiative_details"] = "FakeInteragencyInitiative" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) purpose_result = purpose_form.submit() @@ -2646,9 +2646,8 @@ class DomainRequestTests(TestWithUser, WebTest): additional_details_form = additional_details_page.forms[0] additional_details_form["portfolio_additional_details-working_with_eop"] = "True" - additional_details_form["portfolio_additional_details-first_name"] = "Testy" - additional_details_form["portfolio_additional_details-last_name"] = "Tester" - additional_details_form["portfolio_additional_details-email"] = "testy@town.com" + additional_details_form["portfolio_additional_details-first_name"] = "TesterFirstName" + additional_details_form["portfolio_additional_details-last_name"] = "TesterLastName" additional_details_form["portfolio_additional_details-has_anything_else_text"] = "True" additional_details_form["portfolio_additional_details-anything_else"] = "test" self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) @@ -2659,6 +2658,16 @@ class DomainRequestTests(TestWithUser, WebTest): requirements_page = additional_details_result.follow() self.feb_requirements_page_tests(requirements_page) + requirements_form = requirements_page.forms[0] + requirements_form["requirements-is_policy_acknowledged"] = "True" + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + requirements_result = requirements_form.submit() + + # ---- REVIEW PAGE ---- + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + review_page = requirements_result.follow() + self.feb_review_page_tests(review_page) + def feb_purpose_page_tests(self, purpose_page): self.assertContains(purpose_page, "What is the purpose of your requested domain?") @@ -2710,7 +2719,6 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(additional_details_page, "eop-contact-container") self.assertContains(additional_details_page, "additional_details-first_name") self.assertContains(additional_details_page, "additional_details-last_name") - self.assertContains(additional_details_page, "additional_details-email") # Make sure the additional details form is present self.assertContains(additional_details_page, "additional_details-has_anything_else_text") @@ -2733,6 +2741,25 @@ class DomainRequestTests(TestWithUser, WebTest): "I read and understand the guidance outlined in the DOTGOV Act for operating a .gov domain.", ) + def feb_review_page_tests(self, review_page): + # Meets naming requirements + self.assertContains(review_page, "Meets naming requirements") + self.assertContains(review_page, "No") + self.assertContains(review_page, "Because this is a test") + # Purpose + self.assertContains(review_page, "Purpose") + self.assertContains(review_page, "Used as a redirect for an existing website") + self.assertContains(review_page, "testPurpose123") + # Target time frame + self.assertContains(review_page, "Target time frame") + self.assertContains(review_page, "1/2/2025 - 1/2/2026") + # Interagency initiative + self.assertContains(review_page, "Interagency initiative") + self.assertContains(review_page, "FakeInteragencyInitiative") + # EOP contact + self.assertContains(review_page, "EOP contact") + self.assertContains(review_page, "TesterFirstName TesterLastName") + @less_console_noise_decorator def test_domain_request_formsets(self): """Users are able to add more than one of some fields.""" diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index b2e3df55e..00c7a84af 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1841,6 +1841,18 @@ class DomainRequestExport(BaseExport): details = [cisa_rep, model.get("anything_else")] additional_details = " | ".join([field for field in details if field]) + # FEB fields + purpose_type = model.get("feb_purpose_choice") + purpose_type_display = ( + DomainRequest.FEBPurposeChoices.get_purpose_label(purpose_type) if purpose_type else "N/A" + ) + eop_stakeholder_first_name = model.get("eop_stakeholder_first_name") + eop_stakeholder_last_name = model.get("eop_stakeholder_last_name") + if not eop_stakeholder_first_name or not eop_stakeholder_last_name: + eop_stakeholder_name = None + else: + eop_stakeholder_name = f"{eop_stakeholder_first_name} {eop_stakeholder_last_name}" + # create a dictionary of fields which can be included in output. # "extra_fields" are precomputed fields (generated in the DB or parsed). FIELDS = { @@ -1882,6 +1894,12 @@ class DomainRequestExport(BaseExport): "Last submitted date": model.get("last_submitted_date"), "First submitted date": model.get("first_submitted_date"), "Last status update": model.get("last_status_update"), + # FEB only fields + "Purpose": purpose_type_display, + "Domain name rationale": model.get("feb_naming_requirements_details", None), + "Target time frame": model.get("time_frame_details", None), + "Interagency initiative": model.get("interagency_initiative_details", None), + "EOP stakeholder name": eop_stakeholder_name, } row = [FIELDS.get(column, "") for column in columns] @@ -1927,6 +1945,12 @@ class DomainRequestDataType(DomainRequestExport): "Last submitted date", "First submitted date", "Last status update", + "Purpose", + "Domain name rationale", + "Target time frame", + "Interagency initiative", + "EOP stakeholder name", + "EOP stakeholder email", ] @classmethod @@ -2071,6 +2095,12 @@ class DomainRequestDataFull(DomainRequestExport): "CISA regional representative", "Current websites", "Investigator", + "Purpose", + "Domain name rationale", + "Target time frame", + "Interagency initiative", + "EOP stakeholder name", + "EOP stakeholder email", ] @classmethod diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 6e27b9ed4..f6fac176d 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -625,6 +625,11 @@ class PortfolioAdditionalDetails(DomainRequestWizard): 2: FEBAnythingElseYesNoForm 3: PortfolioAnythingElseForm """ + if not self.requires_feb_questions(): + for i in range(3): + forms[i].mark_form_for_deletion() + # If FEB questions aren't required, validate only the anything else form + return forms[3].is_valid() eop_forms_valid = True if not forms[0].is_valid(): # If the user isn't working with EOP, don't validate the EOP contact form @@ -792,11 +797,11 @@ class Purpose(DomainRequestWizard): option = feb_purpose_options_form.cleaned_data.get("feb_purpose_choice") if option == "new": purpose_details_form.fields["purpose"].error_messages = { - "required": "Explain why a new domain is required." + "required": "Provide details on why a new domain is required." } elif option == "redirect": purpose_details_form.fields["purpose"].error_messages = { - "required": "Explain why a redirect is needed." + "required": "Provide details on why a redirect is necessary." } elif option == "other": purpose_details_form.fields["purpose"].error_messages = { @@ -965,6 +970,9 @@ class Review(DomainRequestWizard): context["Step"] = self.get_step_enum().__members__ context["domain_request"] = self.domain_request context["requires_feb_questions"] = self.requires_feb_questions() + context["purpose_label"] = DomainRequest.FEBPurposeChoices.get_purpose_label( + self.domain_request.feb_purpose_choice + ) return context def goto_next_step(self): @@ -1178,6 +1186,10 @@ class PortfolioDomainRequestStatusViewOnly(DetailView): context["Step"] = PortfolioDomainRequestStep.__members__ context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep) context["form_titles"] = wizard.titles + context["requires_feb_questions"] = self.object.is_feb() and flag_is_active_for_user( + self.request.user, "organization_feature" + ) + context["purpose_label"] = DomainRequest.FEBPurposeChoices.get_purpose_label(self.object.feb_purpose_choice) return context