diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6332956f8..9278cab2f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -63,9 +63,21 @@ jobs: # leverage the docker compose setup that we already have for local development run: docker compose up -d - - name: run pa11y + - name: run pa11y in non-org mode working-directory: ./src run: | sleep 20; npm i -g pa11y-ci pa11y-ci + + # - name: Enable org waffle flags + # working-directory: ./src + # # Run a specific command that enables organization_feature, organization_requests, and organization_member. + # run: docker compose exec app ./manage.py enable_org_waffle_flags + + # - name: run pa11y in org mode + # working-directory: ./src + # run: | + # sleep 20; + # npm i -g pa11y-ci + # pa11y-ci diff --git a/src/.pa11yci b/src/.pa11yci index 571d0b1c8..b38a6609d 100644 --- a/src/.pa11yci +++ b/src/.pa11yci @@ -3,27 +3,217 @@ "concurrency": 1, "timeout": 30000 }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "actions": [ + "wait for url to be #" + ], "urls": [ "http://localhost:8080/", - "http://localhost:8080/health/", + "http://localhost:8080/domains/", + "http://localhost:8080/no-organization-domains/", + "http://localhost:8080/members/", + "http://localhost:8080/member/9999/", + "http://localhost:8080/member/9999/delete/", + "http://localhost:8080/member/9999/permissions/", + "http://localhost:8080/member/9999/domains/", + "http://localhost:8080/member/9999/domains/edit/", + "http://localhost:8080/invitedmember/9999/", + "http://localhost:8080/invitedmember/9999/delete/", + "http://localhost:8080/invitedmember/9999/permissions/", + "http://localhost:8080/invitedmember/9999/domains/", + "http://localhost:8080/invitedmember/9999/domains/edit/", + "http://localhost:8080/members/new-member/", + "http://localhost:8080/requests/", + "http://localhost:8080/no-organization-requests/", + "http://localhost:8080/organization/", + "http://localhost:8080/senior-official/", + "http://localhost:8080/admin/analytics/", + "http://localhost:8080/admin/registrar/user/1/transfer/", + "http://localhost:8080/admin/", + "http://localhost:8080/admin/registrar/user/", + "http://localhost:8080/admin/registrar/user/add/", + "http://localhost:8080/admin/registrar/user/1/history/", + "http://localhost:8080/admin/registrar/user/1/delete/", + "http://localhost:8080/admin/registrar/user/1/change/", + "http://localhost:8080/admin/registrar/user/1/", + "http://localhost:8080/admin/registrar/usergroup/", + "http://localhost:8080/admin/registrar/usergroup/add/", + "http://localhost:8080/admin/registrar/usergroup/1/history/", + "http://localhost:8080/admin/registrar/usergroup/1/delete/", + "http://localhost:8080/admin/registrar/usergroup/1/change/", + "http://localhost:8080/admin/registrar/usergroup/1/", + "http://localhost:8080/admin/registrar/userdomainrole/", + "http://localhost:8080/admin/registrar/userdomainrole/add/", + "http://localhost:8080/admin/registrar/userdomainrole/1/history/", + "http://localhost:8080/admin/registrar/userdomainrole/1/delete/", + "http://localhost:8080/admin/registrar/userdomainrole/1/change/", + "http://localhost:8080/admin/registrar/userdomainrole/1/", + "http://localhost:8080/admin/registrar/contact/", + "http://localhost:8080/admin/registrar/contact/add/", + "http://localhost:8080/admin/registrar/contact/1/history/", + "http://localhost:8080/admin/registrar/contact/1/delete/", + "http://localhost:8080/admin/registrar/contact/1/change/", + "http://localhost:8080/admin/registrar/contact/1/", + "http://localhost:8080/admin/registrar/domaininvitation/", + "http://localhost:8080/admin/registrar/domaininvitation/add/", + "http://localhost:8080/admin/registrar/domaininvitation/1/history/", + "http://localhost:8080/admin/registrar/domaininvitation/1/delete/", + "http://localhost:8080/admin/registrar/domaininvitation/1/change/", + "http://localhost:8080/admin/registrar/domaininvitation/1/", + "http://localhost:8080/admin/registrar/domaininformation/", + "http://localhost:8080/admin/registrar/domaininformation/add/", + "http://localhost:8080/admin/registrar/domaininformation/1/history/", + "http://localhost:8080/admin/registrar/domaininformation/1/delete/", + "http://localhost:8080/admin/registrar/domaininformation/1/change/", + "http://localhost:8080/admin/registrar/domaininformation/1/", + "http://localhost:8080/admin/registrar/domain/", + "http://localhost:8080/admin/registrar/domain/add/", + "http://localhost:8080/admin/registrar/domain/1/history/", + "http://localhost:8080/admin/registrar/domain/1/delete/", + "http://localhost:8080/admin/registrar/domain/1/change/", + "http://localhost:8080/admin/registrar/domain/1/", + "http://localhost:8080/admin/registrar/draftdomain/", + "http://localhost:8080/admin/registrar/draftdomain/add/", + "http://localhost:8080/admin/registrar/draftdomain/1/history/", + "http://localhost:8080/admin/registrar/draftdomain/1/delete/", + "http://localhost:8080/admin/registrar/draftdomain/1/change/", + "http://localhost:8080/admin/registrar/draftdomain/1/", + "http://localhost:8080/admin/registrar/federalagency/", + "http://localhost:8080/admin/registrar/federalagency/add/", + "http://localhost:8080/admin/registrar/federalagency/1/history/", + "http://localhost:8080/admin/registrar/federalagency/1/delete/", + "http://localhost:8080/admin/registrar/federalagency/1/change/", + "http://localhost:8080/admin/registrar/federalagency/1/", + "http://localhost:8080/admin/registrar/host/", + "http://localhost:8080/admin/registrar/host/add/", + "http://localhost:8080/admin/registrar/host/1/history/", + "http://localhost:8080/admin/registrar/host/1/delete/", + "http://localhost:8080/admin/registrar/host/1/change/", + "http://localhost:8080/admin/registrar/host/1/", + "http://localhost:8080/admin/registrar/hostip/", + "http://localhost:8080/admin/registrar/hostip/add/", + "http://localhost:8080/admin/registrar/hostip/1/history/", + "http://localhost:8080/admin/registrar/hostip/1/delete/", + "http://localhost:8080/admin/registrar/hostip/1/change/", + "http://localhost:8080/admin/registrar/hostip/1/", + "http://localhost:8080/admin/registrar/website/", + "http://localhost:8080/admin/registrar/website/add/", + "http://localhost:8080/admin/registrar/website/1/history/", + "http://localhost:8080/admin/registrar/website/1/delete/", + "http://localhost:8080/admin/registrar/website/1/change/", + "http://localhost:8080/admin/registrar/website/1/", + "http://localhost:8080/admin/registrar/publiccontact/", + "http://localhost:8080/admin/registrar/publiccontact/add/", + "http://localhost:8080/admin/registrar/publiccontact/1/history/", + "http://localhost:8080/admin/registrar/publiccontact/1/delete/", + "http://localhost:8080/admin/registrar/publiccontact/1/change/", + "http://localhost:8080/admin/registrar/publiccontact/1/", + "http://localhost:8080/admin/registrar/domainrequest/", + "http://localhost:8080/admin/registrar/domainrequest/add/", + "http://localhost:8080/admin/registrar/domainrequest/1/history/", + "http://localhost:8080/admin/registrar/domainrequest/1/delete/", + "http://localhost:8080/admin/registrar/domainrequest/1/change/", + "http://localhost:8080/admin/registrar/domainrequest/1/", + "http://localhost:8080/admin/registrar/transitiondomain/", + "http://localhost:8080/admin/registrar/transitiondomain/add/", + "http://localhost:8080/admin/registrar/transitiondomain/1/history/", + "http://localhost:8080/admin/registrar/transitiondomain/1/delete/", + "http://localhost:8080/admin/registrar/transitiondomain/1/change/", + "http://localhost:8080/admin/registrar/transitiondomain/1/", + "http://localhost:8080/admin/registrar/verifiedbystaff/", + "http://localhost:8080/admin/registrar/verifiedbystaff/add/", + "http://localhost:8080/admin/registrar/verifiedbystaff/1/history/", + "http://localhost:8080/admin/registrar/verifiedbystaff/1/delete/", + "http://localhost:8080/admin/registrar/verifiedbystaff/1/change/", + "http://localhost:8080/admin/registrar/verifiedbystaff/1/", + "http://localhost:8080/admin/registrar/portfolioinvitation/", + "http://localhost:8080/admin/registrar/portfolioinvitation/add/", + "http://localhost:8080/admin/registrar/portfolioinvitation/1/history/", + "http://localhost:8080/admin/registrar/portfolioinvitation/1/delete/", + "http://localhost:8080/admin/registrar/portfolioinvitation/1/change/", + "http://localhost:8080/admin/registrar/portfolioinvitation/1/", + "http://localhost:8080/admin/registrar/portfolio/", + "http://localhost:8080/admin/registrar/portfolio/add/", + "http://localhost:8080/admin/registrar/portfolio/1/history/", + "http://localhost:8080/admin/registrar/portfolio/1/delete/", + "http://localhost:8080/admin/registrar/portfolio/1/change/", + "http://localhost:8080/admin/registrar/portfolio/1/", + "http://localhost:8080/admin/registrar/domaingroup/", + "http://localhost:8080/admin/registrar/domaingroup/add/", + "http://localhost:8080/admin/registrar/domaingroup/1/history/", + "http://localhost:8080/admin/registrar/domaingroup/1/delete/", + "http://localhost:8080/admin/registrar/domaingroup/1/change/", + "http://localhost:8080/admin/registrar/domaingroup/1/", + "http://localhost:8080/admin/registrar/suborganization/", + "http://localhost:8080/admin/registrar/suborganization/add/", + "http://localhost:8080/admin/registrar/suborganization/1/history/", + "http://localhost:8080/admin/registrar/suborganization/1/delete/", + "http://localhost:8080/admin/registrar/suborganization/1/change/", + "http://localhost:8080/admin/registrar/suborganization/1/", + "http://localhost:8080/admin/registrar/seniorofficial/", + "http://localhost:8080/admin/registrar/seniorofficial/add/", + "http://localhost:8080/admin/registrar/seniorofficial/1/history/", + "http://localhost:8080/admin/registrar/seniorofficial/1/delete/", + "http://localhost:8080/admin/registrar/seniorofficial/1/change/", + "http://localhost:8080/admin/registrar/seniorofficial/1/", + "http://localhost:8080/admin/registrar/userportfoliopermission/", + "http://localhost:8080/admin/registrar/userportfoliopermission/add/", + "http://localhost:8080/admin/registrar/userportfoliopermission/1/history/", + "http://localhost:8080/admin/registrar/userportfoliopermission/1/delete/", + "http://localhost:8080/admin/registrar/userportfoliopermission/1/change/", + "http://localhost:8080/admin/registrar/userportfoliopermission/1/", + "http://localhost:8080/admin/registrar/allowedemail/", + "http://localhost:8080/admin/registrar/allowedemail/add/", + "http://localhost:8080/admin/registrar/allowedemail/1/history/", + "http://localhost:8080/admin/registrar/allowedemail/1/delete/", + "http://localhost:8080/admin/registrar/allowedemail/1/change/", + "http://localhost:8080/admin/registrar/allowedemail/1/", + "http://localhost:8080/admin/registrar/", + "http://localhost:8080/admin/1/", + "http://localhost:8080/domain-request/9999/edit/", + "http://localhost:8080/domain-request/9999/", + "http://localhost:8080/domain-request/viewonly/9999/", + "http://localhost:8080/domain-request/9999/withdraw/", + "http://localhost:8080/domain-request/9999/withdrawconfirmed/", "http://localhost:8080/request/", - "http://localhost:8080/request/start", - "http://localhost:8080/request/organization/", - "http://localhost:8080/request/org_federal/", - "http://localhost:8080/request/org_election/", - "http://localhost:8080/request/org_contact/", - "http://localhost:8080/request/senior_official/", - "http://localhost:8080/request/current_sites/", - "http://localhost:8080/request/dotgov_domain/", - "http://localhost:8080/request/purpose/", - "http://localhost:8080/request/your_contact/", - "http://localhost:8080/request/other_contacts/", - "http://localhost:8080/request/anything_else/", - "http://localhost:8080/request/requirements/", + "http://localhost:8080/request/start/", "http://localhost:8080/request/finished/", - "http://localhost:8080/request/requesting_entity/", + "http://localhost:8080/request/9999/generic_org_type/", + "http://localhost:8080/request/9999/tribal_government/", + "http://localhost:8080/request/9999/organization_federal/", + "http://localhost:8080/request/9999/organization_election/", + "http://localhost:8080/request/9999/organization_contact/", + "http://localhost:8080/request/9999/about_your_organization/", + "http://localhost:8080/request/9999/senior_official/", + "http://localhost:8080/request/9999/current_sites/", + "http://localhost:8080/request/9999/dotgov_domain/", + "http://localhost:8080/request/9999/purpose/", + "http://localhost:8080/request/9999/other_contacts/", + "http://localhost:8080/request/9999/additional_details/", + "http://localhost:8080/request/9999/requirements/", + "http://localhost:8080/request/9999/review/", + "http://localhost:8080/request/9999/portfolio_requesting_entity/", + "http://localhost:8080/request/9999/portfolio_additional_details/", + "http://localhost:8080/domain/9999/", + "http://localhost:8080/domain/9999/prototype-dns/", + "http://localhost:8080/domain/9999/users/", + "http://localhost:8080/domain/9999/dns/", + "http://localhost:8080/domain/9999/dns/nameservers/", + "http://localhost:8080/domain/9999/dns/dnssec/", + "http://localhost:8080/domain/9999/dns/dnssec/dsdata/", + "http://localhost:8080/domain/9999/org-name-address/", + "http://localhost:8080/domain/9999/suborganization/", + "http://localhost:8080/domain/9999/senior-official/", + "http://localhost:8080/domain/9999/security-email/", + "http://localhost:8080/domain/9999/renewal/", + "http://localhost:8080/domain/9999/users/add/", + "http://localhost:8080/finish-profile-setup/", "http://localhost:8080/user-profile/", - "http://localhost:8080/members/", - "http://localhost:8080/members/new-member" + "http://localhost:8080/invitation/9999/cancel/", + "http://localhost:8080/domain-request/9999/delete/", + "http://localhost:8080/domain/9999/users/9999/delete/" ] -} +} \ No newline at end of file diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d097c900e..0630dfb3a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -628,6 +628,7 @@ def get_queryset(cls, db_field): class AuditedAdmin(admin.ModelAdmin): """Custom admin to make auditing easier.""" + change_form_template = "django/admin/base_change_form.html" def history_view(self, request, object_id, extra_context=None): """On clicking 'History', take admin to the auditlog view for an object.""" @@ -1115,7 +1116,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) ] - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/base_change_form.html" # We name the custom prop 'contact' because linter # is not allowing a short_description attr on it @@ -1246,7 +1247,6 @@ class Meta: class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Custom website admin class.""" - resource_classes = [WebsiteResource] # Search @@ -2289,11 +2289,12 @@ def custom_election_board(self, obj): @admin.display(description=_("Requested Domain")) def custom_requested_domain(self, obj): # Example: Show different icons based on `status` - url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}" text = obj.requested_domain if obj.portfolio: - return format_html(' {}', url, text) - return format_html('{}', url, text) + return format_html( + f'{escape(text)}' + ) + return text custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore @@ -3086,7 +3087,7 @@ class TransitionDomainAdmin(ListHeaderAdmin): search_fields = ["username", "domain_name"] search_help_text = "Search by user or domain name." - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/base_change_form.html" class DomainInformationInline(admin.StackedInline): @@ -4026,7 +4027,7 @@ class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): resource_classes = [PublicContactResource] - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/base_change_form.html" autocomplete_fields = ["domain"] def changeform_view(self, request, object_id=None, form_url="", extra_context=None): @@ -4052,7 +4053,7 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): "requestor", ] - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/base_change_form.html" def truncated_notes(self, obj): # Truncate the 'notes' field to 50 characters diff --git a/src/registrar/assets/src/js/getgov-admin/button-utils.js b/src/registrar/assets/src/js/getgov-admin/button-utils.js new file mode 100644 index 000000000..e3746d289 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/button-utils.js @@ -0,0 +1,15 @@ +/** + * Initializes buttons to behave like links by navigating to their data-url attribute + * Example usage: + */ +export function initButtonLinks() { + document.querySelectorAll('button.use-button-as-link').forEach(button => { + button.addEventListener('click', function() { + // Equivalent to button.getAttribute("data-href") + const href = this.dataset.href; + if (href) { + window.location.href = href; + } + }); + }); +} diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 5c6de20ab..7eb1fc8cd 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -16,6 +16,7 @@ import { initDynamicPortfolioFields } from './portfolio-form.js'; import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainFields } from './domain-form.js'; import { initAnalyticsDashboard } from './analytics.js'; +import { initButtonLinks } from './button-utils.js'; // General initModals(); @@ -23,6 +24,7 @@ initCopyToClipboard(); initFilterHorizontalWidget(); initDescriptions(); initSubmitBar(); +initButtonLinks(); // Domain request initIneligibleModal(); diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 9a00cf022..bd55bbfcb 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -498,7 +498,7 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too font-size: 13px; } -.object-tools li button { +.object-tools li button, button.addlink { font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif; text-transform: none !important; font-size: 14px !important; @@ -520,6 +520,14 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too } } +// Mimic the style for +.object-tools > p > button.addlink { + background-image: url(../admin/img/tooltag-add.svg) !important; + background-repeat: no-repeat !important; + background-position: right 7px center !important; + padding-right: 25px; +} + .usa-modal--django-admin .usa-prose ul > li { list-style-type: inherit; // Styling based off of the

styling in django admin @@ -984,3 +992,7 @@ ul.add-list-reset { } } + +#result_list > tbody tr > th > a { + text-decoration: underline; +} diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py index 8855194f8..b960c9022 100644 --- a/src/registrar/fixtures/fixtures_domains.py +++ b/src/registrar/fixtures/fixtures_domains.py @@ -1,4 +1,5 @@ from datetime import timedelta +from django.db import transaction, connection from django.utils import timezone import logging import random @@ -8,6 +9,8 @@ from registrar.fixtures.fixtures_users import UserFixture from registrar.models import User, DomainRequest from registrar.models.domain import Domain +from registrar.models.domain_information import DomainInformation +from registrar.models.draft_domain import DraftDomain fake = Faker() logger = logging.getLogger(__name__) @@ -40,6 +43,37 @@ def load(cls): # Approve each user associated with `in review` status domains cls._approve_domain_requests(users) + + # We need a hard-coded domain for our pa11y tests + mythical_creature = User.objects.filter(email="mythical.creature@igorville.gov").first() + mythical_request = DomainRequest.objects.filter(creator=mythical_creature, status=DomainRequest.DomainRequestStatus.APPROVED).first() + print(f"another mythical_request: {mythical_request}") + if mythical_request: + # Update the id of the domain request object + new_domain = mythical_request.approved_domain + print(f"new domain: {new_domain}") + + if new_domain: + cls.update_domain_primary_key(new_domain.id, 9999) + + @staticmethod + def update_domain_primary_key(old_id, new_id): + domain = Domain.objects.get(id=old_id) + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL DEFERRED;") + + for rel in domain._meta.related_objects: + if rel.one_to_many or rel.one_to_one: + related_model = rel.related_model + field_name = rel.field.name + filter_kwargs = {field_name: old_id} + update_kwargs = {field_name: new_id} + + # Update all related records to point to the new primary key + related_model.objects.filter(**filter_kwargs).update(**update_kwargs) + + Domain.objects.filter(id=old_id).update(id=new_id) @staticmethod def _generate_fake_expiration_date(days_in_future=365): @@ -126,7 +160,7 @@ def _bulk_update_requests(cls, domain_requests_to_update): """Bulk update domain requests.""" if domain_requests_to_update: try: - DomainRequest.objects.bulk_update(domain_requests_to_update, ["status", "investigator"]) + DomainRequest.objects.bulk_update(domain_requests_to_update, ["status", "investigator", "approved_domain"]) logger.info(f"Successfully updated {len(domain_requests_to_update)} requests.") except Exception as e: logger.error(f"Unexpected error during requests bulk update: {e}") diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py index 6eee6438f..078343ba9 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -314,6 +314,31 @@ def load(cls): cls._create_domain_requests(users) + # this is a request with a hard-coded id for our pa11y tests. + # Outside of the main for loop for maintability and also due to id conflicts + mythical_creature = User.objects.filter(email="mythical.creature@igorville.gov").first() + print(f"mythical_creature: {mythical_creature}") + if mythical_creature: + print("trying to create a specific request?") + random_request_type = random.choice(cls.DOMAINREQUESTS) + # Action needed rather than started because fixtures auto-approves started + request_data = { + "status": DomainRequest.DomainRequestStatus.ACTION_NEEDED, + "organization_name": "Candy Forest", + } + domain_request = DomainRequest( + created_at=datetime.now(), + id=9999, + creator=mythical_creature, + organization_name=random_request_type["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, mythical_creature) + domain_request.save() + cls._set_many_to_many_relations(domain_request, request_data) + else: + logger.error("Could not create hard-coded user for pa11y tests: user does not exist.") + @classmethod def _create_domain_requests(cls, users): # noqa: C901 """Creates DomainRequests given a list of users.""" @@ -335,6 +360,7 @@ def _create_domain_requests(cls, users): # noqa: C901 creator=user, organization_name=request_data["organization_name"], ) + cls._set_non_foreign_key_fields(domain_request, request_data) cls._set_foreign_key_fields(domain_request, request_data, user) domain_requests_to_create.append(domain_request) @@ -369,6 +395,12 @@ def _create_domain_requests(cls, users): # noqa: C901 cls._set_many_to_many_relations(domain_request, request_data) except Exception as e: logger.warning(e) + + + # mythical_request = DomainRequest.objects.filter(creator__email="mythical.creature@igorville.gov").first() + # if mythical_request and mythical_request.id != 9999: + # mythical_request.id = 9999 + # mythical_request.save() @classmethod def _bulk_create_requests(cls, domain_requests_to_create): diff --git a/src/registrar/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index fdaa1c135..081280693 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -297,6 +297,18 @@ class UserFixture: "last_name": "Abbitt-Analyst", "email": "kaitlin.abbitt@gwe.cisa.dhs.gov", }, + { + # This is a reserved user set at a specific id. + # We use this user for pa11y, since it is a static file. + # Respect the mythical creature, *do not* change the id! + "id": "9999", + "username": "10000000-0000-0000-0000-000000000001", + "first_name": "Mythical", + "last_name": "Creature", + "email": "mythical.creature@igorville.gov", + "title": "Reserved pa11y user", + "phone": "123-456-7890" + }, ] # Additional emails to add to the AllowedEmail whitelist. diff --git a/src/registrar/management/commands/enable_org_waffle_flags.py b/src/registrar/management/commands/enable_org_waffle_flags.py new file mode 100644 index 000000000..901667ca5 --- /dev/null +++ b/src/registrar/management/commands/enable_org_waffle_flags.py @@ -0,0 +1,27 @@ +"""Enables org-related waffle flags""" + +import logging +from waffle.decorators import flag_is_active +from waffle.models import get_waffle_flag_model +from django.core.management import BaseCommand +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Runs the cat command on files from /tmp into the getgov directory." + + def handle(self, **options): + # Check for each flag. This is essentially get_or_create, so we have a db reference. + added_flags = [ + "organization_feature", + "organization_requests", + "organization_members" + ] + for flag_name in added_flags: + # We call flag_is_active first to auto-create the flag in the db. + flag_is_active(None, flag_name) + flag = get_waffle_flag_model().get(flag_name) + if not flag.everyone: + logger.info(f"Setting everyone on flag {flag_name} to True.") + flag.everyone = True + flag.save() diff --git a/src/registrar/management/commands/generate_pa11y_config.py b/src/registrar/management/commands/generate_pa11y_config.py new file mode 100644 index 000000000..62e4c6f3a --- /dev/null +++ b/src/registrar/management/commands/generate_pa11y_config.py @@ -0,0 +1,173 @@ +import os +import re +import json +from urllib.parse import urlparse +from django.urls import URLPattern, URLResolver, get_resolver +from django.core.management.base import BaseCommand + +BASE_URL = "http://localhost:8080/" +class Command(BaseCommand): + """ + Generate the .pa11yci configuration file with all URLs from Django's URLconf. + """ + help = ( + "Generates the .pa11yci file with all URLs found in the URLconf, " + "with dynamic parameters substituted with dummy values and some endpoints excluded." + ) + + def handle(self, *args, **options): + """ + Generate and write the .pa11yci configuration file to the current working directory. + """ + resolver = get_resolver() + urls = self.extract_urls(resolver.url_patterns) + config = { + "defaults": { + "concurrency": 1, + "timeout": 30000 + }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "actions": [ + "wait for url to be #" + ], + "urls": urls, + } + output_file = os.path.join(os.getcwd(), ".pa11yci") + with open(output_file, "w") as f: + json.dump(config, f, indent=4) + self.stdout.write(self.style.SUCCESS(f"Generated {output_file} with {len(urls)} URLs.")) + + def should_exclude(self, url: str) -> bool: + """ + Checks whether a given URL should be excluded based on predefined patterns. + + Args: + url (str): The full URL to test. + Returns: + bool: True if URL should be skipped; otherwise False. + """ + exclude_segments = [ + "__debug__", + "api", + "jsi18n", + "r", + "health", + "todo", + "autocomplete", + "openid", + "logout", + "login", + "password_change", + "reports", + "auditlog", + "password", + "import", + "export", + "process_import", + "process_export", + "waffleflag" + ] + + # Specific endpoints to exclude + exclude_endpoints = { + "/get-domains-json/", + "/get-domain-requests-json/", + "/get-portfolio-members-json/", + "/get-member-domains-json/", + "/admin/analytics/export_data_type/", + "/admin/analytics/export_data_domain_requests_full/", + "/admin/analytics/export_data_full/", + "/admin/analytics/export_data_federal/", + "/admin/analytics/export_domains_growth/", + "/admin/analytics/export_requests_growth/", + "/admin/analytics/export_managed_domains/", + "/admin/analytics/export_unmanaged_domains/", + } + + path = urlparse(url).path + if path in exclude_endpoints: + return True + + path_segments = [seg for seg in path.split("/") if seg] + return any(segment in exclude_segments for segment in path_segments) + + @staticmethod + def substitute_regex_params(route: str) -> str: + """ + Replace regex named capture groups with dummy values. + Args: + route (str): The regex string. + Returns: + str: The regex string with named groups replaced by dummy values. + """ + regex = r"\(\?P<(\w+)>([^)]+)\)" + if route == "(?Pauditlog|registrar)/": + return re.sub(regex, "registrar", route) + else: + return re.sub(regex, "1", route) + + def get_route(self, pattern) -> str: + """ + Extract the route string from a URLPattern or URLResolver, applying appropriate substitutions. + + Args: + pattern: An instance of a Django URL pattern (either using RoutePattern or RegexPattern). + Returns: + str: The processed route string. + """ + if hasattr(pattern.pattern, "_route"): + route = pattern.pattern._route + return self.substitute_params(route) + else: + # For regex patterns, remove anchors and substitute named groups. + route = pattern.pattern.regex.pattern + if route.startswith("^"): + route = route[1:] + if route.endswith("$"): + route = route[:-1] + return self.substitute_regex_params(route) + + @staticmethod + def substitute_params(route: str, full_path: str = "") -> str: + """ + Replace URL parameters with dummy values. + Args: + route (str): The route string (e.g. "domain//delete") + full_path (str): The complete path including prefixes + Returns: + str: The route with parameters replaced. + """ + # Check both the route and the full path for admin + hardcoded_id = "1" if ("admin/" in route or "admin/" in full_path) else "9999" + return re.sub(r"<[^>]+>", hardcoded_id, route) + + def extract_urls(self, urlpatterns, prefix: str = "") -> list: + """ + Recursively extract URLs from the provided urlpatterns list. + """ + urls = [] + for pattern in urlpatterns: + if hasattr(pattern.pattern, "_route"): + route = self.substitute_params(pattern.pattern._route, prefix) + else: + # For regex patterns, remove anchors and substitute named groups + route = pattern.pattern.regex.pattern + if route.startswith("^"): + route = route[1:] + if route.endswith("$"): + route = route[:-1] + route = self.substitute_regex_params(route) + + full_route = prefix + route + if full_route and not full_route.endswith("/"): + full_route += "/" + + full_url = f"{BASE_URL}{full_route}" + if isinstance(pattern, URLPattern) and not self.should_exclude(full_url): + urls.append(full_url) + elif isinstance(pattern, URLResolver): + urls.extend(self.extract_urls(pattern.url_patterns, prefix=full_route)) + return urls diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 2f3d282ea..d2ec555e2 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -7,10 +7,10 @@ {% if has_absolute_url %}

{% else %} @@ -30,18 +30,18 @@ {% endif %}
  • - {% translate "History" %} +
  • {% if opts.model_name == 'domainrequest' %}
  • - +
  • {% endif %} diff --git a/src/registrar/templates/admin/change_list_object_tools.html b/src/registrar/templates/admin/change_list_object_tools.html index 9a046b4bb..5ba88aa3a 100644 --- a/src/registrar/templates/admin/change_list_object_tools.html +++ b/src/registrar/templates/admin/change_list_object_tools.html @@ -5,9 +5,9 @@ {% if has_add_permission %}

    {% url cl.opts|admin_urlname:'add' as add_url %} - +

    {% endif %} {% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_list_results.html b/src/registrar/templates/admin/change_list_results.html index 5e4f37711..c5be04133 100644 --- a/src/registrar/templates/admin/change_list_results.html +++ b/src/registrar/templates/admin/change_list_results.html @@ -19,11 +19,11 @@ {% if results.0|contains_checkbox %} {# .gov - hardcode the select all checkbox #} - +
    - +
    @@ -34,9 +34,9 @@ {% if header.sortable %} {% if header.sort_priority > 0 %}
    - + {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} - +
    {% endif %} {% endif %} @@ -61,10 +61,10 @@ {% endif %} {% with result_value=result.0|extract_value %} - {% with result_label=result.1|extract_a_text %} + {% with result_label=result.1|extract_a_text checkbox_id="select-"|add:result_value %} - - + + {% endwith %} {% endwith %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 20b76217b..30528d77a 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -18,7 +18,17 @@ {{ fieldset.description }} {% else %}

    {{ fieldset.name }}

    + {{ fieldset.description }} {% endif %} + {% else %} + {# Add hidden legend for unnamed fieldsets #} + + {% if opts %} + Overview for {{ opts.verbose_name|capfirst }} + {% else %} + Form section + {% endif %} + {% endif %} {# Customize the markup for the collapse toggle: Do not show a description for the collapse fieldsets, instead we're using the description as a screen reader only legend #} diff --git a/src/registrar/templates/admin/import_export/change_list_export_item.html b/src/registrar/templates/admin/import_export/change_list_export_item.html new file mode 100644 index 000000000..9678d224a --- /dev/null +++ b/src/registrar/templates/admin/import_export/change_list_export_item.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% load admin_urls %} + +{% if has_export_permission %} +{% comment %} Uses the initButtonLinks {% endcomment %} +
  • +{% endif %} diff --git a/src/registrar/templates/admin/import_export/change_list_import_item.html b/src/registrar/templates/admin/import_export/change_list_import_item.html index 8255a8ba7..0f2d59421 100644 --- a/src/registrar/templates/admin/import_export/change_list_import_item.html +++ b/src/registrar/templates/admin/import_export/change_list_import_item.html @@ -3,6 +3,6 @@ {% if has_import_permission %} {% if not IS_PRODUCTION %} -
  • {% trans "Import" %}
  • +
  • {% endif %} {% endif %} diff --git a/src/registrar/templates/admin/search_form.html b/src/registrar/templates/admin/search_form.html new file mode 100644 index 000000000..c5fcf31f8 --- /dev/null +++ b/src/registrar/templates/admin/search_form.html @@ -0,0 +1,26 @@ +{% comment %} This is an override of the django search bar to add better accessibility compliance. +There are no blocks defined here, so we had to copy the code. +https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/search_form.html +{% endcomment %} +{% load i18n static %} +{% if cl.search_fields %} +
    +{% endif %} \ No newline at end of file diff --git a/src/registrar/templates/admin/transfer_user.html b/src/registrar/templates/admin/transfer_user.html index 2c98148f2..480eb8265 100644 --- a/src/registrar/templates/admin/transfer_user.html +++ b/src/registrar/templates/admin/transfer_user.html @@ -154,7 +154,7 @@

    User to receive data

    {{ current_user.email }}
    Phone:
    {{ current_user.phone }}
    -

     

    +

    Data that will be added to:

    Domains:
    {% if current_user_domains %} diff --git a/src/registrar/templates/django/admin/email_clipboard_change_form.html b/src/registrar/templates/django/admin/base_change_form.html similarity index 100% rename from src/registrar/templates/django/admin/email_clipboard_change_form.html rename to src/registrar/templates/django/admin/base_change_form.html diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 7aa0034b9..def178416 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -4,7 +4,7 @@ {% block content %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get-portfolio-json' as url %} - + {{ block.super }} {% endblock content %} diff --git a/src/registrar/templates/django/admin/domain_information_change_form.html b/src/registrar/templates/django/admin/domain_information_change_form.html index 487fd97e1..5db87dc45 100644 --- a/src/registrar/templates/django/admin/domain_information_change_form.html +++ b/src/registrar/templates/django/admin/domain_information_change_form.html @@ -4,7 +4,7 @@ {% block content %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get-portfolio-json' as url %} - + {{ block.super }} {% endblock content %} diff --git a/src/registrar/templates/django/admin/domain_invitation_change_form.html b/src/registrar/templates/django/admin/domain_invitation_change_form.html index 699760fa8..0b83b1c54 100644 --- a/src/registrar/templates/django/admin/domain_invitation_change_form.html +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -1,4 +1,4 @@ -{% extends 'django/admin/email_clipboard_change_form.html' %} +{% extends 'django/admin/base_change_form.html' %} {% load custom_filters %} {% load i18n static %} diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index 46965f236..6669fce8d 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -5,13 +5,13 @@ {% block content %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get-portfolio-json' as url %} - + {{ block.super }} {% endblock content %} {% block field_sets %} {# Create an invisible tag so that we can use a click event to toggle the modal. #} - + {# Store the current object id so we can access it easier #} 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..96e931212 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -111,11 +111,16 @@
    {% with total_websites=field.contents|split:", " %} {% if total_websites|length == 1 %} -

    - - {{ total_websites.0 }} - -

    + {% comment %} Don't include websites that are just " " {% endcomment %} + {% with website=total_websites.0 %} + {% if website%} +

    + + {{ website }} + +

    + {% endif %} + {% endwith %} {% elif total_websites|length > 1 %}