diff --git a/.github/workflows/security-check.yaml b/.github/workflows/security-check.yaml index aea700613..bf0498fff 100644 --- a/.github/workflows/security-check.yaml +++ b/.github/workflows/security-check.yaml @@ -2,17 +2,9 @@ name: Security checks on: push: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main pull_request: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 642e9dc30..6332956f8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,10 +3,6 @@ name: Testing on: push: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main pull_request: diff --git a/docs/developer/cloning-databases.md b/docs/developer/cloning-databases.md new file mode 100644 index 000000000..3c8a3c3fa --- /dev/null +++ b/docs/developer/cloning-databases.md @@ -0,0 +1,17 @@ +# Cloning Databases +The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions. + +## Additional Roles Required +The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`. + +## Turning Off DB Cloning Fast (For Emergencies or other Scenarios) +Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger. + +Step 1: +Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want. + +step 2: +Remove the space developer role by doing the following command: +`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper` + +This will cause the job to fail without requiring pushing anything to main. diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 0863aa0b7..cdef3dba7 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -907,14 +907,87 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi ```docker-compose exec app ./manage.py create_federal_portfolio --agency_name "{federal_agency_name}" --both``` ##### Parameters -| | Parameter | Description | -|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------| -| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | -| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial | -| 3 | **both** | If True, runs parse_requests and parse_domains. | -| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | -| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | +| | Parameter | Description | +|:-:|:---------------------------- |:-------------------------------------------------------------------------------------------| +| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | +| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial | +| 3 | **both** | If True, runs parse_requests and parse_domains. | +| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | +| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | +| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. | - Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both. - Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them, you must specify at least one to run this script. + + +## Patch suborganizations +This script deletes some duplicate suborganization data that exists in our database (one-time use). +It works in two ways: +1. If the only name difference between two suborg records is extra spaces or a capitalization difference, +then we delete all duplicate records of this type. +2. If the suborg name is one we manually specify to delete via the script. + +Before it deletes records, it goes through each DomainInformation and DomainRequest object and updates the reference to "sub_organization" to match the non-duplicative record. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +To create a specific portfolio: +```./manage.py patch_suborganizations``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py patch_suborganizations``` + + +## Remove Non-whitelisted Portfolios +This script removes Portfolio entries from the database that are not part of a predefined list of allowed portfolios (`ALLOWED_PORTFOLIOS`). +It performs the following actions: +1. Prompts the user for confirmation before proceeding with deletions. +2. Updates related objects such as `DomainInformation`, `Domain`, and `DomainRequest` to set their `portfolio` field to `None` to prevent integrity errors. +3. Deletes associated objects such as `PortfolioInvitation`, `UserPortfolioPermission`, and `Suborganization`. +4. Logs a detailed summary of all cascading deletions and orphaned objects. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-nl` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +To remove portfolios: +```./manage.py remove_unused_portfolios``` + +If you wish to enable debug mode for additional logging: +```./manage.py remove_unused_portfolios --debug``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py remove_unused_portfolios``` + +To enable debug mode locally: +```docker-compose exec app ./manage.py remove_unused_portfolios --debug``` \ No newline at end of file diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 41e442f2d..38fde0ced 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -21,49 +21,66 @@ class OpenIdConnectBackend(ModelBackend): """ def authenticate(self, request, **kwargs): - logger.debug("kwargs %s" % kwargs) - user = None - if not kwargs or "sub" not in kwargs.keys(): - return user + logger.debug("kwargs %s", kwargs) + + if not kwargs or "sub" not in kwargs: + return None UserModel = get_user_model() username = self.clean_username(kwargs["sub"]) + openid_data = self.extract_openid_data(kwargs) - # Some OP may actually choose to withhold some information, so we must - # test if it is present - openid_data = {"last_login": timezone.now()} - openid_data["first_name"] = kwargs.get("given_name", "") - openid_data["last_name"] = kwargs.get("family_name", "") - openid_data["email"] = kwargs.get("email", "") - openid_data["phone"] = kwargs.get("phone", "") - - # Note that this could be accomplished in one try-except clause, but - # instead we use get_or_create when creating unknown users since it has - # built-in safeguards for multiple threads. if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True): - args = { - UserModel.USERNAME_FIELD: username, - # defaults _will_ be updated, these are not fallbacks - "defaults": openid_data, - } - - user, created = UserModel.objects.get_or_create(**args) - - if not created: - # If user exists, update existing user - self.update_existing_user(user, args["defaults"]) - else: - # If user is created, configure the user - user = self.configure_user(user, **kwargs) + user = self.get_or_create_user(UserModel, username, openid_data, kwargs) else: - try: - user = UserModel.objects.get_by_natural_key(username) - except UserModel.DoesNotExist: - return None - # run this callback for a each login - user.on_each_login() + user = self.get_user_by_username(UserModel, username) + + if user: + user.on_each_login() + return user + def extract_openid_data(self, kwargs): + """Extract OpenID data from authentication kwargs.""" + return { + "last_login": timezone.now(), + "first_name": kwargs.get("given_name", ""), + "last_name": kwargs.get("family_name", ""), + "email": kwargs.get("email", ""), + "phone": kwargs.get("phone", ""), + } + + def get_or_create_user(self, UserModel, username, openid_data, kwargs): + """Retrieve user by username or email, or create a new user.""" + user = self.get_user_by_username(UserModel, username) + + if not user and openid_data["email"]: + user = self.get_user_by_email(UserModel, openid_data["email"]) + if user: + # if found by email, update the username + setattr(user, UserModel.USERNAME_FIELD, username) + + if not user: + user = UserModel.objects.create(**{UserModel.USERNAME_FIELD: username}, **openid_data) + return self.configure_user(user, **kwargs) + + self.update_existing_user(user, openid_data) + return user + + def get_user_by_username(self, UserModel, username): + """Retrieve user by username.""" + try: + return UserModel.objects.get(**{UserModel.USERNAME_FIELD: username}) + except UserModel.DoesNotExist: + return None + + def get_user_by_email(self, UserModel, email): + """Retrieve user by email.""" + try: + return UserModel.objects.get(email=email) + except UserModel.DoesNotExist: + return None + def update_existing_user(self, user, kwargs): """ Update user fields without overwriting certain fields. diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py index c15106fa9..4e8f80a23 100644 --- a/src/djangooidc/tests/test_backends.py +++ b/src/djangooidc/tests/test_backends.py @@ -1,5 +1,6 @@ from django.test import TestCase from registrar.models import User +from api.tests.common import less_console_noise_decorator from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure @@ -17,6 +18,7 @@ class OpenIdConnectBackendTestCase(TestCase): def tearDown(self) -> None: User.objects.all().delete() + @less_console_noise_decorator def test_authenticate_with_create_user(self): """Test that authenticate creates a new user if it does not find existing user""" @@ -32,6 +34,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator def test_authenticate_with_existing_user(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are supplied""" @@ -50,6 +53,30 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator + def test_authenticate_with_existing_user_same_email_different_username(self): + """Test that authenticate updates an existing user if it finds one. + In this case, match is to an existing record with matching email but + a non-matching username. The existing record's username should be udpated. + For this test, given_name and family_name are supplied""" + # Create an existing user with the same username + User.objects.create_user(username="old_username", email="john.doe@example.com") + + # Ensure that the authenticate method updates the existing user + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + + # Verify that user fields are correctly updated + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + self.assertEqual(user.username, "test_user") + # Assert that a user no longer exists by the old username + self.assertFalse(User.objects.filter(username="old_username").exists()) + + @less_console_noise_decorator def test_authenticate_with_existing_user_with_existing_first_last_phone(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are not supplied. @@ -79,6 +106,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "9999999999") + @less_console_noise_decorator def test_authenticate_with_existing_user_different_name_phone(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are supplied and overwrite""" @@ -100,6 +128,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator def test_authenticate_with_unknown_user(self): """Test that authenticate returns None when no kwargs are supplied""" # Ensure that the authenticate method handles the case when the user is not found diff --git a/src/package-lock.json b/src/package-lock.json index a769abdf0..5caff976c 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -7074,9 +7074,9 @@ } }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/src/registrar/admin.py b/src/registrar/admin.py index bb42b66c6..8ecf36f52 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -14,6 +14,7 @@ from django.db.models import ( from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.utility.admin_helpers import ( AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, @@ -27,8 +28,12 @@ from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from registrar.utility.email import EmailSendingError -from registrar.utility.email_invitations import send_portfolio_invitation_email +from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email +from registrar.views.utility.invitation_helper import ( + get_org_membership, + get_requested_user, + handle_invitation_exceptions, +) from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -41,7 +46,7 @@ from waffle.admin import FlagAdmin from waffle.models import Sample, Switch from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices -from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError +from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.waffle import flag_is_active_for_user from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -1217,9 +1222,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SeniorOfficialAdmin(ListHeaderAdmin): """Custom Senior Official Admin class.""" - search_fields = ["first_name", "last_name", "email"] + search_fields = ["first_name", "last_name", "email", "federal_agency__agency"] search_help_text = "Search by first name, last name or email." - list_display = ["first_name", "last_name", "email", "federal_agency"] + list_display = ["federal_agency", "first_name", "last_name", "email"] # this ordering effects the ordering of results # in autocomplete_fields for Senior Official @@ -1362,6 +1367,8 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): autocomplete_fields = ["user", "domain"] + change_form_template = "django/admin/user_domain_role_change_form.html" + # Fixes a bug where non-superusers are redirected to the main page def delete_view(self, request, object_id, extra_context=None): """Custom delete_view implementation that specifies redirect behaviour""" @@ -1389,169 +1396,9 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().changeform_view(request, object_id, form_url, extra_context=extra_context) -class DomainInvitationAdmin(ListHeaderAdmin): - """Custom domain invitation admin class.""" - - class Meta: - model = models.DomainInvitation - fields = "__all__" - - _meta = Meta() - - # Columns - list_display = [ - "email", - "domain", - "status", - ] - - # Search - search_fields = [ - "email", - "domain__name", - ] - - # Filters - list_filter = ("status",) - - search_help_text = "Search by email or domain." - - # Mark the FSM field 'status' as readonly - # to allow admin users to create Domain Invitations - # without triggering the FSM Transition Not Allowed - # error. - readonly_fields = ["status"] - - autocomplete_fields = ["domain"] - - change_form_template = "django/admin/email_clipboard_change_form.html" - - # Select domain invitations to change -> Domain invitations - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Domain invitations" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - - def save_model(self, request, obj, form, change): - """ - Override the save_model method. - - On creation of a new domain invitation, attempt to retrieve the invitation, - which will be successful if a single User exists for that email; otherwise, will - just continue to create the invitation. - """ - if not change and User.objects.filter(email=obj.email).count() == 1: - # Domain Invitation creation for an existing User - obj.retrieve() - # Call the parent save method to save the object - super().save_model(request, obj, form, change) - - -class PortfolioInvitationAdmin(ListHeaderAdmin): - """Custom portfolio invitation admin class.""" - - form = PortfolioInvitationAdminForm - - class Meta: - model = models.PortfolioInvitation - fields = "__all__" - - _meta = Meta() - - # Columns - list_display = [ - "email", - "portfolio", - "roles", - "additional_permissions", - "status", - ] - - # Search - search_fields = [ - "email", - "portfolio__name", - ] - - # Filters - list_filter = ("status",) - - search_help_text = "Search by email or portfolio." - - # Mark the FSM field 'status' as readonly - # to allow admin users to create Domain Invitations - # without triggering the FSM Transition Not Allowed - # error. - readonly_fields = ["status"] - - autocomplete_fields = ["portfolio"] - - change_form_template = "django/admin/portfolio_invitation_change_form.html" - - # Select portfolio invitations to change -> Portfolio invitations - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Portfolio invitations" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - - def save_model(self, request, obj, form, change): - """ - Override the save_model method. - - Only send email on creation of the PortfolioInvitation object. Not on updates. - Emails sent to requested user / email. - When exceptions are raised, return without saving model. - """ - if not change: # Only send email if this is a new PortfolioInvitation (creation) - portfolio = obj.portfolio - requested_email = obj.email - requestor = request.user - - permission_exists = UserPortfolioPermission.objects.filter( - user__email=requested_email, portfolio=portfolio, user__email__isnull=False - ).exists() - try: - if not permission_exists: - # if permission does not exist for a user with requested_email, send email - send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) - messages.success(request, f"{requested_email} has been invited.") - else: - messages.warning(request, "User is already a member of this portfolio.") - except Exception as e: - # when exception is raised, handle and do not save the model - self._handle_exceptions(e, request, obj) - return - # Call the parent save method to save the object - super().save_model(request, obj, form, change) - - def _handle_exceptions(self, exception, request, obj): - """Handle exceptions raised during the process. - - Log warnings / errors, and message errors to the user. - """ - if isinstance(exception, EmailSendingError): - logger.warning( - "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", - obj.email, - obj.portfolio, - exc_info=True, - ) - messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") - elif isinstance(exception, MissingEmailError): - messages.error(request, str(exception)) - logger.error( - f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. " - f"No email exists for the requestor.", - exc_info=True, - ) - - else: - logger.warning("Could not send email invitation (Other Exception)", exc_info=True) - messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") +class BaseInvitationAdmin(ListHeaderAdmin): + """Base class for admin classes which will customize save_model and send email invitations + on model adds, and require custom handling of forms and form errors.""" def response_add(self, request, obj, post_url_continue=None): """ @@ -1560,8 +1407,9 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): Normal flow on successful save_model on add is to redirect to changelist_view. If there are errors, flow is modified to instead render change form. """ - # Check if there are any error or warning messages in the `messages` framework + # store current messages from request so that they are preserved throughout the method storage = get_messages(request) + # Check if there are any error or warning messages in the `messages` framework has_errors = any(message.level_tag in ["error", "warning"] for message in storage) if has_errors: @@ -1608,7 +1456,206 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): change=False, obj=obj, ) - return super().response_add(request, obj, post_url_continue) + + response = super().response_add(request, obj, post_url_continue) + + # Re-add all messages from storage after `super().response_add` + # as super().response_add resets the success messages in request + for message in storage: + messages.add_message(request, message.level, message.message) + + return response + + +class DomainInvitationAdmin(BaseInvitationAdmin): + """Custom domain invitation admin class.""" + + class Meta: + model = models.DomainInvitation + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "email", + "domain", + "status", + ] + + # Search + search_fields = [ + "email", + "domain__name", + ] + + # Filters + list_filter = ("status",) + + search_help_text = "Search by email or domain." + + # Mark the FSM field 'status' as readonly + # to allow admin users to create Domain Invitations + # without triggering the FSM Transition Not Allowed + # error. + readonly_fields = ["status"] + + autocomplete_fields = ["domain"] + + change_form_template = "django/admin/domain_invitation_change_form.html" + + # Select domain invitations to change -> Domain invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Domain invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + + def save_model(self, request, obj, form, change): + """ + Override the save_model method. + + On creation of a new domain invitation, attempt to retrieve the invitation, + which will be successful if a single User exists for that email; otherwise, will + just continue to create the invitation. + """ + if not change: + domain = obj.domain + domain_org = getattr(domain.domain_info, "portfolio", None) + requested_email = obj.email + # Look up a user with that email + requested_user = get_requested_user(requested_email) + requestor = request.user + + member_of_a_different_org, member_of_this_org = get_org_membership( + domain_org, requested_email, requested_user + ) + + try: + if ( + flag_is_active(request, "organization_feature") + and not flag_is_active(request, "multiple_portfolios") + and domain_org is not None + and not member_of_this_org + and not member_of_a_different_org + ): + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + email=requested_email, + portfolio=domain_org, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + portfolio_invitation.retrieve() + portfolio_invitation.save() + messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}") + + send_domain_invitation_email( + email=requested_email, + requestor=requestor, + domains=domain, + is_member_of_different_org=member_of_a_different_org, + requested_user=requested_user, + ) + if requested_user is not None: + # Domain Invitation creation for an existing User + obj.retrieve() + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + messages.success(request, f"{requested_email} has been invited to the domain: {domain}") + except Exception as e: + handle_invitation_exceptions(request, e, requested_email) + return + else: + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + + +class PortfolioInvitationAdmin(BaseInvitationAdmin): + """Custom portfolio invitation admin class.""" + + form = PortfolioInvitationAdminForm + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "email", + "portfolio", + "roles", + "additional_permissions", + "status", + ] + + # Search + search_fields = [ + "email", + "portfolio__organization_name", + ] + + # Filters + list_filter = ("status",) + + search_help_text = "Search by email or portfolio." + + # Mark the FSM field 'status' as readonly + # to allow admin users to create Domain Invitations + # without triggering the FSM Transition Not Allowed + # error. + readonly_fields = ["status"] + + autocomplete_fields = ["portfolio"] + + change_form_template = "django/admin/portfolio_invitation_change_form.html" + + # Select portfolio invitations to change -> Portfolio invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Portfolio invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + + def save_model(self, request, obj, form, change): + """ + Override the save_model method. + + Only send email on creation of the PortfolioInvitation object. Not on updates. + Emails sent to requested user / email. + When exceptions are raised, return without saving model. + """ + if not change: # Only send email if this is a new PortfolioInvitation (creation) + portfolio = obj.portfolio + requested_email = obj.email + requestor = request.user + # Look up a user with that email + requested_user = get_requested_user(requested_email) + + permission_exists = UserPortfolioPermission.objects.filter( + user__email=requested_email, portfolio=portfolio, user__email__isnull=False + ).exists() + try: + if not permission_exists: + # if permission does not exist for a user with requested_email, send email + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + obj.retrieve() + messages.success(request, f"{requested_email} has been invited.") + else: + messages.warning(request, "User is already a member of this portfolio.") + except Exception as e: + # when exception is raised, handle and do not save the model + handle_invitation_exceptions(request, e, requested_email) + return + # Call the parent save method to save the object + super().save_model(request, obj, form, change) class DomainInformationResource(resources.ModelResource): @@ -1631,22 +1678,25 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + DomainInformation.objects.annotate( + converted_generic_org=Case( + When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"), + When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_generic_org = domain_info.converted_generic_org_type # Actual value - converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__organization_type=self.value()) | Q(portfolio__isnull=True, generic_org_type=self.value()) @@ -1984,22 +2034,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + DomainRequest.objects.annotate( + converted_generic_org=Case( + When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"), + When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_request in DomainRequest.objects.all(): - converted_generic_org = domain_request.converted_generic_org_type # Actual value - converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__organization_type=self.value()) | Q(portfolio__isnull=True, generic_org_type=self.value()) @@ -2015,24 +2068,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_federal_types" def lookups(self, request, model_admin): - converted_federal_types = set() - - # Populate the set with tuples of (value, display value) - for domain_request in DomainRequest.objects.all(): - converted_federal_type = domain_request.converted_federal_type # Actual value - converted_federal_type_display = domain_request.converted_federal_type_display # Display value - - if converted_federal_type: - converted_federal_types.add( - (converted_federal_type, converted_federal_type_display) # Value, Display + # Annotate the queryset for efficient filtering + queryset = ( + DomainRequest.objects.annotate( + converted_federal_type=Case( + When( + portfolio__isnull=False, + portfolio__federal_agency__federal_type__isnull=False, + then="portfolio__federal_agency__federal_type", + ), + When( + portfolio__isnull=True, + federal_agency__federal_type__isnull=False, + then="federal_agency__federal_type", + ), + default=Value(""), + output_field=CharField(), ) + ) + .values_list("converted_federal_type", flat=True) + .distinct() + ) - # Sort the set by display value - return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value + # Filter out empty values and return sorted unique entries + return sorted( + [ + (federal_type, BranchChoices.get_branch_label(federal_type)) + for federal_type in queryset + if federal_type + ] + ) - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a federal type is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__federal_agency__federal_type=self.value()) | Q(portfolio__isnull=True, federal_type=self.value()) @@ -3179,59 +3247,86 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + Domain.objects.annotate( + converted_generic_org=Case( + When( + domain_info__isnull=False, + domain_info__portfolio__organization_type__isnull=False, + then="domain_info__portfolio__organization_type", + ), + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=True, + domain_info__generic_org_type__isnull=False, + then="domain_info__generic_org_type", + ), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_generic_org = domain_info.converted_generic_org_type # Actual value - converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(domain_info__portfolio__organization_type=self.value()) | Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value()) ) - return queryset class FederalTypeFilter(admin.SimpleListFilter): """Custom Federal Type filter that accomodates portfolio feature. If we have a portfolio, use the portfolio's federal type. If not, use the - federal type in the Domain Information object.""" + organization in the Domain Request object.""" title = "federal type" parameter_name = "converted_federal_types" def lookups(self, request, model_admin): - converted_federal_types = set() - - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_federal_type = domain_info.converted_federal_type # Actual value - converted_federal_type_display = domain_info.converted_federal_type_display # Display value - - if converted_federal_type: - converted_federal_types.add( - (converted_federal_type, converted_federal_type_display) # Value, Display + # Annotate the queryset for efficient filtering + queryset = ( + Domain.objects.annotate( + converted_federal_type=Case( + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=False, + then=F("domain_info__portfolio__federal_agency__federal_type"), + ), + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=True, + domain_info__federal_type__isnull=False, + then="domain_info__federal_agency__federal_type", + ), + default=Value(""), + output_field=CharField(), ) + ) + .values_list("converted_federal_type", flat=True) + .distinct() + ) - # Sort the set by display value - return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value + # Filter out empty values and return sorted unique entries + return sorted( + [ + (federal_type, BranchChoices.get_branch_label(federal_type)) + for federal_type in queryset + if federal_type + ] + ) - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a federal type is selected in the filter + if self.value(): return queryset.filter( - Q(domain_info__portfolio__federal_agency__federal_type=self.value()) - | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value()) + Q(domain_info__portfolio__federal_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value()) ) return queryset diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index f59417b41..9d4dd2e51 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -29,6 +29,7 @@ * - tooltip dynamic content updated to include nested element (for better sizing control) * - modal exposed to window to be accessible in other js files * - fixed bug in createHeaderButton which added newlines to header button tooltips + * - modified combobox to handle error class */ if ("document" in window.self) { @@ -1213,6 +1214,11 @@ const enhanceComboBox = _comboBoxEl => { input.setAttribute("class", INPUT_CLASS); input.setAttribute("type", "text"); input.setAttribute("role", "combobox"); + // DOTGOV - handle error class for combobox + // Check if 'usa-input--error' exists in selectEl and add it to input if true + if (selectEl.classList.contains('usa-input--error')) { + input.classList.add('usa-input--error'); + } additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => { const value = Sanitizer.escapeHTML`${attr[key]}`; input.setAttribute(key, value); diff --git a/src/registrar/assets/src/js/getgov/combobox.js b/src/registrar/assets/src/js/getgov/combobox.js deleted file mode 100644 index 36b7aa0ad..000000000 --- a/src/registrar/assets/src/js/getgov/combobox.js +++ /dev/null @@ -1,113 +0,0 @@ -import { hideElement, showElement } from './helpers.js'; - -export function loadInitialValuesForComboBoxes() { - var overrideDefaultClearButton = true; - var isTyping = false; - - document.addEventListener('DOMContentLoaded', (event) => { - handleAllComboBoxElements(); - }); - - function handleAllComboBoxElements() { - const comboBoxElements = document.querySelectorAll(".usa-combo-box"); - comboBoxElements.forEach(comboBox => { - const input = comboBox.querySelector("input"); - const select = comboBox.querySelector("select"); - if (!input || !select) { - console.warn("No combobox element found"); - return; - } - // Set the initial value of the combobox - let initialValue = select.getAttribute("data-default-value"); - let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input"); - if (!clearInputButton) { - console.warn("No clear element found"); - return; - } - - // Override the default clear button behavior such that it no longer clears the input, - // it just resets to the data-initial-value. - // Due to the nature of how uswds works, this is slightly hacky. - // Use a MutationObserver to watch for changes in the dropdown list - const dropdownList = comboBox.querySelector(`#${input.id}--list`); - const observer = new MutationObserver(function(mutations) { - mutations.forEach(function(mutation) { - if (mutation.type === "childList") { - addBlankOption(clearInputButton, dropdownList, initialValue); - } - }); - }); - - // Configure the observer to watch for changes in the dropdown list - const config = { childList: true, subtree: true }; - observer.observe(dropdownList, config); - - // Input event listener to detect typing - input.addEventListener("input", () => { - isTyping = true; - }); - - // Blur event listener to reset typing state - input.addEventListener("blur", () => { - isTyping = false; - }); - - // Hide the reset button when there is nothing to reset. - // Do this once on init, then everytime a change occurs. - updateClearButtonVisibility(select, initialValue, clearInputButton) - select.addEventListener("change", () => { - updateClearButtonVisibility(select, initialValue, clearInputButton) - }); - - // Change the default input behaviour - have it reset to the data default instead - clearInputButton.addEventListener("click", (e) => { - if (overrideDefaultClearButton && initialValue) { - e.preventDefault(); - e.stopPropagation(); - input.click(); - // Find the dropdown option with the desired value - const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option"); - if (dropdownOptions) { - dropdownOptions.forEach(option => { - if (option.getAttribute("data-value") === initialValue) { - // Simulate a click event on the dropdown option - option.click(); - } - }); - } - } - }); - }); - } - - function updateClearButtonVisibility(select, initialValue, clearInputButton) { - if (select.value === initialValue) { - hideElement(clearInputButton); - }else { - showElement(clearInputButton) - } - } - - function addBlankOption(clearInputButton, dropdownList, initialValue) { - if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) { - const blankOption = document.createElement("li"); - blankOption.setAttribute("role", "option"); - blankOption.setAttribute("data-value", ""); - blankOption.classList.add("usa-combo-box__list-option"); - if (!initialValue){ - blankOption.classList.add("usa-combo-box__list-option--selected") - } - blankOption.textContent = "⎯"; - - dropdownList.insertBefore(blankOption, dropdownList.firstChild); - blankOption.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - overrideDefaultClearButton = false; - // Trigger the default clear behavior - clearInputButton.click(); - overrideDefaultClearButton = true; - }); - } - } -} diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 6ff402aa4..a077da929 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -3,7 +3,6 @@ import { initDomainValidators } from './domain-validators.js'; import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; import { initializeUrbanizationToggle } from './urbanization.js'; import { userProfileListener, finishUserSetupListener } from './user-profile.js'; -import { loadInitialValuesForComboBoxes } from './combobox.js'; import { handleRequestingEntityFieldset } from './requesting-entity.js'; import { initDomainsTable } from './table-domains.js'; import { initDomainRequestsTable } from './table-domain-requests.js'; @@ -31,8 +30,6 @@ initializeUrbanizationToggle(); userProfileListener(); finishUserSetupListener(); -loadInitialValuesForComboBoxes(); - handleRequestingEntityFieldset(); initDomainsTable(); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index cfb83badc..c96677ebc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -18,11 +18,11 @@ export function initPortfolioNewMemberPageToggle() { const unique_id = `${member_type}-${member_id}`; let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`); // This easter egg is only for fixtures that dont have names as we are displaying their emails // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + MembersTable.addMemberDeleteModal(num_domains, member_email || member_name || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); uswdsInitializeModals(); @@ -87,14 +87,6 @@ export function initAddNewMemberPageListeners() { }); }); - /* - Helper function to capitalize the first letter in a string (for display purposes) - */ - function capitalizeFirstLetter(text) { - if (!text) return ''; // Return empty string if input is falsy - return text.charAt(0).toUpperCase() + text.slice(1); - } - /* Populates contents of the "Add Member" confirmation modal */ @@ -102,10 +94,12 @@ export function initAddNewMemberPageListeners() { const permissionDetailsContainer = document.getElementById("permission_details"); permissionDetailsContainer.innerHTML = ""; // Clear previous content - // Get all permission sections (divs with h3 and radio inputs) - const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + if (permission_details_div_id == 'member-basic-permissions') { + // for basic users, display values are based on selections in the form + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - permissionSections.forEach(section => { + permissionSections.forEach(section => { // Find the

element text const sectionTitle = section.textContent; @@ -113,31 +107,46 @@ export function initAddNewMemberPageListeners() { const fieldset = section.nextElementSibling; if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + // Get the selected radio button within this fieldset + const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - // If a radio button is selected, get its label text - let selectedPermission = "No permission selected"; - if (selectedRadio) { - const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); - selectedPermission = label ? label.textContent : "No permission selected"; + // If a radio button is selected, get its label text + let selectedPermission = "No permission selected"; + if (selectedRadio) { + const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); + if (label) { + // Get only the text node content (excluding subtext in

) + const mainText = Array.from(label.childNodes) + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent.trim()) + .join(""); // Combine and trim whitespace + selectedPermission = mainText || "No permission selected"; } - - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); - - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); - - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); + } + appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer); } - }); + }); + } else { + // for admin users, the permissions are always the same + appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer); + appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer); + appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer); + } + } + + function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) { + // Create new elements for the content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary", "margin-bottom-0"); + + const permissionElement = document.createElement("p"); + permissionElement.textContent = permissionDisplay; + permissionElement.classList.add("margin-top-0"); + + // Append to the content container + permissionContainer.appendChild(titleElement); + permissionContainer.appendChild(permissionElement); } /* @@ -149,18 +158,25 @@ export function initAddNewMemberPageListeners() { let emailValue = document.getElementById('id_email').value; document.getElementById('modalEmail').textContent = emailValue; - // Get selected radio button for access level + // Get selected radio button for member access level let selectedAccess = document.querySelector('input[name="role"]:checked'); - // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) - // This value does not have the first letter capitalized so let's capitalize it - let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; + // Map the access level values to user-friendly labels + const accessLevelMapping = { + organization_admin: "Admin", + organization_member: "Basic", + }; + // Determine the access text based on the selected value + let accessText = selectedAccess + ? accessLevelMapping[selectedAccess.value] || "Unknown access level" + : "No access level selected"; + // Update the modal with the appropriate member access level text document.getElementById('modalAccessLevel').textContent = accessText; // Populate permission details based on access level if (selectedAccess && selectedAccess.value === 'organization_admin') { - populatePermissionDetails('new-member-admin-permissions'); + populatePermissionDetails('admin'); } else { - populatePermissionDetails('new-member-basic-permissions'); + populatePermissionDetails('member-basic-permissions'); } //------- Show the modal @@ -177,22 +193,14 @@ export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { let memberForm = document.getElementById("member_form"); let newMemberForm = document.getElementById("add_member_form") - if (memberForm) { + if (memberForm || newMemberForm) { hookupRadioTogglerListener( 'role', { - 'organization_admin': 'member-admin-permissions', + 'organization_admin': '', 'organization_member': 'member-basic-permissions' } ); - }else if (newMemberForm){ - hookupRadioTogglerListener( - 'role', - { - 'organization_admin': 'new-member-admin-permissions', - 'organization_member': 'new-member-basic-permissions' - } - ); } }); } diff --git a/src/registrar/assets/src/js/getgov/requesting-entity.js b/src/registrar/assets/src/js/getgov/requesting-entity.js index 3bcdcd35c..833eab2f8 100644 --- a/src/registrar/assets/src/js/getgov/requesting-entity.js +++ b/src/registrar/assets/src/js/getgov/requesting-entity.js @@ -9,15 +9,15 @@ export function handleRequestingEntityFieldset() { const formPrefix = "portfolio_requesting_entity"; const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`); const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`); - const select = document.getElementById(`id_${formPrefix}-sub_organization`); - const selectParent = select?.parentElement; + const input = document.getElementById(`id_${formPrefix}-sub_organization`); + const inputGrandParent = input?.parentElement?.parentElement; + const select = input?.previousElementSibling; const suborgContainer = document.getElementById("suborganization-container"); const suborgDetailsContainer = document.getElementById("suborganization-container__details"); const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction"); - const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; // Make sure all crucial page elements exist before proceeding. // This more or less ensures that we are on the Requesting Entity page, and not elsewhere. - if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return; + if (!radios || !input || !select || !inputGrandParent || !suborgContainer || !suborgDetailsContainer) return; // requestingSuborganization: This just broadly determines if they're requesting a suborg at all // requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not. @@ -27,8 +27,8 @@ export function handleRequestingEntityFieldset() { function toggleSuborganization(radio=null) { if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); - if (select.options.length == 2) { // --Select-- and other are the only options - hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg + if (select.options.length == 1) { // other is the only option + hideElement(inputGrandParent); // Hide the combo box and indicate requesting new suborg hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list requestingNewSuborganization.value = "True"; } else { @@ -37,11 +37,6 @@ export function handleRequestingEntityFieldset() { requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer); } - // Add fake "other" option to sub_organization select - if (select && !Array.from(select.options).some(option => option.value === "other")) { - select.add(new Option(subOrgCreateNewOption, "other")); - } - if (requestingNewSuborganization.value === "True") { select.value = "other"; } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index 338d5d98c..0abfee9b6 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r ` : ''} ${modal_button_text} - ${screen_reader_text} `; @@ -107,6 +106,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions" aria-expanded="false" aria-controls="more-actions-${unique_id}" + aria-label="${screen_reader_text}" >

Loading table.

'; let url = `${baseUrlValue}?${searchParams.toString()}` fetch(url) .then(response => response.json()) @@ -469,7 +473,6 @@ export class BaseTable { let dataObjects = this.getDataObjects(data); let customTableOptions = this.customizeTable(data); - dataObjects.forEach(dataObject => { this.addRow(dataObject, tbody, customTableOptions); }); diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js index 86aa39c37..19e36c902 100644 --- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -103,8 +103,9 @@ export class EditMemberDomainsTable extends BaseTable { disabled = true; } + // uses margin-right-neg-5 as a hack to increase the text-wrapping width on this table row.innerHTML = ` - +
@@ -119,10 +121,10 @@ export class EditMemberDomainsTable extends BaseTable { ${domain.id}
- + ${domain.name} - ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} + ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} `; tbody.appendChild(row); @@ -235,7 +237,8 @@ export class EditMemberDomainsTable extends BaseTable { // Create unassigned domains list const unassignedDomainsList = document.createElement('ul'); unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); - this.removedDomains.forEach(removedDomain => { + let removedDomainsCopy = [...this.removedDomains].sort((a, b) => a.name.localeCompare(b.name)); + removedDomainsCopy.forEach(removedDomain => { const removedDomainListItem = document.createElement('li'); removedDomainListItem.textContent = removedDomain.name; // Use textContent for security unassignedDomainsList.appendChild(removedDomainListItem); @@ -244,7 +247,8 @@ export class EditMemberDomainsTable extends BaseTable { // Create assigned domains list const assignedDomainsList = document.createElement('ul'); assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); - this.addedDomains.forEach(addedDomain => { + let addedDomainsCopy = [...this.addedDomains].sort((a, b) => a.name.localeCompare(b.name)); + addedDomainsCopy.forEach(addedDomain => { const addedDomainListItem = document.createElement('li'); addedDomainListItem.textContent = addedDomain.name; // Use textContent for security assignedDomainsList.appendChild(addedDomainListItem); @@ -259,7 +263,7 @@ export class EditMemberDomainsTable extends BaseTable { // Append unassigned domains section if (this.removedDomains.length) { const unassignedHeader = document.createElement('h3'); - unassignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + unassignedHeader.classList.add('margin-bottom-05', 'h4'); unassignedHeader.textContent = 'Unassigned domains'; domainAssignmentSummary.appendChild(unassignedHeader); domainAssignmentSummary.appendChild(unassignedDomainsList); @@ -268,7 +272,8 @@ export class EditMemberDomainsTable extends BaseTable { // Append assigned domains section if (this.addedDomains.length) { const assignedHeader = document.createElement('h3'); - assignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + // Make this h3 look like a h4 + assignedHeader.classList.add('margin-bottom-05', 'h4'); assignedHeader.textContent = 'Assigned domains'; domainAssignmentSummary.appendChild(assignedHeader); domainAssignmentSummary.appendChild(assignedDomainsList); @@ -276,7 +281,8 @@ export class EditMemberDomainsTable extends BaseTable { // Append total assigned domains section const totalHeader = document.createElement('h3'); - totalHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + // Make this h3 look like a h4 + totalHeader.classList.add('margin-bottom-05', 'h4'); totalHeader.textContent = 'Total assigned domains'; domainAssignmentSummary.appendChild(totalHeader); const totalCount = document.createElement('p'); @@ -289,6 +295,7 @@ export class EditMemberDomainsTable extends BaseTable { this.updateReadonlyDisplay(); hideElement(this.editModeContainer); showElement(this.readonlyModeContainer); + window.scrollTo(0, 0); } showEditMode() { diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js index 7f89eee52..f9b789e1f 100644 --- a/src/registrar/assets/src/js/getgov/table-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -19,9 +19,9 @@ export class MemberDomainsTable extends BaseTable { const domain = dataObject; const row = document.createElement('tr'); row.innerHTML = ` - + ${domain.name} - + `; tbody.appendChild(row); } diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js index 99a7fc652..75a7c29ac 100644 --- a/src/registrar/assets/src/js/getgov/table-members.js +++ b/src/registrar/assets/src/js/getgov/table-members.js @@ -78,13 +78,12 @@ export class MembersTable extends BaseTable { const num_domains = member.domain_urls.length; const last_active = this.handleLastActive(member.last_active); let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; - const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): ''; const row = document.createElement('tr'); - let admin_tagHTML = ``; if (member.is_admin) - admin_tagHTML = `Admin` + admin_tagHTML = `Admin` // generate html blocks for domains and permissions for the member let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); @@ -99,7 +98,8 @@ export class MembersTable extends BaseTable { type="button" class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1" data-for=${unique_id} - aria-label="Expand for additional information" + aria-label="Expand for additional information for ${member.member_display}" + aria-label-placeholder="${member.member_display}" > Expand
"; - domainsHTML += "

Domains assigned

"; - domainsHTML += `

This member is assigned to ${num_domains} domains:

`; + domainsHTML += "

Domains assigned

"; + domainsHTML += `

This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:

`; domainsHTML += ""; // If there are more than 6 domains, display a "View assigned domains" link - if (num_domains >= 6) { - domainsHTML += `

View assigned domains

`; - } + domainsHTML += `

View assigned domains

`; domainsHTML += "
"; } @@ -378,34 +390,37 @@ export class MembersTable extends BaseTable { generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) { let permissionsHTML = ''; + // Define shared classes across elements for easier refactoring + let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote"; + // Check domain-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { - permissionsHTML += "

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + permissionsHTML += `

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { - permissionsHTML += "

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + permissionsHTML += `

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

`; } // Check request-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { - permissionsHTML += "

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

"; + permissionsHTML += `

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { - permissionsHTML += "

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

"; + permissionsHTML += `

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

`; } // Check member-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { - permissionsHTML += "

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

"; + permissionsHTML += `

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { - permissionsHTML += "

Members (view-only): Can view all organizational members. Can't manage any members.

"; + permissionsHTML += `

Members (view-only): Can view all organizational members. Can't manage any members.

`; } // If no specific permissions are assigned, display a message indicating no additional permissions if (!permissionsHTML) { - permissionsHTML += "

No additional permissions: There are no additional permissions for this member.

"; + permissionsHTML += `

No additional permissions: There are no additional permissions for this member.

`; } // Add a permissions header and wrap the entire output in a container - permissionsHTML = "

Additional permissions for this member

" + permissionsHTML + "
"; + permissionsHTML = `

Additional permissions for this member

${permissionsHTML}
`; return permissionsHTML; } @@ -423,7 +438,7 @@ export class MembersTable extends BaseTable { let modalDescription = ``; if (num_domains >= 0){ - modalHeading = `Are you sure you want to delete ${member_email}?`; + modalHeading = `Are you sure you want to remove ${member_email} from the organization?`; modalDescription = `They will no longer be able to access this organization. This action cannot be undone.`; if (num_domains >= 1) diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index 762618415..ca9990ca9 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -49,3 +49,30 @@ tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { bottom: -10px; right: 30px; } + +// A CSS only show-more/show-less based on usa-accordion +.usa-accordion--show-more { + width: auto; + .usa-accordion__button[aria-expanded=false], + .usa-accordion__button[aria-expanded=false]:hover, + .usa-accordion__button[aria-expanded=true], + .usa-accordion__button[aria-expanded=true]:hover { + background-image: none; + background-color: transparent; + padding-right: 0; + padding-left: 0; + font-weight: normal; + } + .usa-accordion__button[aria-expanded=true] .expand-more { + display: inline-block; + } + .usa-accordion__button[aria-expanded=true] .expand-less { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-more { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-less { + display: inline-block; + } +} diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index a71804d77..4f75fd2fb 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -188,7 +188,7 @@ html[data-theme="dark"] { } #branding h1, -h1, h2, h3, +.dashboard h1, .dashboard h2, .dashboard h3, .module h2 { font-weight: font-weight('bold'); } @@ -516,10 +516,6 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too max-width: 68ex; } -.usa-summary-box__dhs-color { - color: $dhs-blue-70; -} - details.dja-detail-table { display: inline-table; background-color: var(--body-bg); @@ -812,18 +808,6 @@ div.dja__model-description{ text-decoration: underline !important; } -//-- Override some styling for the USWDS summary box (per design quidance for ticket #2055 -.usa-summary-box { - background: #{$dhs-blue-10}; - border-color: #{$dhs-blue-30}; - max-width: 72ex; - word-wrap: break-word; -} - -.usa-summary-box h3 { - color: #{$dhs-blue-60}; -} - .module caption, .inline-group h2 { text-transform: capitalize; } @@ -929,14 +913,6 @@ ul.add-list-reset { font-size: 14px; } -.domain-name-wrap { - white-space: normal; - word-wrap: break-word; - overflow: visible; - word-break: break-all; - max-width: 100%; -} - .organization-admin-label { font-weight: 600; font-size: .8125rem; diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 97f5710e9..be3b89baf 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -59,7 +59,6 @@ body { } h2 { - color: color('primary-dark'); margin-top: units(2); margin-bottom: units(2); } @@ -130,16 +129,6 @@ grid column to the max-width of the searchbar, which was calculated to be 33rem. word-break: break-word; } -.dotgov-status-box { - background-color: color('primary-lightest'); - border-color: color('accent-cool-lighter'); -} - -.dotgov-status-box--action-need { - background-color: color('warning-lighter'); - border-color: color('warning'); -} - footer { border-top: 1px solid color('primary-darker'); } @@ -228,14 +217,6 @@ abbr[title] { max-width: 23ch; } -.ellipsis--30 { - max-width: 30ch; -} - -.ellipsis--50 { - max-width: 50ch; -} - .vertical-align-middle { vertical-align: middle; } @@ -272,6 +253,14 @@ abbr[title] { word-break: break-word; } +.string-wrap { + white-space: normal; + word-wrap: break-word; + overflow: visible; + word-break: break-all; + max-width: 100%; +} + //Icon size adjustment used by buttons and form errors .usa-icon.usa-icon--large { margin: 0; @@ -285,4 +274,4 @@ abbr[title] { .width-quarter { width: 25%; -} \ No newline at end of file +} diff --git a/src/registrar/assets/src/sass/_theme/_buttons.scss b/src/registrar/assets/src/sass/_theme/_buttons.scss index 3342f5f7d..13bc163a8 100644 --- a/src/registrar/assets/src/sass/_theme/_buttons.scss +++ b/src/registrar/assets/src/sass/_theme/_buttons.scss @@ -236,13 +236,6 @@ a.withdraw_outline:active { align-items: center; } -.dotgov-table a -a .usa-icon, -.usa-button--with-icon .usa-icon { - height: 1.3em; - width: 1.3em; -} - // Red, for delete buttons // Used on: All delete buttons // Note: Can be simplified by adding text-secondary to delete anchors in tables @@ -253,6 +246,10 @@ a.text-secondary:hover { color: $theme-color-error; } +.usa-button.usa-button--secondary { + background-color: $theme-color-error; +} + .usa-button--show-more-button { font-size: size('ui', 'xs'); text-decoration: none; diff --git a/src/registrar/assets/src/sass/_theme/_forms.scss b/src/registrar/assets/src/sass/_theme/_forms.scss index 4138c5878..670a69136 100644 --- a/src/registrar/assets/src/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -1,7 +1,14 @@ @use "uswds-core" as *; @use "cisa_colors" as *; -@use "typography" as *; +// Normalize typography in forms +.usa-form, +.usa-form fieldset { + font-size: 1rem; + .usa-legend { + font-size: 1rem; + } +} .usa-form .usa-button { margin-top: units(3); } @@ -69,16 +76,6 @@ legend.float-left-tablet + button.float-right-tablet { } } -.read-only-label { - @extend .h4--sm-05; - font-weight: bold; - color: color('primary-dark'); -} - -.read-only-value { - margin-top: units(0); -} - .bg-gray-1 .usa-radio { background: color('gray-1'); } diff --git a/src/registrar/assets/src/sass/_theme/_modals.scss b/src/registrar/assets/src/sass/_theme/_modals.scss new file mode 100644 index 000000000..44107790d --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_modals.scss @@ -0,0 +1,5 @@ +@use "uswds-core" as *; + +.usa-modal__main { + padding: 0 2rem 2rem; +} diff --git a/src/registrar/assets/src/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss index fcc5b5ae6..19de29a48 100644 --- a/src/registrar/assets/src/sass/_theme/_register-form.scss +++ b/src/registrar/assets/src/sass/_theme/_register-form.scss @@ -1,5 +1,4 @@ @use "uswds-core" as *; -@use "typography" as *; .register-form-step > h1 { //align to top of sidebar on first page of the form @@ -12,11 +11,7 @@ margin-top: units(1); } -// header--body is used on the summary page and -// should not be styled like the register form headers -.register-form-step h3 { - color: color('primary-dark'); - letter-spacing: $letter-space--xs; +.register-form-step h3:not(.margin-top-05) { margin-top: units(3); margin-bottom: 0; @@ -64,26 +59,10 @@ margin-top: units(3); } - .summary-item hr, +.summary-item hr, .review__step hr { border: none; //reset border-top: 1px solid color('primary-dark'); margin-top: 0; margin-bottom: units(0.5); } - -.review__step__title a:visited { - color: color('primary'); -} - -.review__step__name { - color: color('primary-dark'); - font-weight: font-weight('semibold'); - margin-bottom: units(0.5); -} - -.review__step__subheading { - color: color('primary-dark'); - font-weight: font-weight('semibold'); - margin-bottom: units(0.5); -} diff --git a/src/registrar/assets/src/sass/_theme/_summary-box.scss b/src/registrar/assets/src/sass/_theme/_summary-box.scss new file mode 100644 index 000000000..45f41d5d3 --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_summary-box.scss @@ -0,0 +1,15 @@ +@use "uswds-core" as *; + +.usa-summary-box { + background-color: color('primary-lightest'); + border-color: color('accent-cool-lighter'); +} + +.usa-summary-box--action-needed { + background-color: color('warning-lighter'); + border-color: color('warning'); +} + +.usa-summary-box__heading { + font-weight: bold; +} diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index ea160396e..37ae22b1b 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -41,6 +41,13 @@ th { } } +// The member table has an extra "expand" row, which looks like a single row. +// But the DOM disagrees - so we basically need to hide the border on both rows. +#members__table-wrapper .dotgov-table tr:nth-last-child(2) td, +#members__table-wrapper .dotgov-table tr:nth-last-child(2) th { + border-bottom: none; +} + .dotgov-table { width: 100%; @@ -56,10 +63,9 @@ th { border: none; } - tr:not(.hide-td-borders) { - td, th { - border-bottom: 1px solid color('base-lighter'); - } + tr:not(.hide-td-borders):not(:last-child) td, + tr:not(.hide-td-borders):not(:last-child) th { + border-bottom: 1px solid color('base-lighter'); } thead th { @@ -99,3 +105,25 @@ th { } } } + +.dotgov-table--cell-padding-2 { + td, th { + padding: units(2); + } +} + +.usa-table--striped tbody tr:nth-child(odd) th, +.usa-table--striped tbody tr:nth-child(odd) td { + background-color: color('primary-lightest'); +} + +.usa-table--bg-transparent { + td, thead th { + background-color: transparent; + } +} + +.usa-table--full-borderless td, +.usa-table--full-borderless th { + border: none !important; +} diff --git a/src/registrar/assets/src/sass/_theme/_tags.scss b/src/registrar/assets/src/sass/_theme/_tags.scss new file mode 100644 index 000000000..495bb93a8 --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_tags.scss @@ -0,0 +1,3 @@ +.usa-tag { + text-transform: none; +} diff --git a/src/registrar/assets/src/sass/_theme/_tooltips.scss b/src/registrar/assets/src/sass/_theme/_tooltips.scss index 58beb8ae6..22b5cf534 100644 --- a/src/registrar/assets/src/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/src/sass/_theme/_tooltips.scss @@ -66,9 +66,9 @@ text-align: center; font-size: inherit; //inherit tooltip fontsize of .93rem max-width: fit-content; + display: block; @include at-media('desktop') { width: 70vw; } - display: block; } -} \ No newline at end of file +} diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index db19a595b..22069f726 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -10,44 +10,43 @@ address, max-width: measure(5); } -h1 { +h1:not(.usa-alert__heading), +// .module h2 excludes headers in DJA +h2:not(.usa-alert__heading, .module h2), +h3:not(.usa-alert__heading), +h4:not(.usa-alert__heading), +h5:not(.usa-alert__heading), +h6:not(.usa-alert__heading) { + color: color('primary-darker'); +} + +h1, .h1 { + font-size: 2.125rem; @include typeset('sans', '2xl', 2); margin: 0 0 units(2); - color: color('primary-darker'); } -h2 { - font-weight: font-weight('semibold'); - line-height: line-height('heading', 3); +h2, .h2 { + line-height: 1.3; margin: units(4) 0 units(1); - color: color('primary-darker'); } -.header--body { - margin-top: units(2); +h3, .h3 { + font-size: 1.25rem; font-weight: font-weight('semibold'); - // The units mixin can only get us close, so it's between - // hardcoding the value and using in markup - font-size: 16.96px; } -.h4--sm-05 { - font-size: size('body', 'sm'); - font-weight: normal; - color: color('primary'); - margin-bottom: units(0.5); -} - -// Normalize typography in forms -.usa-form, -.usa-form fieldset { - font-size: 1rem; - .usa-legend { - font-size: 1rem; - } +h4, .h4 { + font-size: 1.125rem; + line-height: 1.25; + font-weight: font-weight('semibold'); } .p--blockquote { padding-left: units(1); border-left: 2px solid color('base-lighter'); } + +.font-body-1 { + font-size: size('body', 1); +} diff --git a/src/registrar/assets/src/sass/_theme/_uswds-theme.scss b/src/registrar/assets/src/sass/_theme/_uswds-theme.scss index 1661a6388..21bb48e96 100644 --- a/src/registrar/assets/src/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/src/sass/_theme/_uswds-theme.scss @@ -68,6 +68,7 @@ in the form $setting: value, /*--------------------------- ## Font weights ----------------------------*/ + $theme-font-weight-medium: 400, $theme-font-weight-semibold: 600, /*--------------------------- diff --git a/src/registrar/assets/src/sass/_theme/styles.scss b/src/registrar/assets/src/sass/_theme/styles.scss index 78d27b2e0..4962bf184 100644 --- a/src/registrar/assets/src/sass/_theme/styles.scss +++ b/src/registrar/assets/src/sass/_theme/styles.scss @@ -17,6 +17,7 @@ @forward "forms"; @forward "search"; @forward "tooltips"; +@forward "summary-box"; @forward "fieldsets"; @forward "alerts"; @forward "tables"; @@ -25,6 +26,8 @@ @forward "header"; @forward "register-form"; @forward "containers"; +@forward "modals"; +@forward "tags"; /*-------------------------------------------------- --- Admin ---------------------------------*/ diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 0111245a1..a58e3e2f9 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -520,7 +520,7 @@ LOGGING = { "()": JsonFormatter, }, }, - # define where log messages will be sent; + # define where log messages will be sent # each logger can have one or more handlers "handlers": { "console": { diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py index bff49ff6b..c4d824b37 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -323,22 +323,50 @@ class DomainRequestFixture: cls._create_domain_requests(users) @classmethod - def _create_domain_requests(cls, users): + def _create_domain_requests(cls, users): # noqa: C901 """Creates DomainRequests given a list of users.""" + total_domain_requests_to_make = len(users) # 100000 + + # Check if the database is already populated with the desired + # number of entries. + # (Prevents re-adding more entries to an already populated database, + # which happens when restarting Docker src) + domain_requests_already_made = DomainRequest.objects.count() + domain_requests_to_create = [] - for user in users: - for request_data in cls.DOMAINREQUESTS: - # Prepare DomainRequest objects - try: - domain_request = DomainRequest( - 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) - except Exception as e: - logger.warning(e) + if domain_requests_already_made < total_domain_requests_to_make: + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + 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) + except Exception as e: + logger.warning(e) + + num_additional_requests_to_make = ( + total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) + ) + if num_additional_requests_to_make > 0: + for _ in range(num_additional_requests_to_make): + random_user = random.choice(users) # nosec + try: + random_request_type = random.choice(cls.DOMAINREQUESTS) # nosec + # Prepare DomainRequest objects + domain_request = DomainRequest( + creator=random_user, + organization_name=random_request_type["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, random_request_type) + cls._set_foreign_key_fields(domain_request, random_request_type, random_user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(f"Error creating random domain request: {e}") # Bulk create domain requests cls._bulk_create_requests(domain_requests_to_create) diff --git a/src/registrar/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index e40998231..977bf0858 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -196,6 +196,7 @@ class UserFixture: "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd", "first_name": "Alysia-Analyst", "last_name": "Alysia-Analyst", + "email": "abroddrick+1@truss.works", }, { "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44", @@ -351,32 +352,65 @@ class UserFixture: @staticmethod def _get_existing_users(users): + # if users match existing users in db by email address, update the users with the username + # from the db. this will prevent duplicate users (with same email) from being added to db. + # it is ok to keep the old username in the db because the username will be updated by oidc process during login + + # Extract email addresses from users + emails = [user.get("email") for user in users] + + # Fetch existing users by email + existing_users_by_email = User.objects.filter(email__in=emails).values_list("email", "username", "id") + + # Create a dictionary to map emails to existing usernames + email_to_existing_user = {user[0]: user[1] for user in existing_users_by_email} + + # Update the users list with the usernames from existing users by email + for user in users: + email = user.get("email") + if email and email in email_to_existing_user: + user["username"] = email_to_existing_user[email] # Update username with the existing one + + # Get the user identifiers (username, id) for the existing users to query the database user_identifiers = [(user.get("username"), user.get("id")) for user in users] + + # Fetch existing users by username or id existing_users = User.objects.filter( username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers] ).values_list("username", "id") + + # Create sets for usernames and ids that exist existing_usernames = set(user[0] for user in existing_users) existing_user_ids = set(user[1] for user in existing_users) + return existing_usernames, existing_user_ids @staticmethod def _prepare_new_users(users, existing_usernames, existing_user_ids, are_superusers): - return [ - User( - id=user_data.get("id"), - first_name=user_data.get("first_name"), - last_name=user_data.get("last_name"), - username=user_data.get("username"), - email=user_data.get("email", ""), - title=user_data.get("title", "Peon"), - phone=user_data.get("phone", "2022222222"), - is_active=user_data.get("is_active", True), - is_staff=True, - is_superuser=are_superusers, - ) - for user_data in users - if user_data.get("username") not in existing_usernames and user_data.get("id") not in existing_user_ids - ] + new_users = [] + for i, user_data in enumerate(users): + username = user_data.get("username") + id = user_data.get("id") + first_name = user_data.get("first_name", "Bob") + last_name = user_data.get("last_name", "Builder") + + default_email = f"placeholder.{first_name.lower()}.{last_name.lower()}+{i}@igorville.gov" + email = user_data.get("email", default_email) + if username not in existing_usernames and id not in existing_user_ids: + user = User( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + title=user_data.get("title", "Peon"), + phone=user_data.get("phone", "2022222222"), + is_active=user_data.get("is_active", True), + is_staff=True, + is_superuser=are_superusers, + ) + new_users.append(user) + return new_users @staticmethod def _create_new_users(new_users): diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 699efe63b..05eb90db3 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -4,6 +4,7 @@ import logging from django import forms from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator from django.forms import formset_factory +from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import DomainRequest, FederalAgency from phonenumber_field.widgets import RegionalPhoneNumberWidget from registrar.models.suborganization import Suborganization @@ -161,9 +162,10 @@ class DomainSuborganizationForm(forms.ModelForm): """Form for updating the suborganization""" sub_organization = forms.ModelChoiceField( + label="Suborganization name", queryset=Suborganization.objects.none(), required=False, - widget=forms.Select(), + widget=ComboboxWidget, ) class Meta: @@ -178,20 +180,6 @@ class DomainSuborganizationForm(forms.ModelForm): portfolio = self.instance.portfolio if self.instance else None self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio) - # Set initial value - if self.instance and self.instance.sub_organization: - self.fields["sub_organization"].initial = self.instance.sub_organization - - # Set custom form label - self.fields["sub_organization"].label = "Suborganization name" - - # Use the combobox rather than the regular select widget - self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html" - - # Set data-default-value attribute - if self.instance and self.instance.sub_organization: - self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk - class BaseNameserverFormset(forms.BaseFormSet): def clean(self): @@ -456,6 +444,13 @@ class DomainSecurityEmailForm(forms.Form): class DomainOrgNameAddressForm(forms.ModelForm): """Form for updating the organization name and mailing address.""" + # for federal agencies we also want to know the top-level agency. + federal_agency = forms.ModelChoiceField( + label="Federal agency", + required=False, + queryset=FederalAgency.objects.all(), + widget=ComboboxWidget, + ) zipcode = forms.CharField( label="Zip code", validators=[ @@ -469,6 +464,16 @@ class DomainOrgNameAddressForm(forms.ModelForm): }, ) + state_territory = forms.ChoiceField( + label="State, territory, or military post", + required=True, + choices=DomainInformation.StateTerritoryChoices.choices, + error_messages={ + "required": ("Select the state, territory, or military post where your organization is located.") + }, + widget=ComboboxWidget(attrs={"required": True}), + ) + class Meta: model = DomainInformation fields = [ @@ -486,25 +491,12 @@ class DomainOrgNameAddressForm(forms.ModelForm): "organization_name": {"required": "Enter the name of your organization."}, "address_line1": {"required": "Enter the street address of your organization."}, "city": {"required": "Enter the city where your organization is located."}, - "state_territory": { - "required": "Select the state, territory, or military post where your organization is located." - }, } widgets = { - # We need to set the required attributed for State/territory - # because for this fields we are creating an individual - # instance of the Select. For the other fields we use the for loop to set - # the class's required attribute to true. "organization_name": forms.TextInput, "address_line1": forms.TextInput, "address_line2": forms.TextInput, "city": forms.TextInput, - "state_territory": forms.Select( - attrs={ - "required": True, - }, - choices=DomainInformation.StateTerritoryChoices.choices, - ), "urbanization": forms.TextInput, } diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index ccdbb17ba..89f7522b3 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -7,6 +7,7 @@ from django import forms from django.core.validators import RegexValidator, MaxLengthValidator from django.utils.safestring import mark_safe +from registrar.forms.utility.combobox import ComboboxWidget from registrar.forms.utility.wizard_form_helper import ( RegistrarForm, RegistrarFormSet, @@ -43,7 +44,7 @@ class RequestingEntityForm(RegistrarForm): label="Suborganization name", required=False, queryset=Suborganization.objects.none(), - empty_label="--Select--", + widget=ComboboxWidget, ) requested_suborganization = forms.CharField( label="Requested suborganization", @@ -56,22 +57,44 @@ class RequestingEntityForm(RegistrarForm): suborganization_state_territory = forms.ChoiceField( label="State, territory, or military post", required=False, - choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices, + choices=DomainRequest.StateTerritoryChoices.choices, + widget=ComboboxWidget, ) def __init__(self, *args, **kwargs): - """Override of init to add the suborganization queryset""" + """Override of init to add the suborganization queryset and 'other' option""" super().__init__(*args, **kwargs) if self.domain_request.portfolio: - self.fields["sub_organization"].queryset = Suborganization.objects.filter( - portfolio=self.domain_request.portfolio - ) + # Fetch the queryset for the portfolio + queryset = Suborganization.objects.filter(portfolio=self.domain_request.portfolio) + # set the queryset appropriately so that post can validate against queryset + self.fields["sub_organization"].queryset = queryset + + # Modify the choices to include "other" so that form can display options properly + self.fields["sub_organization"].choices = [(obj.id, str(obj)) for obj in queryset] + [ + ("other", "Other (enter your suborganization manually)") + ] + + @classmethod + def from_database(cls, obj: DomainRequest | Contact | None): + """Returns a dict of form field values gotten from `obj`. + Overrides RegistrarForm method in order to set sub_organization to 'other' + on GETs of the RequestingEntityForm.""" + if obj is None: + 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" + + return domain_request_dict def clean_sub_organization(self): """On suborganization clean, set the suborganization value to None if the user is requesting a custom suborganization (as it doesn't exist yet)""" - # If it's a new suborganization, return None (equivalent to selecting nothing) if self.cleaned_data.get("is_requesting_new_suborganization"): return None @@ -94,41 +117,60 @@ class RequestingEntityForm(RegistrarForm): return name def full_clean(self): - """Validation logic to remove the custom suborganization value before clean is triggered. + """Validation logic to temporarily remove the custom suborganization value before clean is triggered. Without this override, the form will throw an 'invalid option' error.""" - # Remove the custom other field before cleaning - data = self.data.copy() if self.data else None + # Ensure self.data is not None before proceeding + if self.data: + # handle case where form has been submitted + # Create a copy of the data for manipulation + data = self.data.copy() - # Remove the 'other' value from suborganization if it exists. - # This is a special value that tracks if the user is requesting a new suborg. - suborganization = self.data.get("portfolio_requesting_entity-sub_organization") - if suborganization and "other" in suborganization: - data["portfolio_requesting_entity-sub_organization"] = "" + # Retrieve sub_organization and store in _original_suborganization + suborganization = data.get("portfolio_requesting_entity-sub_organization") + self._original_suborganization = suborganization + # If the original value was "other", clear it for validation + if self._original_suborganization == "other": + data["portfolio_requesting_entity-sub_organization"] = "" - # Set the modified data back to the form - self.data = data + # Set the modified data back to the form + self.data = data + else: + # handle case of a GET + suborganization = None + if self.initial and "sub_organization" in self.initial: + suborganization = self.initial["sub_organization"] + + # Check if is_requesting_new_suborganization is True + is_requesting_new_suborganization = False + if self.initial and "is_requesting_new_suborganization" in self.initial: + # Call the method if it exists + is_requesting_new_suborganization = self.initial["is_requesting_new_suborganization"]() + + # Determine if "other" should be set + if is_requesting_new_suborganization and suborganization is None: + self._original_suborganization = "other" + else: + self._original_suborganization = suborganization # Call the parent's full_clean method super().full_clean() + # Restore "other" if there are errors + if self.errors: + self.data["portfolio_requesting_entity-sub_organization"] = self._original_suborganization + def clean(self): - """Custom clean implementation to handle our desired logic flow for suborganization. - Given that these fields often rely on eachother, we need to do this in the parent function.""" + """Custom clean implementation to handle our desired logic flow for suborganization.""" cleaned_data = super().clean() - # Do some custom error validation if the requesting entity is a suborg. - # Otherwise, just validate as normal. - suborganization = self.cleaned_data.get("sub_organization") - is_requesting_new_suborganization = self.cleaned_data.get("is_requesting_new_suborganization") - - # Get the value of the yes/no checkbox from RequestingEntityYesNoForm. - # Since self.data stores this as a string, we need to convert "True" => True. + # Get the cleaned data + suborganization = cleaned_data.get("sub_organization") + is_requesting_new_suborganization = cleaned_data.get("is_requesting_new_suborganization") requesting_entity_is_suborganization = self.data.get( "portfolio_requesting_entity-requesting_entity_is_suborganization" ) if requesting_entity_is_suborganization == "True": if is_requesting_new_suborganization: - # Validate custom suborganization fields if not cleaned_data.get("requested_suborganization") and "requested_suborganization" not in self.errors: self.add_error("requested_suborganization", "Enter the name of your suborganization.") if not cleaned_data.get("suborganization_city"): @@ -141,6 +183,12 @@ class RequestingEntityForm(RegistrarForm): elif not suborganization: self.add_error("sub_organization", "Suborganization is required.") + # If there are errors, restore the "other" value for rendering + if self.errors and getattr(self, "_original_suborganization", None) == "other": + self.cleaned_data["sub_organization"] = self._original_suborganization + elif not self.data and getattr(self, "_original_suborganization", None) == "other": + self.cleaned_data["sub_organization"] = self._original_suborganization + return cleaned_data @@ -274,7 +322,7 @@ class OrganizationContactForm(RegistrarForm): # uncomment to see if modelChoiceField can be an arg later required=False, queryset=FederalAgency.objects.exclude(agency__in=excluded_agencies), - empty_label="--Select--", + widget=ComboboxWidget, ) organization_name = forms.CharField( label="Organization name", @@ -294,10 +342,11 @@ class OrganizationContactForm(RegistrarForm): ) state_territory = forms.ChoiceField( label="State, territory, or military post", - choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices, + choices=DomainRequest.StateTerritoryChoices.choices, error_messages={ "required": ("Select the state, territory, or military post where your organization is located.") }, + widget=ComboboxWidget, ) zipcode = forms.CharField( label="Zip code", @@ -413,6 +462,7 @@ class CurrentSitesForm(RegistrarForm): error_messages={ "invalid": ("Enter your organization's current website in the required format, like example.com.") }, + widget=forms.URLInput(attrs={"aria-labelledby": "id_current_sites_header id_current_sites_body"}), ) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 0a8c4d623..c9ef280b0 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,8 +4,8 @@ import logging from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator -from django.utils.safestring import mark_safe +from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, @@ -33,6 +33,15 @@ class PortfolioOrgAddressForm(forms.ModelForm): "required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", }, ) + state_territory = forms.ChoiceField( + label="State, territory, or military post", + required=True, + choices=DomainInformation.StateTerritoryChoices.choices, + error_messages={ + "required": ("Select the state, territory, or military post where your organization is located.") + }, + widget=ComboboxWidget(attrs={"required": True}), + ) class Meta: model = Portfolio @@ -47,25 +56,12 @@ class PortfolioOrgAddressForm(forms.ModelForm): error_messages = { "address_line1": {"required": "Enter the street address of your organization."}, "city": {"required": "Enter the city where your organization is located."}, - "state_territory": { - "required": "Select the state, territory, or military post where your organization is located." - }, "zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."}, } widgets = { - # We need to set the required attributed for State/territory - # because for this fields we are creating an individual - # instance of the Select. For the other fields we use the for loop to set - # the class's required attribute to true. "address_line1": forms.TextInput, "address_line2": forms.TextInput, "city": forms.TextInput, - "state_territory": forms.Select( - attrs={ - "required": True, - }, - choices=DomainInformation.StateTerritoryChoices.choices, - ), # "urbanization": forms.TextInput, } @@ -124,47 +120,47 @@ class BasePortfolioMemberForm(forms.ModelForm): widget=forms.RadioSelect, required=True, error_messages={ - "required": "Member access level is required", + "required": "Select the level of access you would like to grant this member.", }, ) - domain_request_permission_admin = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec + domain_permissions = forms.ChoiceField( choices=[ - (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"), + (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"), ], widget=forms.RadioSelect, required=False, + initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, error_messages={ - "required": "Admin domain request permission is required", + "required": "Domain permission is required.", }, ) - member_permission_admin = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec + domain_request_permissions = forms.ChoiceField( choices=[ - (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), - ], - widget=forms.RadioSelect, - required=False, - error_messages={ - "required": "Admin member permission is required", - }, - ) - - domain_request_permission_member = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec - choices=[ - (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"), ], widget=forms.RadioSelect, required=False, + initial="no_access", error_messages={ - "required": "Basic member permission is required", + "required": "Domain request permission is required.", + }, + ) + + member_permissions = forms.ChoiceField( + choices=[ + ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"), + ], + widget=forms.RadioSelect, + required=False, + initial="no_access", + error_messages={ + "required": "Member permission is required.", }, ) @@ -172,12 +168,11 @@ class BasePortfolioMemberForm(forms.ModelForm): # All of the fields included here have "required=False" by default as they are conditionally required. # see def clean() for more details. ROLE_REQUIRED_FIELDS = { - UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ - "domain_request_permission_admin", - "member_permission_admin", - ], + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [], UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - "domain_request_permission_member", + "domain_permissions", + "member_permissions", + "domain_request_permissions", ], } @@ -193,15 +188,24 @@ class BasePortfolioMemberForm(forms.ModelForm): Update field descriptions. """ super().__init__(*args, **kwargs) - # Adds a

description beneath each role option - self.fields["role"].descriptions = { - "organization_admin": UserPortfolioRoleChoices.get_role_description( - UserPortfolioRoleChoices.ORGANIZATION_ADMIN - ), - "organization_member": UserPortfolioRoleChoices.get_role_description( - UserPortfolioRoleChoices.ORGANIZATION_MEMBER - ), + + # Adds a

description beneath each option + self.fields["domain_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage", + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization", } + self.fields["domain_request_permissions"].descriptions = { + UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( + "Can view all domain requests for the organization and create requests" + ), + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization", + "no_access": "Cannot view or create domain requests", + } + self.fields["member_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions", + "no_access": "Cannot view member permissions", + } + # Map model instance values to custom form fields if self.instance: self.map_instance_to_initial() @@ -225,8 +229,12 @@ class BasePortfolioMemberForm(forms.ModelForm): self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) # Edgecase: Member uses a special form value for None called "no_access". - if cleaned_data.get("domain_request_permission_member") == "no_access": - cleaned_data["domain_request_permission_member"] = None + if cleaned_data.get("domain_request_permissions") == "no_access": + cleaned_data["domain_request_permissions"] = None + + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("member_permissions") == "no_access": + cleaned_data["member_permissions"] = None # Handle roles cleaned_data["roles"] = [role] @@ -256,7 +264,7 @@ class BasePortfolioMemberForm(forms.ModelForm): "role": "organization_admin" or "organization_member", "member_permission_admin": permission level if admin, "domain_request_permission_admin": permission level if admin, - "domain_request_permission_member": permission level if member + "domain_request_permissions": permission level if member } """ if self.initial is None: @@ -270,12 +278,15 @@ class BasePortfolioMemberForm(forms.ModelForm): UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER, ] - domain_perms = [ + domain_request_perms = [ UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] + domain_perms = [ + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ] member_perms = [ - UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, ] @@ -285,16 +296,21 @@ class BasePortfolioMemberForm(forms.ModelForm): roles = self.instance.roles or [] selected_role = next((role for role in roles if role in roles), None) self.initial["role"] = selected_role - is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN - if is_admin: - selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) - selected_member_permission = next((perm for perm in member_perms if perm in perms), None) - self.initial["domain_request_permission_admin"] = selected_domain_permission - self.initial["member_permission_admin"] = selected_member_permission - else: - # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. - selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") - self.initial["domain_request_permission_member"] = selected_domain_permission + is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER + if is_member: + # Edgecase: Member and domain request use a special form value for None called "no_access". + # This ensures a form selection. + selected_domain_permission = next( + (perm for perm in domain_perms if perm in perms), + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + ) + selected_domain_request_permission = next( + (perm for perm in domain_request_perms if perm in perms), "no_access" + ) + selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access") + self.initial["domain_request_permissions"] = selected_domain_request_permission + self.initial["domain_permissions"] = selected_domain_permission + self.initial["member_permissions"] = selected_member_permission class PortfolioMemberForm(BasePortfolioMemberForm): @@ -323,7 +339,7 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): """ email = forms.EmailField( - label="Enter the email of the member you'd like to invite", + label="Email", max_length=None, error_messages={ "invalid": ("Enter an email address in the required format, like name@example.com."), diff --git a/src/registrar/forms/utility/combobox.py b/src/registrar/forms/utility/combobox.py new file mode 100644 index 000000000..277aec4f3 --- /dev/null +++ b/src/registrar/forms/utility/combobox.py @@ -0,0 +1,5 @@ +from django.forms import Select + + +class ComboboxWidget(Select): + template_name = "django/forms/widgets/combobox.html" diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index 7a90deed1..2f2cc2625 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -83,6 +83,11 @@ class Command(BaseCommand): action=argparse.BooleanOptionalAction, help="Add all domain managers of the portfolio's domains to the organization.", ) + parser.add_argument( + "--skip_existing_portfolios", + action=argparse.BooleanOptionalAction, + help="Only add suborganizations to newly created portfolios, skip existing ones.", + ) def handle(self, **options): agency_name = options.get("agency_name") @@ -91,6 +96,8 @@ class Command(BaseCommand): parse_domains = options.get("parse_domains") both = options.get("both") add_managers = options.get("add_managers") + skip_existing_portfolios = options.get("skip_existing_portfolios") + if not both: if not parse_requests and not parse_domains: raise CommandError("You must specify at least one of --parse_requests or --parse_domains.") @@ -116,19 +123,10 @@ class Command(BaseCommand): TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) try: # C901 'Command.handle' is too complex (12) - # if the portfolio is already created, we don't want to create it again - portfolio = Portfolio.objects.filter(organization_name=federal_agency.agency) - if portfolio.exists(): - portfolio = portfolio.first() - message = f"Portfolio '{federal_agency.agency}' already exists. Skipping create." - TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) - else: - portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both) - portfolios.append(portfolio) - logger.debug(f"add_managers: {add_managers}") - if add_managers: - logger.debug("Adding managers to portfolio") - self.add_managers_to_portfolio(portfolio) + portfolio = self.handle_populate_portfolio( + federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios + ) + portfolios.append(portfolio) except Exception as exec: self.failed_portfolios.add(federal_agency) logger.error(exec) @@ -139,13 +137,13 @@ class Command(BaseCommand): updated_suborg_count = self.post_process_all_suborganization_fields(agencies) message = f"Added city and state_territory information to {updated_suborg_count} suborgs." TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) - TerminalHelper.log_script_run_summary( self.updated_portfolios, self.failed_portfolios, self.skipped_portfolios, debug=False, - skipped_header="----- SOME PORTFOLIOS WERENT CREATED -----", + log_header="============= FINISHED HANDLE PORTFOLIO STEP ===============", + skipped_header="----- SOME PORTFOLIOS WERENT CREATED (BUT OTHER RECORDS ARE STILL PROCESSED) -----", display_as_str=True, ) @@ -171,13 +169,20 @@ class Command(BaseCommand): # POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name. # We only do this for started domain requests. if parse_requests or both: + prompt_message = ( + "This action will update domain requests even if they aren't on a portfolio." + "\nNOTE: This will modify domain requests, even if no portfolios were created." + "\nIn the event no portfolios *are* created, then this step will target " + "the existing portfolios with your given params." + "\nThis step is entirely optional, and is just for extra data cleanup." + ) TerminalHelper.prompt_for_execution( system_exit_on_terminate=True, - prompt_message="This action will update domain requests even if they aren't on a portfolio.", + prompt_message=prompt_message, prompt_title=( "POST PROCESS STEP: Do you want to clear federal agency on (related) started domain requests?" ), - verify_message=None, + verify_message="*** THIS STEP IS OPTIONAL ***", ) self.post_process_started_domain_requests(agencies, portfolios) @@ -295,8 +300,11 @@ class Command(BaseCommand): status=DomainRequest.DomainRequestStatus.STARTED, organization_name__isnull=False, ) - message = (f"domain_requests_to_update: {domain_requests_to_update}") - TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) + + if domain_requests_to_update.count() == 0: + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, "No domain requests to update.") + return + portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio} message = f"portfolio_set: {portfolio_set}" TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) @@ -326,10 +334,19 @@ class Command(BaseCommand): DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"]) TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.") - def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both): + def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios): """Attempts to create a portfolio. If successful, this function will also create new suborganizations""" - portfolio, _ = self.create_portfolio(federal_agency) + portfolio, created = self.create_portfolio(federal_agency) + if skip_existing_portfolios and not created: + TerminalHelper.colorful_logger( + logger.warning, + TerminalColors.YELLOW, + "Skipping modifications to suborgs, domain requests, and " + "domains due to the --skip_existing_portfolios flag. Portfolio already exists.", + ) + return portfolio + self.create_suborganizations(portfolio, federal_agency) if parse_domains or both: self.handle_portfolio_domains(portfolio, federal_agency) @@ -436,15 +453,13 @@ class Command(BaseCommand): DomainRequest.DomainRequestStatus.INELIGIBLE, DomainRequest.DomainRequestStatus.REJECTED, ] - domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude( - status__in=invalid_states - ) + domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states) if not domain_requests.exists(): message = f""" Portfolio '{portfolio}' not added to domain requests: no valid records found. This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. Excluded statuses: STARTED, INELIGIBLE, REJECTED. - Filter info: DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude( + Filter info: DomainRequest.objects.filter(federal_agency=federal_agency).exclude( status__in=invalid_states ) """ @@ -488,12 +503,12 @@ class Command(BaseCommand): Returns a queryset of DomainInformation objects, or None if nothing changed. """ - domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True) + domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency) if not domain_infos.exists(): message = f""" Portfolio '{portfolio}' not added to domains: no valid records found. The filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. - Filter info: DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True) + Filter info: DomainInformation.objects.filter(federal_agency=federal_agency) """ TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) return None diff --git a/src/registrar/management/commands/patch_suborganizations.py b/src/registrar/management/commands/patch_suborganizations.py new file mode 100644 index 000000000..98ff1e36f --- /dev/null +++ b/src/registrar/management/commands/patch_suborganizations.py @@ -0,0 +1,133 @@ +import logging +from django.core.management import BaseCommand +from registrar.models import Suborganization, DomainRequest, DomainInformation +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper +from registrar.models.utility.generic_helper import count_capitals, normalize_string + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Clean up duplicate suborganizations that differ only by spaces and capitalization" + + def handle(self, **kwargs): + """Process manual deletions and find/remove duplicates. Shows preview + and updates DomainInformation / DomainRequest sub_organization references before deletion.""" + + # First: get a preset list of records we want to delete. + # For extra_records_to_prune: the key gets deleted, the value gets kept. + extra_records_to_prune = { + normalize_string("Assistant Secretary for Preparedness and Response Office of the Secretary"): { + "replace_with": "Assistant Secretary for Preparedness and Response, Office of the Secretary" + }, + normalize_string("US Geological Survey"): {"replace_with": "U.S. Geological Survey"}, + normalize_string("USDA/OC"): {"replace_with": "USDA, Office of Communications"}, + normalize_string("GSA, IC, OGP WebPortfolio"): {"replace_with": "GSA, IC, OGP Web Portfolio"}, + normalize_string("USDA/ARS/NAL"): {"replace_with": "USDA, ARS, NAL"}, + } + + # Second: loop through every Suborganization and return a dict of what to keep, and what to delete + # for each duplicate or "incorrect" record. We do this by pruning records with extra spaces or bad caps + # Note that "extra_records_to_prune" is just a manual mapping. + records_to_prune = self.get_records_to_prune(extra_records_to_prune) + if len(records_to_prune) == 0: + TerminalHelper.colorful_logger(logger.error, TerminalColors.FAIL, "No suborganizations to delete.") + return + + # Third: Build a preview of the changes + total_records_to_remove = 0 + preview_lines = ["The following records will be removed:"] + for data in records_to_prune.values(): + keep = data.get("keep") + delete = data.get("delete") + if keep: + preview_lines.append(f"Keeping: '{keep.name}' (id: {keep.id})") + + for duplicate in delete: + preview_lines.append(f"Removing: '{duplicate.name}' (id: {duplicate.id})") + total_records_to_remove += 1 + preview_lines.append("") + preview = "\n".join(preview_lines) + + # Fourth: Get user confirmation and delete + if TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + prompt_message=preview, + prompt_title=f"Remove {total_records_to_remove} suborganizations?", + verify_message="*** WARNING: This will replace the record on DomainInformation and DomainRequest! ***", + ): + try: + # Update all references to point to the right suborg before deletion + all_suborgs_to_remove = set() + for record in records_to_prune.values(): + best_record = record["keep"] + suborgs_to_remove = {dupe.id for dupe in record["delete"]} + DomainRequest.objects.filter(sub_organization_id__in=suborgs_to_remove).update( + sub_organization=best_record + ) + DomainInformation.objects.filter(sub_organization_id__in=suborgs_to_remove).update( + sub_organization=best_record + ) + all_suborgs_to_remove.update(suborgs_to_remove) + # Delete the suborgs + delete_count, _ = Suborganization.objects.filter(id__in=all_suborgs_to_remove).delete() + TerminalHelper.colorful_logger( + logger.info, TerminalColors.MAGENTA, f"Successfully deleted {delete_count} suborganizations." + ) + except Exception as e: + TerminalHelper.colorful_logger( + logger.error, TerminalColors.FAIL, f"Failed to delete suborganizations: {str(e)}" + ) + + def get_records_to_prune(self, extra_records_to_prune): + """Maps all suborgs into a dictionary with a record to keep, and an array of records to delete.""" + # First: Group all suborganization names by their "normalized" names (finding duplicates). + # Returns a dict that looks like this: + # { + # "amtrak": [, , ], + # "usda/oc": [], + # ...etc + # } + # + name_groups = {} + for suborg in Suborganization.objects.all(): + normalized_name = normalize_string(suborg.name) + name_groups.setdefault(normalized_name, []).append(suborg) + + # Second: find the record we should keep, and the records we should delete + # Returns a dict that looks like this: + # { + # "amtrak": { + # "keep": + # "delete": [, ] + # }, + # "usda/oc": { + # "keep": , + # "delete": [] + # }, + # ...etc + # } + records_to_prune = {} + for normalized_name, duplicate_suborgs in name_groups.items(): + # Delete data from our preset list + if normalized_name in extra_records_to_prune: + # The 'keep' field expects a Suborganization but we just pass in a string, so this is just a workaround. + # This assumes that there is only one item in the name_group array (see usda/oc example). + # But this should be fine, given our data. + hardcoded_record_name = extra_records_to_prune[normalized_name]["replace_with"] + name_group = name_groups.get(normalize_string(hardcoded_record_name)) + keep = name_group[0] if name_group else None + records_to_prune[normalized_name] = {"keep": keep, "delete": duplicate_suborgs} + # Delete duplicates (extra spaces or casing differences) + elif len(duplicate_suborgs) > 1: + # Pick the best record (fewest spaces, most leading capitals) + best_record = max( + duplicate_suborgs, + key=lambda suborg: (-suborg.name.count(" "), count_capitals(suborg.name, leading_only=True)), + ) + records_to_prune[normalized_name] = { + "keep": best_record, + "delete": [s for s in duplicate_suborgs if s != best_record], + } + return records_to_prune diff --git a/src/registrar/management/commands/remove_unused_portfolios.py b/src/registrar/management/commands/remove_unused_portfolios.py new file mode 100644 index 000000000..4940cc16f --- /dev/null +++ b/src/registrar/management/commands/remove_unused_portfolios.py @@ -0,0 +1,238 @@ +import argparse +import logging + +from django.core.management.base import BaseCommand +from django.db import IntegrityError +from django.db import transaction +from registrar.management.commands.utility.terminal_helper import ( + TerminalColors, + TerminalHelper, +) +from registrar.models import ( + Portfolio, + DomainGroup, + DomainInformation, + DomainRequest, + PortfolioInvitation, + Suborganization, + UserPortfolioPermission, +) + +logger = logging.getLogger(__name__) + +ALLOWED_PORTFOLIOS = [ + "Department of Veterans Affairs", + "Department of the Treasury", + "National Archives and Records Administration", + "Department of Defense", + "Office of Personnel Management", + "National Aeronautics and Space Administration", + "City and County of San Francisco", + "State of Arizona, Executive Branch", + "Department of the Interior", + "Department of State", + "Department of Justice", + "Capitol Police", + "Administrative Office of the Courts", + "Supreme Court of the United States", +] + + +class Command(BaseCommand): + help = "Remove all Portfolio entries with names not in the allowed list." + + def add_arguments(self, parser): + """ + OPTIONAL ARGUMENTS: + --debug + A boolean (default to true), which activates additional print statements + """ + parser.add_argument("--debug", action=argparse.BooleanOptionalAction) + + def prompt_delete_entries(self, portfolios_to_delete, debug_on): + """Brings up a prompt in the terminal asking + if the user wishes to delete data in the + Portfolio table. If the user confirms, + deletes the data in the Portfolio table""" + + entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True)) + formatted_entries = "\n\t\t".join(entries_to_remove_by_name) + confirm_delete = TerminalHelper.query_yes_no( + f""" + {TerminalColors.FAIL} + WARNING: You are about to delete the following portfolios: + + {formatted_entries} + + Are you sure you want to continue?{TerminalColors.ENDC}""" + ) + if confirm_delete: + logger.info( + f"""{TerminalColors.YELLOW} + ----------Deleting entries---------- + (please wait) + {TerminalColors.ENDC}""" + ) + self.delete_entries(portfolios_to_delete, debug_on) + else: + logger.info( + f"""{TerminalColors.OKCYAN} + ----------No entries deleted---------- + (exiting script) + {TerminalColors.ENDC}""" + ) + + def delete_entries(self, portfolios_to_delete, debug_on): # noqa: C901 + # Log the number of entries being removed + count = portfolios_to_delete.count() + if count == 0: + logger.info( + f"""{TerminalColors.OKCYAN} + No entries to remove. + {TerminalColors.ENDC} + """ + ) + return + + # If debug mode is on, print out entries being removed + if debug_on: + entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True)) + formatted_entries = ", ".join(entries_to_remove_by_name) + logger.info( + f"""{TerminalColors.YELLOW} + Entries to be removed: {formatted_entries} + {TerminalColors.ENDC} + """ + ) + + # Check for portfolios with non-empty related objects + # (These will throw integrity errors if they are not updated) + portfolios_with_assignments = [] + for portfolio in portfolios_to_delete: + has_assignments = any( + [ + DomainGroup.objects.filter(portfolio=portfolio).exists(), + DomainInformation.objects.filter(portfolio=portfolio).exists(), + DomainRequest.objects.filter(portfolio=portfolio).exists(), + PortfolioInvitation.objects.filter(portfolio=portfolio).exists(), + Suborganization.objects.filter(portfolio=portfolio).exists(), + UserPortfolioPermission.objects.filter(portfolio=portfolio).exists(), + ] + ) + if has_assignments: + portfolios_with_assignments.append(portfolio) + + if portfolios_with_assignments: + formatted_entries = "\n\t\t".join( + f"{portfolio.organization_name}" for portfolio in portfolios_with_assignments + ) + confirm_cascade_delete = TerminalHelper.query_yes_no( + f""" + {TerminalColors.FAIL} + WARNING: these entries have related objects. + + {formatted_entries} + + Deleting them will update any associated domains / domain requests to have no portfolio + and will cascade delete any associated portfolio invitations, portfolio permissions, domain groups, + and suborganizations. Any suborganizations that get deleted will also orphan (not delete) their + associated domains / domain requests. + + Are you sure you want to continue?{TerminalColors.ENDC}""" + ) + if not confirm_cascade_delete: + logger.info( + f"""{TerminalColors.OKCYAN} + Operation canceled by the user. + {TerminalColors.ENDC} + """ + ) + return + + with transaction.atomic(): + # Try to delete the portfolios + try: + summary = [] + for portfolio in portfolios_to_delete: + portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"] + if portfolio in portfolios_with_assignments: + domain_groups = DomainGroup.objects.filter(portfolio=portfolio) + domain_informations = DomainInformation.objects.filter(portfolio=portfolio) + domain_requests = DomainRequest.objects.filter(portfolio=portfolio) + portfolio_invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) + suborganizations = Suborganization.objects.filter(portfolio=portfolio) + user_permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + + if domain_groups.exists(): + formatted_groups = "\n".join([str(group) for group in domain_groups]) + portfolio_summary.append(f"{len(domain_groups)} Deleted DomainGroups:\n{formatted_groups}") + domain_groups.delete() + + if domain_informations.exists(): + formatted_domain_infos = "\n".join([str(info) for info in domain_informations]) + portfolio_summary.append( + f"{len(domain_informations)} Orphaned DomainInformations:\n{formatted_domain_infos}" + ) + domain_informations.update(portfolio=None) + + if domain_requests.exists(): + formatted_domain_reqs = "\n".join([str(req) for req in domain_requests]) + portfolio_summary.append( + f"{len(domain_requests)} Orphaned DomainRequests:\n{formatted_domain_reqs}" + ) + domain_requests.update(portfolio=None) + + if portfolio_invitations.exists(): + formatted_portfolio_invitations = "\n".join([str(inv) for inv in portfolio_invitations]) + portfolio_summary.append( + f"{len(portfolio_invitations)} Deleted PortfolioInvitations:\n{formatted_portfolio_invitations}" # noqa + ) + portfolio_invitations.delete() + + if user_permissions.exists(): + formatted_user_list = "\n".join( + [perm.user.get_formatted_name() for perm in user_permissions] + ) + portfolio_summary.append( + f"Deleted UserPortfolioPermissions for the following users:\n{formatted_user_list}" + ) + user_permissions.delete() + + if suborganizations.exists(): + portfolio_summary.append("Cascade Deleted Suborganizations:") + for suborg in suborganizations: + DomainInformation.objects.filter(sub_organization=suborg).update(sub_organization=None) + DomainRequest.objects.filter(sub_organization=suborg).update(sub_organization=None) + portfolio_summary.append(f"{suborg.name}") + suborg.delete() + + portfolio.delete() + summary.append("\n\n".join(portfolio_summary)) + summary_string = "\n\n".join(summary) + + # Output a success message with detailed summary + logger.info( + f"""{TerminalColors.OKCYAN} + Successfully removed {count} portfolios. + + The following portfolio deletions had cascading effects; + + {summary_string} + {TerminalColors.ENDC} + """ + ) + + except IntegrityError as e: + logger.info( + f"""{TerminalColors.FAIL} + Could not delete some portfolios due to integrity constraints: + {e} + {TerminalColors.ENDC} + """ + ) + + def handle(self, *args, **options): + # Get all Portfolio entries not in the allowed portfolios list + portfolios_to_delete = Portfolio.objects.exclude(organization_name__in=ALLOWED_PORTFOLIOS) + + self.prompt_delete_entries(portfolios_to_delete, options.get("debug")) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index eed1027f7..87d9f12e5 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -401,16 +401,15 @@ class TerminalHelper: # Allow the user to inspect the command string # and ask if they wish to proceed proceed_execution = TerminalHelper.query_yes_no_exit( - f"""{TerminalColors.OKCYAN} - ===================================================== - {prompt_title} - ===================================================== - {verify_message} - - {prompt_message} - {TerminalColors.FAIL} - Proceed? (Y = proceed, N = {action_description_for_selecting_no}) - {TerminalColors.ENDC}""" + f"\n{TerminalColors.OKCYAN}" + "=====================================================" + f"\n{prompt_title}\n" + "=====================================================" + f"\n{verify_message}\n" + f"\n{prompt_message}\n" + f"{TerminalColors.FAIL}" + f"Proceed? (Y = proceed, N = {action_description_for_selecting_no})" + f"{TerminalColors.ENDC}" ) # If the user decided to proceed return true. @@ -443,13 +442,14 @@ class TerminalHelper: f.write(file_contents) @staticmethod - def colorful_logger(log_level, color, message): + def colorful_logger(log_level, color, message, exc_info=True): """Adds some color to your log output. Args: log_level: str | Logger.method -> Desired log level. ex: logger.info or "INFO" color: str | TerminalColors -> Output color. ex: TerminalColors.YELLOW or "YELLOW" message: str -> Message to display. + exc_info: bool -> Whether the log should print exc_info or not """ if isinstance(log_level, str) and hasattr(logger, log_level.lower()): @@ -463,4 +463,4 @@ class TerminalHelper: terminal_color = color colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}" - log_method(colored_message) + log_method(colored_message, exc_info=exc_info) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 6bd8278a1..cb481db7a 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -4,9 +4,9 @@ import ipaddress import re from datetime import date, timedelta from typing import Optional +from django.db import transaction from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore - -from django.db import models +from django.db import models, IntegrityError from django.utils import timezone from typing import Any from registrar.models.host import Host @@ -1329,14 +1329,14 @@ class Domain(TimeStampedModel, DomainHelper): def get_default_administrative_contact(self): """Gets the default administrative contact.""" - logger.info("get_default_security_contact() -> Adding administrative security contact") + logger.info("get_default_administrative_contact() -> Adding default administrative contact") contact = PublicContact.get_default_administrative() contact.domain = self return contact def get_default_technical_contact(self): """Gets the default technical contact.""" - logger.info("get_default_security_contact() -> Adding technical security contact") + logger.info("get_default_security_contact() -> Adding default technical contact") contact = PublicContact.get_default_technical() contact.domain = self return contact @@ -1678,9 +1678,11 @@ class Domain(TimeStampedModel, DomainHelper): for domainContact in contact_data: req = commands.InfoContact(id=domainContact.contact) data = registry.send(req, cleaned=True).res_data[0] + logger.info(f"_fetch_contacts => this is the data: {data}") # Map the object we recieved from EPP to a PublicContact mapped_object = self.map_epp_contact_to_public_contact(data, domainContact.contact, domainContact.type) + logger.info(f"_fetch_contacts => mapped_object: {mapped_object}") # Find/create it in the DB in_db = self._get_or_create_public_contact(mapped_object) @@ -1871,8 +1873,9 @@ class Domain(TimeStampedModel, DomainHelper): missingSecurity = True missingTech = True - if len(cleaned.get("_contacts")) < 3: - for contact in cleaned.get("_contacts"): + contacts = cleaned.get("_contacts", []) + if len(contacts) < 3: + for contact in contacts: if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: missingAdmin = False if contact.type == PublicContact.ContactTypeChoices.SECURITY: @@ -1891,6 +1894,11 @@ class Domain(TimeStampedModel, DomainHelper): technical_contact = self.get_default_technical_contact() technical_contact.save() + logger.info( + "_add_missing_contacts_if_unknown => Adding contacts. Values are " + f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}" + ) + def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False): """Contact registry for info about a domain.""" try: @@ -2104,8 +2112,21 @@ class Domain(TimeStampedModel, DomainHelper): # Save to DB if it doesn't exist already. if db_contact.count() == 0: # Doesn't run custom save logic, just saves to DB - public_contact.save(skip_epp_save=True) - logger.info(f"Created a new PublicContact: {public_contact}") + try: + with transaction.atomic(): + public_contact.save(skip_epp_save=True) + logger.info(f"Created a new PublicContact: {public_contact}") + except IntegrityError as err: + logger.error( + f"_get_or_create_public_contact() => tried to create a duplicate public contact: {err}", + exc_info=True, + ) + return PublicContact.objects.get( + registry_id=public_contact.registry_id, + contact_type=public_contact.contact_type, + domain=self, + ) + # Append the item we just created return public_contact @@ -2115,7 +2136,7 @@ class Domain(TimeStampedModel, DomainHelper): if existing_contact.email != public_contact.email or existing_contact.registry_id != public_contact.registry_id: existing_contact.delete() public_contact.save() - logger.warning("Requested PublicContact is out of sync " "with DB.") + logger.warning("Requested PublicContact is out of sync with DB.") return public_contact # If it already exists, we can assume that the DB instance was updated during set, so we should just use that. diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index b1c4fd806..aa933e282 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -101,7 +101,6 @@ class DomainInformation(TimeStampedModel): verbose_name="election office", ) - # TODO - Ticket #1911: stub this data from DomainRequest organization_type = models.CharField( max_length=255, choices=DomainRequest.OrgChoicesElectionOffice.choices, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 3d3aac769..c5a0926ad 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -9,6 +9,7 @@ from django.utils import timezone from registrar.models.domain import Domain from registrar.models.federal_agency import FederalAgency from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.constants import BranchChoices from auditlog.models import LogEntry @@ -903,6 +904,7 @@ class DomainRequest(TimeStampedModel): email_template, email_template_subject, bcc_address="", + cc_addresses: list[str] = [], context=None, send_email=True, wrap_email=False, @@ -955,12 +957,20 @@ class DomainRequest(TimeStampedModel): if custom_email_content: context["custom_email_content"] = custom_email_content + + if self.requesting_entity_is_portfolio() or self.requesting_entity_is_suborganization(): + portfolio_view_requests_users = self.portfolio.portfolio_users_with_permissions( # type: ignore + permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], include_admin=True + ) + cc_addresses = list(portfolio_view_requests_users.values_list("email", flat=True)) + send_templated_email( email_template, email_template_subject, recipient.email, context=context, bcc_address=bcc_address, + cc_addresses=cc_addresses, wrap_email=wrap_email, ) logger.info(f"The {new_status} email sent to: {recipient.email}") diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 82afcd4d6..9607736f2 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -4,6 +4,7 @@ from registrar.models.domain_request import DomainRequest from registrar.models.federal_agency import FederalAgency from registrar.models.user import User from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from django.db.models import Q from .utility.time_stamped_model import TimeStampedModel @@ -122,6 +123,16 @@ class Portfolio(TimeStampedModel): if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization: self.urbanization = None + # If the org type is federal, and org federal agency is not blank, and is a federal agency + # overwrite the organization name with the federal agency's agency + if ( + self.organization_type == self.OrganizationChoices.FEDERAL + and self.federal_agency + and self.federal_agency != FederalAgency.get_non_federal_agency() + and self.federal_agency.agency + ): + self.organization_name = self.federal_agency.agency + super().save(*args, **kwargs) @property @@ -144,6 +155,25 @@ class Portfolio(TimeStampedModel): ).values_list("user__id", flat=True) return User.objects.filter(id__in=admin_ids) + def portfolio_users_with_permissions(self, permissions=[], include_admin=False): + """Gets all users with specified additional permissions for this particular portfolio. + Returns a queryset of User.""" + portfolio_users = self.portfolio_users + if permissions: + if include_admin: + portfolio_users = portfolio_users.filter( + Q(additional_permissions__overlap=permissions) + | Q( + roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ] + ), + ) + else: + portfolio_users = portfolio_users.filter(additional_permissions__overlap=permissions) + user_ids = portfolio_users.values_list("user__id", flat=True) + return User.objects.filter(id__in=user_ids) + # == Getters for domains == # def get_domains(self, order_by=None): """Returns all DomainInformations associated with this portfolio""" diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 38ce4f35d..3268e9dc9 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -55,7 +55,9 @@ class SeniorOfficial(TimeStampedModel): return " ".join(names) if names else "Unknown" def __str__(self): - if self.first_name or self.last_name: + if self.federal_agency and (self.first_name or self.last_name): + return self.get_formatted_name() + " of " + self.federal_agency.__str__() + elif self.first_name or self.last_name: return self.get_formatted_name() elif self.pk: return str(self.pk) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 03a01b80d..c4be90a9b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -21,16 +21,18 @@ class UserPortfolioPermission(TimeStampedModel): UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, - # Domain: field specific permissions UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], # NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here. UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, ], } @@ -38,9 +40,9 @@ class UserPortfolioPermission(TimeStampedModel): # Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation. FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_MEMBERS, - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], } diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py index 87a885309..0459831e9 100644 --- a/src/registrar/models/utility/domain_helper.py +++ b/src/registrar/models/utility/domain_helper.py @@ -15,9 +15,11 @@ class DomainHelper: # a domain name is alphanumeric or hyphen, up to 63 characters, doesn't # begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters - DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?{{ current_user.email }}

Phone:
{{ current_user.phone }}
-

 

+

 

Domains:
{% if current_user_domains %} diff --git a/src/registrar/templates/django/admin/domain_delete_confirmation.html b/src/registrar/templates/django/admin/domain_delete_confirmation.html index 5a9bef5b0..3228188d7 100644 --- a/src/registrar/templates/django/admin/domain_delete_confirmation.html +++ b/src/registrar/templates/django/admin/domain_delete_confirmation.html @@ -8,7 +8,7 @@ aria-labelledby="summary-box-description" >
-

+

When a domain is deleted:

diff --git a/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html index 3e0a32a4d..68f726708 100644 --- a/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html +++ b/src/registrar/templates/django/admin/domain_delete_selected_confirmation.html @@ -9,7 +9,7 @@ aria-labelledby="summary-box-description" >
-

+

When a domain is deleted:

diff --git a/src/registrar/templates/django/admin/domain_invitation_change_form.html b/src/registrar/templates/django/admin/domain_invitation_change_form.html new file mode 100644 index 000000000..6ce6ed0d1 --- /dev/null +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -0,0 +1,14 @@ +{% extends 'django/admin/email_clipboard_change_form.html' %} +{% load custom_filters %} +{% load i18n static %} + +{% block content_subtitle %} +
+
+

+ If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the User domain roles permissions table instead. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html index fe62f268b..d07e5abf4 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html @@ -30,7 +30,7 @@ {{ member.user.phone }} {% for role in member.user|portfolio_role_summary:original %} - {{ role }} + {{ role }} {% endfor %} diff --git a/src/registrar/templates/django/admin/user_domain_role_change_form.html b/src/registrar/templates/django/admin/user_domain_role_change_form.html new file mode 100644 index 000000000..d8c298bc1 --- /dev/null +++ b/src/registrar/templates/django/admin/user_domain_role_change_form.html @@ -0,0 +1,14 @@ +{% extends 'django/admin/email_clipboard_change_form.html' %} +{% load custom_filters %} +{% load i18n static %} + +{% block content_subtitle %} +
+
+

+ If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the User Domain Role invitations table instead. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/forms/widgets/combobox.html b/src/registrar/templates/django/forms/widgets/combobox.html index 7ff31945b..02cd4e35e 100644 --- a/src/registrar/templates/django/forms/widgets/combobox.html +++ b/src/registrar/templates/django/forms/widgets/combobox.html @@ -11,6 +11,7 @@ for now we just carry the attribute to both the parent element and the select. {{ name }}="{{ value }}" {% endif %} {% endfor %} +data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}" > - {% include "django/forms/widgets/select.html" %} + {% include "django/forms/widgets/select.html" with is_combobox=True %}
diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index cc0e11989..af98e898b 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -21,7 +21,7 @@ {% if field and field.field and field.field.descriptions %} {% with description=field.field.descriptions|get_dict_value:option.value %} {% if description %} -

{{ description }}

+

{{ description }}

{% endif %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/django/forms/widgets/select.html b/src/registrar/templates/django/forms/widgets/select.html index cc62eb91d..db6deafe2 100644 --- a/src/registrar/templates/django/forms/widgets/select.html +++ b/src/registrar/templates/django/forms/widgets/select.html @@ -3,6 +3,9 @@ {# hint: spacing in the class string matters #} class="usa-select{% if classes %} {{ classes }}{% endif %}" {% include "django/forms/widgets/attrs.html" %} + {% if is_combobox %} + data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}" + {% endif %} > {% for group, options, index in widget.optgroups %} {% if group %}{% endif %} diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index b09f1f814..04565f61e 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -4,6 +4,9 @@ {% block title %}Add a domain manager | {% endblock %} {% block domain_content %} + + {% include "includes/form_errors.html" with form=form %} + {% block breadcrumb %} {% if portfolio %} @@ -38,8 +41,6 @@ {% endif %} {% endblock breadcrumb %} - {% include "includes/form_errors.html" with form=form %} -

Add a domain manager

{% if has_organization_feature_flag %}

diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index c88492a93..58038d0a4 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -11,7 +11,7 @@

Domain name: {{ domain.name }}

@@ -26,7 +26,7 @@ {% if not domain.domain_info %}
-

Domain missing domain information

+

Domain missing domain information

You are attempting to manage a domain, {{ domain.name }}, which does not have a domain information object. Please correct this in the admin by editing the domain, and adding domain information, as appropriate.

@@ -36,7 +36,7 @@ {% if is_analyst_or_superuser and analyst_action == 'edit' and analyst_action_location == domain.pk %}
-

Attention!

+

Attention!

You are making changes to a registrant’s domain. When finished making changes, close this tab and inform the registrant of your updates.

@@ -46,7 +46,7 @@ {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} -
+
{{ message }}
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 2cd3e5a5c..1d34ef4e4 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -21,21 +21,17 @@ {{ block.super }}
-

{{ domain.name }}

+

{{ domain.name }}

-

- - Status: - - - +

+ Status: {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %} Expired @@ -46,9 +42,10 @@ {% else %} {{ domain.state|title }} {% endif %} - +

+ {% if domain.get_state_help_text %} -
+

{% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %} This domain has expired, but it is still online. {% url 'domain-renewal' pk=domain.id as url %} @@ -64,13 +61,11 @@ {% else %} {{ domain.get_state_help_text }} {% endif %} -

+

{% endif %} -

+
-
-
- +
{% include "includes/domain_dates.html" %} diff --git a/src/registrar/templates/domain_dnssec.html b/src/registrar/templates/domain_dnssec.html index a795fb2fc..3beb2548e 100644 --- a/src/registrar/templates/domain_dnssec.html +++ b/src/registrar/templates/domain_dnssec.html @@ -35,21 +35,23 @@ {% csrf_token %} {% if has_dnssec_records %}
-

-

To fully disable DNSSEC

-
    -
  • Click “Disable DNSSEC” below.
  • -
  • Wait until the Time to Live (TTL) expires on your DNSSEC records managed by your DNS hosting provider. This is often less than 24 hours, but confirm with your provider.
  • -
  • After the TTL expiration, disable DNSSEC at your DNS hosting provider.
  • -
-

Warning: If you disable DNSSEC at your DNS hosting provider before TTL expiration, this may cause your domain to appear offline.

+

To fully disable DNSSEC

+ +
+
    +
  • Click “Disable DNSSEC” below.
  • +
  • Wait until the Time to Live (TTL) expires on your DNSSEC records managed by your DNS hosting provider. This is often less than 24 hours, but confirm with your provider.
  • +
  • After the TTL expiration, disable DNSSEC at your DNS hosting provider.
  • +
+

Warning: If you disable DNSSEC at your DNS hosting provider before TTL expiration, this may cause your domain to appear offline.

+
+

DNSSEC is enabled on your domain

diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 5ebb264c4..36eb811e3 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -5,6 +5,10 @@ {% block domain_content %} + {% for form in formset %} + {% include "includes/form_errors.html" with form=form %} + {% endfor %} + {% block breadcrumb %} {% if portfolio %} @@ -38,10 +42,6 @@
{% endif %} - {% for form in formset %} - {% include "includes/form_errors.html" with form=form %} - {% endfor %} -

DS data

In order to enable DNSSEC, you must first configure it with your DNS hosting service.

diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index a5fd171a2..ad8d61592 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -4,6 +4,12 @@ {% block title %}DNS name servers | {{ domain.name }} | {% endblock %} {% block domain_content %} + + {# this is right after the messages block in the parent template #} + {% for form in formset %} + {% include "includes/form_errors.html" with form=form %} + {% endfor %} + {% block breadcrumb %} {% if portfolio %} @@ -26,11 +32,6 @@ {% endif %} {% endblock breadcrumb %} - {# this is right after the messages block in the parent template #} - {% for form in formset %} - {% include "includes/form_errors.html" with form=form %} - {% endfor %} -

DNS name servers

Before your domain can be used we’ll need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.

diff --git a/src/registrar/templates/domain_renewal.html b/src/registrar/templates/domain_renewal.html index 32e535ed5..30e1be0e4 100644 --- a/src/registrar/templates/domain_renewal.html +++ b/src/registrar/templates/domain_renewal.html @@ -37,7 +37,7 @@ {{ block.super }}
-

Confirm the following information for accuracy

+

Confirm the following information for accuracy

Review these details below. We require that you maintain accurate information for the domain. The details you provide will only be used to support the administration of .gov and won't be made public. diff --git a/src/registrar/templates/domain_request_current_sites.html b/src/registrar/templates/domain_request_current_sites.html index 2a2ac6885..769906309 100644 --- a/src/registrar/templates/domain_request_current_sites.html +++ b/src/registrar/templates/domain_request_current_sites.html @@ -3,8 +3,8 @@ {% block form_instructions %}

We can better evaluate your request if we know about domains you’re already using.

-

What are the current websites for your organization?

-

Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.

+

What are the current websites for your organization?

+

Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.

{% endblock %} {% block form_required_fields_help_text %} @@ -20,7 +20,7 @@ {% endwith %} {% endfor %} - @@ -70,7 +70,7 @@ {% endfor %} -
+
diff --git a/src/registrar/templates/includes/member_permissions_summary.html b/src/registrar/templates/includes/member_permissions_summary.html new file mode 100644 index 000000000..3a91d16f6 --- /dev/null +++ b/src/registrar/templates/includes/member_permissions_summary.html @@ -0,0 +1,33 @@ +

Member access

+{% if permissions.roles and 'organization_admin' in permissions.roles %} +

Admin

+{% elif permissions.roles and 'organization_member' in permissions.roles %} +

Basic

+{% else %} +

+{% endif %} + +

Domains

+{% if member_has_view_all_domains_portfolio_permission %} +

Viewer, all

+{% else %} +

Viewer, limited

+{% endif %} + +

Domain requests

+{% if member_has_edit_request_portfolio_permission %} +

Creator

+{% elif member_has_view_all_requests_portfolio_permission %} +

Viewer

+{% else %} +

No access

+{% endif %} + +

Members

+{% if member_has_edit_members_portfolio_permission %} +

Manager

+{% elif member_has_view_members_portfolio_permission %} +

Viewer

+{% else %} +

No access

+{% endif %} \ No newline at end of file diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 19320a4ae..cc308619a 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -54,7 +54,7 @@ Member - Last Active + Last active {{ modal_heading }} {%if domain_name_modal is not None %} - + {{ domain_name_modal }} {%endif%} diff --git a/src/registrar/templates/includes/portfolio_request_review_steps.html b/src/registrar/templates/includes/portfolio_request_review_steps.html index eecc5005a..89c8e652a 100644 --- a/src/registrar/templates/includes/portfolio_request_review_steps.html +++ b/src/registrar/templates/includes/portfolio_request_review_steps.html @@ -46,7 +46,7 @@ {% endwith %} {% if domain_request.alternative_domains.all %} -

Alternative domains

+

Alternative domains

    {% for site in domain_request.alternative_domains.all %}
  • {{ site.website }}
  • diff --git a/src/registrar/templates/includes/profile_information.html b/src/registrar/templates/includes/profile_information.html index 3e7c827f1..257e8d1dc 100644 --- a/src/registrar/templates/includes/profile_information.html +++ b/src/registrar/templates/includes/profile_information.html @@ -12,7 +12,7 @@ Your contact information
    -
      +
      • Full name: {{ user.get_formatted_name }}
      • Organization email: {{ user.email }}
      • Title or role in your organization: {{ user.title }}
      • diff --git a/src/registrar/templates/includes/request_review_steps.html b/src/registrar/templates/includes/request_review_steps.html index dada2dffb..6151d01a8 100644 --- a/src/registrar/templates/includes/request_review_steps.html +++ b/src/registrar/templates/includes/request_review_steps.html @@ -88,7 +88,7 @@ {% endwith %} {% if domain_request.alternative_domains.all %} -

        Alternative domains

        +

        Alternative domains

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

          CISA Regional Representative

          -
            +

            CISA Regional Representative

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

            Anything else

            -
              +

              Anything else

              +
                {% if domain_request.anything_else %} {{domain_request.anything_else}} {% else %} diff --git a/src/registrar/templates/includes/request_status_manage.html b/src/registrar/templates/includes/request_status_manage.html index b4738d0d0..d96adedf6 100644 --- a/src/registrar/templates/includes/request_status_manage.html +++ b/src/registrar/templates/includes/request_status_manage.html @@ -39,34 +39,32 @@ {% block status_summary %}
                -
                -

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

                +
                +
                +

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

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

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

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

                {% else %} -

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

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

                {% endif %} {% endif %} @@ -77,49 +75,32 @@ There is some code repetition, but it gives us more flexibility rather than a dense reduction. Leave it this way until we've solidified our requirements. {% endcomment %} - {% if DomainRequest.status == statuses.STARTED %} - {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} -

                + +

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

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

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

                -

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

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

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

                -

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

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

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

                -

                - Rejected on: {{last_status_update}} -

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

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

                -

                - Withdrawn on: {{last_status_update}} -

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

                - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} + Started on: {{last_status_update|default:first_started_date}} + {% endwith %} + {% elif DomainRequest.status == statuses.SUBMITTED %} + Submitted on: {{last_submitted|default:first_submitted }}
                + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} + {% elif DomainRequest.status == statuses.ACTION_NEEDED %} + Submitted on: {{last_submitted|default:first_submitted }}
                + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} + {% elif DomainRequest.status == statuses.REJECTED %} + Submitted on: {{last_submitted|default:first_submitted }}
                + Rejected on: {{last_status_update}} + {% elif DomainRequest.status == statuses.WITHDRAWN %} + Submitted on: {{last_submitted|default:first_submitted }}
                + Withdrawn on: {{last_status_update}} + {% else %} + {% comment %} Shown for in_review, approved, ineligible {% endcomment %} + Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}}

                {% endif %} {% endwith %} @@ -127,7 +108,7 @@ {% block status_blurb %} {% if DomainRequest.is_awaiting_review %} -

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

                + {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %} {% endif %} {% endblock status_blurb %} @@ -142,20 +123,19 @@
                {% block request_summary_header %} -

                Summary of your domain request

                +

                Summary of your domain request

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

                Federally-recognized tribe

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

                CISA Regional Representative

                +

                CISA Regional Representative

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

                Anything else

                +

                Anything else

                  {% if DomainRequest.anything_else %} {{DomainRequest.anything_else}} @@ -234,7 +214,6 @@ {% endif %}
                {% endif %} - {% endwith %} {% endif %} {% endblock request_summary%}
                diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 25387ecbd..26e56fea7 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -9,10 +9,7 @@ {% else %}

                {{ title }} @@ -22,10 +19,10 @@

                {% endif %} {% if sub_header_text %} -

                {{ sub_header_text }}

                +

                {{ sub_header_text }}

                {% endif %} {% if permissions %} - {% include "includes/member_permissions.html" with permissions=value %} + {% include "includes/member_permissions_summary.html" with permissions=value %} {% elif domain_mgmt %} {% include "includes/member_domain_management.html" with domain_count=value %} {% elif address %} @@ -40,9 +37,7 @@ {% for item in value %}
                -

                Contact {{forloop.counter}} @@ -119,7 +114,7 @@ {% endif %} {% endif %} {% if value.invitations.all %} -

                Invited domain managers

                +

                Invited domain managers

                  {% for item in value.invitations.all %}
                • {{ item.email }}
                • @@ -143,7 +138,7 @@
                  1. Members @@ -41,7 +41,7 @@ Organization member {% if has_edit_members_portfolio_permission %} {% if member %}
                    +