diff --git a/docs/developer/README.md b/docs/developer/README.md index 9fc79dce8..464626b87 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -207,6 +207,17 @@ Linters: docker-compose exec app ./manage.py lint ``` +### Get availability for domain requests to work locally + +If you're on local (localhost:8080) and want to submit a domain request, and keep getting the "We’re experiencing a system error. Please wait a few minutes and try again. If you continue to get this error, contact help@get.gov." error, you can get past the availability check by updating the available() function in registrar/models/domain.py to return True and comment everything else out - see below for reference! + +``` +@classmethod +def available(cls, domain: str) -> bool: + # Comment everything else out in the function + return True +``` + ### Testing behind logged in pages To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ec5c10d51..343624915 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1846,7 +1846,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): requested_user = get_requested_user(requested_email) permission_exists = UserPortfolioPermission.objects.filter( - user__email=requested_email, portfolio=portfolio, user__email__isnull=False + user__email__iexact=requested_email, portfolio=portfolio, user__email__isnull=False ).exists() if not permission_exists: # if permission does not exist for a user with requested_email, send email @@ -1856,9 +1856,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): portfolio=portfolio, is_admin_invitation=is_admin_invitation, ): - messages.warning( - self.request, "Could not send email notification to existing organization admins." - ) + messages.warning(request, "Could not send email notification to existing organization admins.") # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: obj.retrieve() @@ -2758,17 +2756,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "investigator", "portfolio", "sub_organization", + "senior_official", ] filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") # Table ordering # NOTE: This impacts the select2 dropdowns (combobox) - # Currentl, there's only one for requests on DomainInfo + # Currently, there's only one for requests on DomainInfo ordering = ["-last_submitted_date", "requested_domain__name"] - change_form_template = "django/admin/domain_request_change_form.html" - def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) diff --git a/src/registrar/assets/src/js/getgov-admin/andi.js b/src/registrar/assets/src/js/getgov-admin/andi.js new file mode 100644 index 000000000..a6b42b966 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/andi.js @@ -0,0 +1,60 @@ +/* +This function intercepts all select2 dropdowns and adds aria content. +It relies on an override in detail_table_fieldset.html that provides +a span with a corresponding id for aria-describedby content. + +This allows us to avoid overriding aria-label, which is used by select2 +to send the current dropdown selection to ANDI. +*/ +export function initAriaInjectionsForSelect2Dropdowns() { + document.addEventListener('DOMContentLoaded', function () { + // Find all spans with "--aria-description" in their id + const descriptionSpans = document.querySelectorAll('span[id*="--aria-description"]'); + + descriptionSpans.forEach(function (span) { + // Extract the base ID from the span's id (remove "--aria-description") + const fieldId = span.id.replace('--aria-description', ''); + const field = document.getElementById(fieldId); + + if (field) { + // If Select2 is already initialized, apply aria-describedby immediately + if (field.classList.contains('select2-hidden-accessible')) { + applyAriaDescribedBy(field, span.id); + return; + } + + // Use MutationObserver to detect Select2 initialization + const observer = new MutationObserver(function (mutations) { + if (document.getElementById(fieldId)?.classList.contains("select2-hidden-accessible")) { + applyAriaDescribedBy(field, span.id); + observer.disconnect(); // Stop observing after applying attributes + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + }); + + // Function to apply aria-describedby to Select2 UI + function applyAriaDescribedBy(field, descriptionId) { + let select2ElementDetected = false; + const select2Id = "select2-" + field.id + "-container"; + + // Find the Select2 selection box + const select2SpanThatTriggersAria = document.querySelector(`span[aria-labelledby='${select2Id}']`); + + if (select2SpanThatTriggersAria) { + select2SpanThatTriggersAria.setAttribute('aria-describedby', descriptionId); + select2ElementDetected = true; + } + + // If no Select2 component was detected, apply aria-describedby directly to the field + if (!select2ElementDetected) { + field.setAttribute('aria-describedby', descriptionId); + } + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 97ce33b93..6ea73b9f3 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -19,6 +19,7 @@ import { initDynamicDomainInformationFields } from './domain-information-form.js import { initDynamicDomainFields } from './domain-form.js'; import { initAnalyticsDashboard } from './analytics.js'; import { initButtonLinks } from './button-utils.js'; +import { initAriaInjectionsForSelect2Dropdowns } from './andi.js' // General initModals(); @@ -26,6 +27,7 @@ initCopyToClipboard(); initFilterHorizontalWidget(); initDescriptions(); initSubmitBar(); +initAriaInjectionsForSelect2Dropdowns(); initButtonLinks(); // Domain request diff --git a/src/registrar/assets/src/js/getgov/domain-purpose-form.js b/src/registrar/assets/src/js/getgov/domain-purpose-form.js new file mode 100644 index 000000000..7cde5bc35 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-purpose-form.js @@ -0,0 +1,41 @@ +import { showElement } from './helpers.js'; + +export const domain_purpose_choice_callbacks = { + 'new': { + callback: function(value, 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.' + + '

' + // Adding double line break for spacing + 'Include any data that supports a clear public benefit or ' + + 'evidence user need for this new domain. ' + + '*'; + }, + element: document.getElementById('purpose-details-container') + }, + 'redirect': { + callback: function(value, 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 redirect is necessary. ' + + '*'; + }, + element: document.getElementById('purpose-details-container') + }, + 'other': { + callback: function(value, 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 = 'Describe how this domain will be used. ' + + '*'; + }, + element: document.getElementById('purpose-details-container') + } +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 0529d3614..f077448aa 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -1,4 +1,4 @@ -import { hookupYesNoListener } from './radios.js'; +import { hookupYesNoListener, hookupCallbacksToRadioToggler } from './radios.js'; import { initDomainValidators } from './domain-validators.js'; import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js'; import { initFormNameservers } from './form-nameservers' @@ -16,6 +16,7 @@ import { initDomainManagersPage } from './domain-managers.js'; import { initDomainDSData } from './domain-dsdata.js'; import { initDomainDNSSEC } from './domain-dnssec.js'; import { initFormErrorHandling } from './form-errors.js'; +import { domain_purpose_choice_callbacks } from './domain-purpose-form.js'; import { initButtonLinks } from '../getgov-admin/button-utils.js'; initDomainValidators(); @@ -27,6 +28,14 @@ initFormNameservers(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); +hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container"); + +hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); + +hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null); +hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null); + + initializeUrbanizationToggle(); userProfileListener(); diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 055bdf621..d5feb05d5 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -17,7 +17,7 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme 'False': elementIdToShowIfNo }); } - + /** * Hookup listeners for radio togglers in form fields. * @@ -75,3 +75,57 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { handleRadioButtonChange(); } } + +/** + * Hookup listeners for radio togglers in form fields. + * + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - valueToCallbackMap: An object where keys are the values of the radio buttons, + * and values are dictionaries containing a 'callback' key and an optional 'element' key. + * If provided, the element will be passed in as the second argument to the callback function. + * + * Usage Example: + * Assuming you have radio buttons with values 'option1', 'option2', and 'option3', + * and corresponding callback functions 'function1', 'function2', 'function3' that will + * apply to elements 'element1', 'element2', 'element3' respectively. + * + * hookupCallbacksToRadioToggler('exampleRadioGroup', { + * 'option1': {callback: function1, element: element1}, + * 'option2': {callback: function2, element: element2}, + * 'option3': {callback: function3} // No element provided + * }); + * + * Picking the 'option1' radio button will call function1('option1', element1). + * Picking the 'option3' radio button will call function3('option3') without a second parameter. + **/ +export function hookupCallbacksToRadioToggler(radioButtonName, valueToCallbackMap) { + // Get the radio buttons + let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`); + + function handleRadioButtonChange() { + // Find the checked radio button + let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`); + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + // Execute the callback function for the selected value + if (selectedValue && valueToCallbackMap[selectedValue]) { + const entry = valueToCallbackMap[selectedValue]; + if ('element' in entry) { + entry.callback(selectedValue, entry.element); + } else { + entry.callback(selectedValue); + } + } + } + + if (radioButtons && radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // Initialize by checking the current state + handleRadioButtonChange(); + } +} \ No newline at end of file diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 71894ce59..2484cd534 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -99,9 +99,7 @@ body { } .section-outlined__search { flex-grow: 4; - // Align right max-width: 383px; - margin-left: auto; } } } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 12efe5a9f..56f0cfd0f 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -89,52 +89,52 @@ urlpatterns = [ name="members", ), path( - "member/", + "member/", views.PortfolioMemberView.as_view(), name="member", ), path( - "member//delete", + "member//delete", views.PortfolioMemberDeleteView.as_view(), name="member-delete", ), path( - "member//permissions", + "member//permissions", views.PortfolioMemberEditView.as_view(), name="member-permissions", ), path( - "member//domains", + "member//domains", views.PortfolioMemberDomainsView.as_view(), name="member-domains", ), path( - "member//domains/edit", + "member//domains/edit", views.PortfolioMemberDomainsEditView.as_view(), name="member-domains-edit", ), path( - "invitedmember/", + "invitedmember/", views.PortfolioInvitedMemberView.as_view(), name="invitedmember", ), path( - "invitedmember//delete", + "invitedmember//delete", views.PortfolioInvitedMemberDeleteView.as_view(), name="invitedmember-delete", ), path( - "invitedmember//permissions", + "invitedmember//permissions", views.PortfolioInvitedMemberEditView.as_view(), name="invitedmember-permissions", ), path( - "invitedmember//domains", + "invitedmember//domains", views.PortfolioInvitedMemberDomainsView.as_view(), name="invitedmember-domains", ), path( - "invitedmember//domains/edit", + "invitedmember//domains/edit", views.PortfolioInvitedMemberDomainsEditView.as_view(), name="invitedmember-domains-edit", ), diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index 7cb2792f4..b4b5c3bd2 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -1,7 +1,13 @@ +import logging import functools from django.core.exceptions import PermissionDenied from django.utils.decorators import method_decorator from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_portfolio_permission import UserPortfolioPermission + + +logger = logging.getLogger(__name__) # Constants for clarity ALL = "all" @@ -98,24 +104,38 @@ def _user_has_permission(user, request, rules, **kwargs): if not user.is_authenticated or user.is_restricted(): return False + portfolio = request.session.get("portfolio") # Define permission checks permission_checks = [ (IS_STAFF, lambda: user.is_staff), - (IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)), + ( + IS_DOMAIN_MANAGER, + lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs)) + or ( + user.is_org_user(request) + and _is_domain_manager(user, **kwargs) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")) + ), + ), (IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)), (IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)), ( HAS_PORTFOLIO_DOMAINS_VIEW_ALL, - lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")), + lambda: user.is_org_user(request) + and user.has_view_all_domains_portfolio_permission(portfolio) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( HAS_PORTFOLIO_DOMAINS_ANY_PERM, lambda: user.is_org_user(request) - and user.has_any_domains_portfolio_permission(request.session.get("portfolio")), + and user.has_any_domains_portfolio_permission(portfolio) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, - lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request), + lambda: _is_domain_manager(user, **kwargs) + and _is_portfolio_member(request) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, @@ -129,34 +149,55 @@ def _user_has_permission(user, request, rules, **kwargs): ( HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, lambda: user.is_org_user(request) - and user.has_any_requests_portfolio_permission(request.session.get("portfolio")), + and user.has_any_requests_portfolio_permission(portfolio) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, lambda: user.is_org_user(request) - and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")), + and user.has_view_all_domain_requests_portfolio_permission(portfolio) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, - lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")), + lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_MEMBERS_ANY_PERM, lambda: user.is_org_user(request) and ( - user.has_view_members_portfolio_permission(request.session.get("portfolio")) - or user.has_edit_members_portfolio_permission(request.session.get("portfolio")) + user.has_view_members_portfolio_permission(portfolio) + or user.has_edit_members_portfolio_permission(portfolio) + ) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) ), ), ( HAS_PORTFOLIO_MEMBERS_EDIT, lambda: user.is_org_user(request) - and user.has_edit_members_portfolio_permission(request.session.get("portfolio")), + and user.has_edit_members_portfolio_permission(portfolio) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) + ), ), ( HAS_PORTFOLIO_MEMBERS_VIEW, lambda: user.is_org_user(request) - and user.has_view_members_portfolio_permission(request.session.get("portfolio")), + and user.has_view_members_portfolio_permission(portfolio) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) + ), ), ] @@ -191,6 +232,70 @@ def _is_domain_manager(user, **kwargs): return False +def _domain_exists_under_portfolio(portfolio, domain_pk): + """Checks to see if the given domain exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not domain_pk: + logger.warning( + "_domain_exists_under_portfolio => Could not find domain_pk. " + "This is a non-issue if called from the right context." + ) + return True + return Domain.objects.filter(domain_info__portfolio=portfolio, id=domain_pk).exists() + + +def _domain_request_exists_under_portfolio(portfolio, domain_request_pk): + """Checks to see if the given domain request exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not domain_request_pk: + logger.warning( + "_domain_request_exists_under_portfolio => Could not find domain_request_pk. " + "This is a non-issue if called from the right context." + ) + return True + return DomainRequest.objects.filter(portfolio=portfolio, id=domain_request_pk).exists() + + +def _member_exists_under_portfolio(portfolio, member_pk): + """Checks to see if the given UserPortfolioPermission exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not member_pk: + logger.warning( + "_member_exists_under_portfolio => Could not find member_pk. " + "This is a non-issue if called from the right context." + ) + return True + return UserPortfolioPermission.objects.filter(portfolio=portfolio, id=member_pk).exists() + + +def _member_invitation_exists_under_portfolio(portfolio, invitedmember_pk): + """Checks to see if the given PortfolioInvitation exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not invitedmember_pk: + logger.warning( + "_member_invitation_exists_under_portfolio => Could not find invitedmember_pk. " + "This is a non-issue if called from the right context." + ) + return True + return PortfolioInvitation.objects.filter(portfolio=portfolio, id=invitedmember_pk).exists() + + def _is_domain_request_creator(user, domain_request_pk): """Checks to see if the user is the creator of a domain request with domain_request_pk.""" @@ -286,15 +391,3 @@ def _is_staff_managing_domain(request, **kwargs): # the user is permissioned, # and it is in a valid status return True - - -def _has_portfolio_view_all_domains(request, domain_pk): - """Returns whether the user in the request can access the domain - via portfolio view all domains permission.""" - portfolio = request.session.get("portfolio") - if request.user.has_view_all_domains_portfolio_permission(portfolio): - if Domain.objects.filter(id=domain_pk).exists(): - domain = Domain.objects.get(id=domain_pk) - if domain.domain_info.portfolio == portfolio: - return True - return False diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index d7a02b124..779100f73 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -86,7 +86,6 @@ class RequestingEntityForm(RegistrarForm): return {} # get the domain request as a dict, per usual method domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - # set sub_organization to 'other' if is_requesting_new_suborganization is True if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): domain_request_dict["sub_organization"] = "other" @@ -348,7 +347,7 @@ class OrganizationContactForm(RegistrarForm): error_messages={ "required": ("Select the state, territory, or military post where your organization is located.") }, - widget=ComboboxWidget, + widget=ComboboxWidget(attrs={"required": True}), ) zipcode = forms.CharField( label="Zip code", @@ -608,7 +607,10 @@ class DotGovDomainForm(RegistrarForm): ) -class PurposeForm(RegistrarForm): +class PurposeDetailsForm(BaseDeletableRegistrarForm): + + field_name = "purpose" + purpose = forms.CharField( label="Purpose", widget=forms.Textarea( diff --git a/src/registrar/forms/feb.py b/src/registrar/forms/feb.py new file mode 100644 index 000000000..44fd417c2 --- /dev/null +++ b/src/registrar/forms/feb.py @@ -0,0 +1,123 @@ +from django import forms +from django.core.validators import MaxLengthValidator +from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm + + +class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): + """ + Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements. + If the "no" option is selected, details must be provided via the separate details form. + """ + + field_name = "feb_naming_requirements" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.feb_naming_requirements + + +class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): + # Text area for additional details; rendered conditionally when "no" is selected. + feb_naming_requirements_details = forms.CharField( + widget=forms.Textarea(attrs={"maxlength": "2000"}), + max_length=2000, + required=True, + error_messages={"required": ("This field is required.")}, + validators=[ + MaxLengthValidator( + 2000, + message="Response must be less than 2000 characters.", + ) + ], + label="", + help_text="Maximum 2000 characters allowed.", + ) + + +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"), + ) + + feb_purpose_choice = forms.ChoiceField( + required=True, + choices=form_choices, + widget=forms.RadioSelect, + error_messages={ + "required": "This question is required.", + }, + label="Select one", + ) + + +class FEBTimeFrameYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): + """ + Form for determining whether the domain request comes with a target timeframe for launch. + If the "no" option is selected, details must be provided via the separate details form. + """ + + field_name = "has_timeframe" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.has_timeframe + + +class FEBTimeFrameDetailsForm(BaseDeletableRegistrarForm): + time_frame_details = forms.CharField( + label="time_frame_details", + widget=forms.Textarea( + attrs={ + "aria-label": "Provide details on your target timeframe. \ + Is there a special significance to this date (legal requirement, announcement, event, etc)?" + } + ), + validators=[ + MaxLengthValidator( + 2000, + message="Response must be less than 2000 characters.", + ) + ], + error_messages={"required": "Provide details on your target timeframe."}, + ) + + +class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): + """ + Form for determining whether the domain request is part of an interagency initative. + If the "no" option is selected, details must be provided via the separate details form. + """ + + field_name = "is_interagency_initiative" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.is_interagency_initiative + + +class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm): + interagency_initiative_details = forms.CharField( + label="interagency_initiative_details", + widget=forms.Textarea(attrs={"aria-label": "Name the agencies that will be involved in this initiative."}), + validators=[ + MaxLengthValidator( + 2000, + message="Response must be less than 2000 characters.", + ) + ], + error_messages={"required": "Name the agencies that will be involved in this initiative."}, + ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b83e718cb..db1f58d88 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_portfolio_invitation_associations, ) logger = logging.getLogger(__name__) @@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + existing_permissions, existing_invitations = get_portfolio_invitation_associations(self.instance) + + same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) + same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) + if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): + self.add_error( + field, f"{self.instance.email} is already a member of another .gov organization." + ) override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py b/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py new file mode 100644 index 000000000..5a8dbeec8 --- /dev/null +++ b/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.17 on 2025-03-10 19:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0141_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements_details", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="feb_purpose_choice", + field=models.CharField( + blank=True, choices=[("website", "Website"), ("redirect", "Redirect"), ("other", "Other")], null=True + ), + ), + migrations.AddField( + model_name="domainrequest", + name="has_timeframe", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="interagency_initiative_details", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="is_interagency_initiative", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="time_frame_details", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index d3c0ed347..01ed246ac 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -245,6 +245,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" + 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 1cca3742f..5ae51ebc6 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -54,6 +54,11 @@ class DomainRequest(TimeStampedModel): """Returns the associated label for a given status name""" return cls(status_name).label if status_name else None + class FEBPurposeChoices(models.TextChoices): + WEBSITE = "website" + REDIRECT = "redirect" + OTHER = "other" + class StateTerritoryChoices(models.TextChoices): ALABAMA = "AL", "Alabama (AL)" ALASKA = "AK", "Alaska (AK)" @@ -501,6 +506,51 @@ class DomainRequest(TimeStampedModel): on_delete=models.PROTECT, ) + # Fields specific to Federal Executive Branch agencies, used by OMB for reviewing requests + feb_naming_requirements = models.BooleanField( + null=True, + blank=True, + ) + + feb_naming_requirements_details = models.TextField( + null=True, + blank=True, + ) + + feb_purpose_choice = models.CharField( + null=True, + blank=True, + choices=FEBPurposeChoices.choices, + ) + + # 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, + ) + + has_timeframe = models.BooleanField( + null=True, + blank=True, + ) + + time_frame_details = models.TextField( + null=True, + blank=True, + ) + + is_interagency_initiative = models.BooleanField( + null=True, + blank=True, + ) + + interagency_initiative_details = models.TextField( + null=True, + blank=True, + ) + alternative_domains = models.ManyToManyField( "registrar.Website", blank=True, @@ -508,11 +558,6 @@ class DomainRequest(TimeStampedModel): help_text="Other domain names the creator provided for consideration", ) - purpose = models.TextField( - null=True, - blank=True, - ) - other_contacts = models.ManyToManyField( "registrar.Contact", blank=True, @@ -1389,6 +1434,12 @@ class DomainRequest(TimeStampedModel): has_details = False return has_details + def is_feb(self) -> bool: + """Is this domain request for a Federal Executive Branch agency?""" + if self.portfolio: + return self.portfolio.federal_type == BranchChoices.EXECUTIVE + return False + def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 009ea3c26..669985725 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") - UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") - has_portfolio = bool(user_portfolio_permission.portfolio_id) portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions()) @@ -286,8 +283,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( - user=user_portfolio_permission.user + existing_permissions, existing_invitations = get_user_portfolio_permission_associations( + user_portfolio_permission ) if existing_permissions.exists(): raise ValidationError( @@ -296,10 +293,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_permissions", ) - existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( - Q(portfolio=user_portfolio_permission.portfolio) - | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " @@ -308,6 +301,32 @@ def validate_user_portfolio_permission(user_portfolio_permission): ) +def get_user_portfolio_permission_associations(user_portfolio_permission): + """ + Retrieves the associations for a user portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects excluding the current permission. + - existing_invitations: PortfolioInvitation objects for the user email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( + user=user_portfolio_permission.user + ) + existing_invitations = PortfolioInvitation.objects.filter( + email__iexact=user_portfolio_permission.user.email + ).exclude( + Q(portfolio=user_portfolio_permission.portfolio) + | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + + def validate_portfolio_invitation(portfolio_invitation): """ Validates a PortfolioInvitation instance. Located in portfolio_helper to avoid circular imports @@ -324,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() @@ -351,17 +369,12 @@ def validate_portfolio_invitation(portfolio_invitation): ) # == Validate the multiple_porfolios flag. == # - user = User.objects.filter(email=portfolio_invitation.email).first() + user = User.objects.filter(email__iexact=portfolio_invitation.email).first() # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.filter(user=user) - - existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude( - Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) - + existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation) if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -377,6 +390,27 @@ def validate_portfolio_invitation(portfolio_invitation): ) +def get_portfolio_invitation_associations(portfolio_invitation): + """ + Retrieves the associations for a portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects matching the email. + - existing_invitations: PortfolioInvitation objects for the email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.filter(user__email__iexact=portfolio_invitation.email) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=portfolio_invitation.email).exclude( + Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + + def cleanup_after_portfolio_member_deletion(portfolio, email, user=None): """ Cleans up after removing a portfolio member or a portfolio invitation. diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index a074e8a7c..f12bd67f9 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -160,6 +160,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endblock field_readonly %} {% block field_other %} + {% comment %} + .gov override - add Aria messages for select2 dropdowns. These messages are hooked-up to their respective DOM + elements via javascript (see andi.js) + {% endcomment %} + {% if "related_widget_wrapper" in field.field.field.widget.template_name %} + + {{ field.field.label }}, edit, has autocomplete. To set the value, use the arrow keys or type the text. + + {% endif %} + {% if field.field.name == "action_needed_reason_email" %} {{ field.field }} @@ -251,7 +261,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "rejection_reason_email" %} {{ field.field }} - @@ -331,7 +340,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - {% if original_object.rejection_reason_email %} {% else %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 249f69d32..60c931dce 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -58,7 +58,7 @@ {% if request.path|endswith:"renewal"%}

Renew {{domain.name}}

{%else%} -

Domain Overview

+

Domain overview

{% endif%} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index eba0eaf85..a0d477249 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -99,7 +99,7 @@ {% if domain.dnssecdata is not None %} {% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %} {% else %} - {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} + {% include "includes/summary_item.html" with title='DNSSEC' value='Not enabled' edit_link=url editable=is_editable %} {% endif %} {% if portfolio %} diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 9edba1612..57df80cb1 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -2,19 +2,19 @@ {% 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 our naming requirements. Your domain name must:

  • Be available
  • -
  • Relate to your organization’s name, location, and/or services
  • +
  • Relate to your organization's name, location, and/or services
  • Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience)

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 @@ -41,9 +41,10 @@

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 #} {% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %} @@ -63,10 +64,9 @@

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 #} {% with attr_validate="domain" append_gov=True add_label_class="usa-sr-only" add_class="blank-ok alternate-domain-input" %} @@ -83,10 +83,10 @@
Add another alternative domain
-
Check domain 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.

+ {{ forms.2.management_form }} + {{ forms.3.management_form }} - + {% if requires_feb_questions %} +
+ +

Does this submission meet each domain naming requirement?

+
+

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

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.feb_naming_requirements %} + {% endwith %} + + {# Conditional Details Field – only shown when the executive naming requirements radio is "False" #} + +
+ {% endif %} {% endblock %} diff --git a/src/registrar/templates/domain_request_purpose.html b/src/registrar/templates/domain_request_purpose.html index bfd9beb15..9c6754f22 100644 --- a/src/registrar/templates/domain_request_purpose.html +++ b/src/registrar/templates/domain_request_purpose.html @@ -3,17 +3,84 @@ {% block form_instructions %}

.Gov domains are intended for public use. Domains will not be given to organizations that only want to reserve a domain name (defensive registration) or that only intend to use the domain internally (as for an intranet).

-

Read about activities that are prohibited on .gov domains.

-

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?

+

Read about activities that are prohibited on .gov domains.

{% endblock %} {% block form_required_fields_help_text %} -{# commented out so it does not appear on this page #} +{# empty this block so it doesn't show on this page #} {% endblock %} {% 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?

+

+ Select one. * +

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.0.feb_purpose_choice %} + {% 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 %} + +
+

+ Provide details below. * +

+ {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %} + {% input_with_errors forms.3.time_frame_details %} + {% 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 below. * +

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

Maximum 2000 characters allowed.

+
+
+ {% 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?

+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} - {% input_with_errors forms.0.purpose %} + {% input_with_errors forms.1.purpose %} {% endwith %} + {% endif %} {% endblock %} + + diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt index 14dd626dd..35947eb72 100644 --- a/src/registrar/templates/emails/transition_domain_invitation.txt +++ b/src/registrar/templates/emails/transition_domain_invitation.txt @@ -57,7 +57,7 @@ THANK YOU The .gov team .Gov blog -Domain management <{{ manage_url }}}> +Domain management <{{ manage_url }}> Get.gov The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 3c96016eb..1d178d3fb 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -15,14 +15,14 @@ {% include "includes/form_messages.html" %} {% endblock %} -

Manage your domains

- -

- -

+
+

Manage your domains

+
+ +
+
{% include "includes/domains_table.html" with user_domain_count=user_domain_count %} {% include "includes/domain_requests_table.html" %} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 5a57c1bd7..377a69add 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -4,49 +4,20 @@ {% url 'get_domain_requests_json' as url %} -
-
+
+
{% if not portfolio %} -

Domain requests

+
+

Domain requests

+
{% else %} {% endif %} - - - + + {% with label_text=portfolio|yesno:"Search by domain name or creator,Search by domain name" item_name="domain-requests" aria_label_text="Domain requests search component"%} + {% include "includes/search.html" %} + {% endwith %}
{% if portfolio %} diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 567bddd2a..17a956935 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -26,56 +26,32 @@ {% endif %}
-
+
{% if not portfolio %} -

Domains

+
+

Domains

+ + {% if user_domain_count and user_domain_count > 0 %} + {% with export_aria="Domains report component" export_url='export_data_type_user' %} + {% include "includes/export.html" %} + {% endwith %} + {% endif %} +
{% else %} {% endif %} - - {% if user_domain_count and user_domain_count > 0 %} -
-
-
Click to export as csv
- -
-
+ + {% with label_text="Search by domain name" item_name="domains" aria_label_text="Domains search component"%} + {% include "includes/search.html" %} + {% endwith %} + + {% if user_domain_count and user_domain_count > 0 and portfolio%} + {% with export_aria="Domains report component" export_url='export_data_type_user' %} + {% include "includes/export.html" %} + {% endwith %} {% endif %}
- {% if num_expiring_domains > 0 and not portfolio %}
diff --git a/src/registrar/templates/includes/export.html b/src/registrar/templates/includes/export.html new file mode 100644 index 000000000..7ed003e88 --- /dev/null +++ b/src/registrar/templates/includes/export.html @@ -0,0 +1,12 @@ +{% load static %} +
+
+ +
+
+ + \ No newline at end of file diff --git a/src/registrar/templates/includes/member_domains_edit_table.html b/src/registrar/templates/includes/member_domains_edit_table.html index a76f711cc..1d706f89a 100644 --- a/src/registrar/templates/includes/member_domains_edit_table.html +++ b/src/registrar/templates/includes/member_domains_edit_table.html @@ -91,9 +91,9 @@ aria-describedby="You have unsaved changes that will be lost." > {% if portfolio_permission %} - {% url 'member-domains' pk=portfolio_permission.id as url %} + {% url 'member-domains' member_pk=portfolio_permission.id as url %} {% else %} - {% url 'invitedmember-domains' pk=portfolio_invitation.id as url %} + {% url 'invitedmember-domains' invitedmember_pk=portfolio_invitation.id as url %} {% endif %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_url=url modal_button_text="Continue without saving" %} diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 528f9a5dc..f93b0d3c8 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -7,47 +7,14 @@
- - -
-
-
Click to export as csv
- -
-
-
+ + {% with label_text="Search by member name" item_name="members" aria_label_text="Members search component"%} + {% include "includes/search.html" %} + {% endwith %} + {% with export_aria="Members report component" export_url='export_members_portfolio' %} + {% include "includes/export.html" %} + {% endwith %} +