diff --git a/docs/developer/README.md b/docs/developer/README.md index 358df649c..9ddb35352 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o "username": "", "first_name": "", "last_name": "", + "email": "", }, ... ] @@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f "username": "", "first_name": "", "last_name": "", + "email": "", }, ... ] @@ -131,6 +133,20 @@ Analysts are a variant of the admin role with limited permissions. The process f Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst` +## Adding an email address to the email whitelist (sandboxes only) +On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions. + +To add yourself to this, you can go about it in three ways. + +Permanent (all sandboxes): +1. In src/registrar/fixtures_users.py, add the "email" field to your user in either the ADMIN or STAFF table. +2. In src/registrar/fixtures_users.py, add the desired email address to the `ADDITIONAL_ALLOWED_EMAILS` list. This route is suggested for product. + +Sandbox specific (wiped when the db is reset): +3. Create a new record on the `Allowed emails` table with your email address. This can be done through django admin. + +More detailed instructions regarding #3 can be found [here](https://docs.google.com/document/d/1ebIz4PcUuoiT7LlVy83EAyHAk_nWPEc99neMp4QjzDs). + ## Adding to CODEOWNERS (optional) The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of. diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 55421eda0..82bbc4e3a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -7,9 +7,11 @@ from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect +from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models.domain_information import DomainInformation +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages @@ -1953,6 +1955,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): else: obj.action_needed_reason_email = default_email + if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION: + self._check_for_valid_email(request, obj) + # == Handle status == # if obj.status == original_obj.status: # If the status hasn't changed, let the base function take care of it @@ -1965,6 +1970,29 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if should_save: return super().save_model(request, obj, form, change) + def _check_for_valid_email(self, request, obj): + """Certain emails are whitelisted in non-production environments, + so we should display that information using this function. + + """ + + # TODO 2574: remove lines 1977-1978 (refactor as needed) + profile_flag = flag_is_active(request, "profile_feature") + if profile_flag and hasattr(obj, "creator"): + recipient = obj.creator + elif not profile_flag and hasattr(obj, "submitter"): + recipient = obj.submitter + else: + recipient = None + + # Displays a warning in admin when an email cannot be sent + if recipient and recipient.email: + email = recipient.email + allowed = models.AllowedEmail.is_allowed_email(email) + error_message = f"Could not send email. The email '{email}' does not exist within the whitelist." + if not allowed: + messages.warning(request, error_message) + def _handle_status_change(self, request, obj, original_obj): """ Checks for various conditions when a status change is triggered. @@ -2926,11 +2954,7 @@ class PortfolioAdmin(ListHeaderAdmin): fieldsets = [ # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}" (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}), - # TODO - uncomment in #2521 - # ("Portfolio members", { - # "classes": ("collapse", "closed"), - # "fields": ["administrators", "members"]} - # ), + ("Portfolio members", {"fields": ["display_admins", "display_members"]}), ("Portfolio domains", {"fields": ["domains", "domain_requests"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}), ( @@ -2978,15 +3002,118 @@ class PortfolioAdmin(ListHeaderAdmin): readonly_fields = [ # This is the created_at field "created_on", - # Custom fields such as these must be defined as readonly. + # Django admin doesn't allow methods to be directly listed in fieldsets. We can + # display the custom methods display_admins amd display_members in the admin form if + # they are readonly. "federal_type", "domains", "domain_requests", "suborganizations", "portfolio_type", + "display_admins", + "display_members", "creator", ] + def get_admin_users(self, obj): + # Filter UserPortfolioPermission objects related to the portfolio + admin_permissions = UserPortfolioPermission.objects.filter( + portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Get the user objects associated with these permissions + admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions) + + return admin_users + + def get_non_admin_users(self, obj): + # Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role + non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude( + roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Get the user objects associated with these permissions + non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions) + + return non_admin_users + + def display_admins(self, obj): + """Get joined users who are Admin, unpack and return an HTML block. + + 'DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the field_readonly block""" + admins = self.get_admin_users(obj) + if not admins: + return format_html("

No admins found.

") + + admin_details = "" + for portfolio_admin in admins: + change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk]) + admin_details += "
" + admin_details += f'{escape(portfolio_admin)}
' + admin_details += f"{escape(portfolio_admin.title)}
" + admin_details += f"{escape(portfolio_admin.email)}" + admin_details += "
" + admin_details += f"{escape(portfolio_admin.phone)}" + admin_details += "
" + return format_html(admin_details) + + display_admins.short_description = "Administrators" # type: ignore + + def display_members(self, obj): + """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. + + DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the after_help_text block.""" + members = self.get_non_admin_users(obj) + if not members: + return "" + + member_details = ( + "" + + "" + ) + for member in members: + full_name = member.get_formatted_name() + member_details += "" + member_details += f"" + member_details += f"" + member_details += f"" + member_details += f"" + member_details += "" + member_details += "
NameTitleEmailPhoneRoles
{escape(full_name)}{escape(member.title)}{escape(member.email)}{escape(member.phone)}" + for role in member.portfolio_role_summary(obj): + member_details += f"{escape(role)} " + member_details += "
" + return format_html(member_details) + + display_members.short_description = "Members" # type: ignore + + def display_members_summary(self, obj): + """Will be passed as context and used in the field_readonly block.""" + members = self.get_non_admin_users(obj) + if not members: + return {} + + return self.get_field_links_as_list(members, "user", separator=", ") + def federal_type(self, obj: models.Portfolio): """Returns the federal_type field""" return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-" @@ -3046,7 +3173,7 @@ class PortfolioAdmin(ListHeaderAdmin): ] def get_field_links_as_list( - self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None + self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None ): """ Generate HTML links for items in a queryset, using a specified attribute for link text. @@ -3078,14 +3205,14 @@ class PortfolioAdmin(ListHeaderAdmin): if link_info_attribute: link += f" ({self.value_of_attribute(item, link_info_attribute)})" - if seperator: + if separator: links.append(link) else: links.append(f"
  • {link}
  • ") - # If no seperator is specified, just return an unordered list. - if seperator: - return format_html(seperator.join(links)) if links else "-" + # If no separator is specified, just return an unordered list. + if separator: + return format_html(separator.join(links)) if links else "-" else: links = "".join(links) return format_html(f'') if links else "-" @@ -3128,8 +3255,12 @@ class PortfolioAdmin(ListHeaderAdmin): return readonly_fields def change_view(self, request, object_id, form_url="", extra_context=None): - """Add related suborganizations and domain groups""" - extra_context = {"skip_additional_contact_info": True} + """Add related suborganizations and domain groups. + Add the summary for the portfolio members field (list of members that link to change_forms).""" + obj = self.get_object(request, object_id) + extra_context = extra_context or {} + extra_context["skip_additional_contact_info"] = True + extra_context["display_members_summary"] = self.display_members_summary(obj) return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): @@ -3238,6 +3369,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().change_view(request, object_id, form_url, extra_context) +class AllowedEmailAdmin(ListHeaderAdmin): + class Meta: + model = models.AllowedEmail + + list_display = ["email"] + search_fields = ["email"] + search_help_text = "Search by email." + ordering = ["email"] + + admin.site.unregister(LogEntry) # Unregister the default registration admin.site.register(LogEntry, CustomLogEntryAdmin) @@ -3266,6 +3407,7 @@ admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin) +admin.site.register(models.AllowedEmail, AllowedEmailAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 24f020b75..b24e946dc 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -519,7 +519,6 @@ function initializeWidgetOnList(list, parentId) { var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea") // Edit e-mail modal (and its confirmation button) - var actionNeededEmailAlreadySentModal = document.querySelector("#email-already-sent-modal") var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button") // Headers and footers (which change depending on if the e-mail was sent or not) @@ -561,11 +560,11 @@ function initializeWidgetOnList(list, parentId) { updateActionNeededEmailDisplay(reason) }); - editEmailButton.addEventListener("click", function() { - if (!checkEmailAlreadySent()) { - showEmail(canEdit=true) - } - }); + // editEmailButton.addEventListener("click", function() { + // if (!checkEmailAlreadySent()) { + // showEmail(canEdit=true) + // } + // }); confirmEditEmailButton.addEventListener("click", function() { // Show editable view diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 70659b009..e6b5a039f 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1220,7 +1220,7 @@ document.addEventListener('DOMContentLoaded', function() { const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const actionUrl = domain.action_url; - const suborganization = domain.suborganization ? domain.suborganization : ''; + const suborganization = domain.suborganization ? domain.suborganization : '⎯'; const row = document.createElement('tr'); @@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() { if (!noPortfolioFlag) { markupForSuborganizationRow = ` - ${suborganization} + ${suborganization} ` } @@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() { let editableFormGroup = button.parentElement.parentElement.parentElement; if (editableFormGroup){ - let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field") + let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field") let inputField = document.getElementById(`id_${fieldName}`); if (!inputField || !readonlyField) { return; @@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() { // Keep the path before '#' and replace the part after '#' with 'invalid' const newHref = parts[0] + '#error'; svg.setAttribute('xlink:href', newHref); - fullNameField.classList.add("input-with-edit-button__error") - label = fullNameField.querySelector(".input-with-edit-button__readonly-field") + fullNameField.classList.add("toggleable_input__error") + label = fullNameField.querySelector(".toggleable_input__readonly-field") label.innerHTML = "Unknown"; } } @@ -2043,11 +2043,11 @@ document.addEventListener('DOMContentLoaded', function() { // 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 = document.querySelector(`#${input.id}--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); + addBlankOption(clearInputButton, dropdownList, initialValue); } }); }); @@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() { if (!initialValue){ blankOption.classList.add("usa-combo-box__list-option--selected") } - blankOption.textContent = "---------"; + blankOption.textContent = "⎯"; dropdownList.insertBefore(blankOption, dropdownList.firstChild); blankOption.addEventListener("click", (e) => { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index f7d1e5788..e2377e07c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -546,7 +546,7 @@ button .usa-icon, #submitRowToggle { color: var(--body-fg); } - .requested-domain-sticky { + .submit-row-sticky { max-width: 325px; overflow: hidden; white-space: nowrap; diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 9f8a0cbb6..e3ab4d538 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -33,16 +33,19 @@ body { } #wrapper.dashboard--portfolio { - background-color: color('gray-1'); padding-top: units(4)!important; } +#wrapper.dashboard--grey-1 { + background-color: color('gray-1'); +} -.section--outlined { + +.section-outlined { background-color: color('white'); border: 1px solid color('base-lighter'); border-radius: 4px; - padding: 0 units(2) units(3); + padding: 0 units(4) units(3) units(2); margin-top: units(3); &.margin-top-0 { @@ -72,9 +75,13 @@ body { } } -.section--outlined__header--no-portfolio { - .section--outlined__search, - .section--outlined__utility-button { +.section-outlined--border-base-light { + border: 1px solid color('base-light'); +} + +.section-outlined__header--no-portfolio { + .section-outlined__search, + .section-outlined__utility-button { margin-top: units(2); } @@ -82,11 +89,11 @@ body { display: flex; column-gap: units(3); - .section--outlined__search, - .section--outlined__utility-button { + .section-outlined__search, + .section-outlined__utility-button { margin-top: 0; } - .section--outlined__search { + .section-outlined__search { flex-grow: 4; // Align right max-width: 383px; @@ -192,3 +199,7 @@ abbr[title] { max-width: 50ch; } } + +.margin-right-neg-4px { + margin-right: -4px; +} diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index d246366d8..12eee9926 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -124,10 +124,6 @@ a.withdraw:active { background-color: color('error-darker'); } -.usa-button--unstyled .usa-icon { - vertical-align: bottom; -} - a.usa-button--unstyled:visited { color: color('primary'); } @@ -162,14 +158,14 @@ a.usa-button--unstyled:visited { } } -.input-with-edit-button { +.toggleable_input { svg.usa-icon { width: 1.5em !important; height: 1.5em !important; color: #{$dhs-green}; position: absolute; } - &.input-with-edit-button__error { + &.toggleable_input__error { svg.usa-icon { color: #{$dhs-red}; } @@ -205,12 +201,32 @@ a.usa-button--unstyled:visited { } } +.dotgov-table a, +.usa-link--icon, +.usa-button--with-icon { + display: flex; + align-items: flex-start; + color: color('primary'); + column-gap: units(.5); + align-items: center; +} + + +.dotgov-table a, +.usa-link--icon { + &:visited { + color: color('primary'); + } +} + +a .usa-icon, +.usa-button--with-icon .usa-icon { + height: 1.3em; + width: 1.3em; +} + .usa-icon.usa-icon--big { margin: 0; height: 1.5em; width: 1.5em; } - -.margin-right-neg-4px { - margin-right: -4px; -} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss deleted file mode 100644 index fd1c3dee9..000000000 --- a/src/registrar/assets/sass/_theme/_links.scss +++ /dev/null @@ -1,18 +0,0 @@ -@use "uswds-core" as *; - -.dotgov-table a, -.usa-link--icon { - display: flex; - align-items: flex-start; - color: color('primary'); - - &:visited { - color: color('primary'); - } - .usa-icon { - // align icon with x height - margin-top: units(0.5); - margin-right: units(0.5); - } -} - diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss index e78715da8..d57b51117 100644 --- a/src/registrar/assets/sass/_theme/_tables.scss +++ b/src/registrar/assets/sass/_theme/_tables.scss @@ -1,5 +1,10 @@ @use "uswds-core" as *; +td, +th { + vertical-align: top; +} + .dotgov-table--stacked { td, th { padding: units(1) units(2) units(2px) 0; @@ -12,7 +17,7 @@ tr { border-bottom: none; - border-top: 2px solid color('base-light'); + border-top: 2px solid color('base-lighter'); margin-top: units(2); &:first-child { @@ -39,10 +44,6 @@ .dotgov-table { width: 100%; - th[data-sortable]:not([aria-sort]) .usa-table__header__button { - right: auto; - } - tbody th { word-break: break-word; } @@ -56,7 +57,7 @@ } td, th { - border-bottom: 1px solid color('base-light'); + border-bottom: 1px solid color('base-lighter'); } thead th { @@ -72,11 +73,17 @@ td, th, .usa-tabel th{ - padding: units(2) units(2) units(2) 0; + padding: units(2) units(4) units(2) 0; } thead tr:first-child th:first-child { border-top: none; } } + + @include at-media(tablet-lg) { + th[data-sortable]:not([aria-sort]) .usa-table__header__button { + right: auto; + } + } } diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index f9df015b4..5616b7509 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -10,7 +10,6 @@ --- Custom Styles ---------------------------------*/ @forward "base"; @forward "typography"; -@forward "links"; @forward "lists"; @forward "accordions"; @forward "buttons"; diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 0fc203248..1b8eda9ab 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -6,6 +6,7 @@ from registrar.models import ( User, UserGroup, ) +from registrar.models.allowed_email import AllowedEmail fake = Faker() @@ -32,6 +33,7 @@ class UserFixture: "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", "first_name": "Aditi", "last_name": "Green", + "email": "aditidevelops+01@gmail.com", }, { "username": "be17c826-e200-4999-9389-2ded48c43691", @@ -42,11 +44,13 @@ class UserFixture: "username": "5f283494-31bd-49b5-b024-a7e7cae00848", "first_name": "Rachid", "last_name": "Mrad", + "email": "rachid.mrad@associates.cisa.dhs.gov", }, { "username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74", "first_name": "Alysia", "last_name": "Broddrick", + "email": "abroddrick@truss.works", }, { "username": "8f8e7293-17f7-4716-889b-1990241cbd39", @@ -63,6 +67,7 @@ class UserFixture: "username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c", "first_name": "Cameron", "last_name": "Dixon", + "email": "cameron.dixon@cisa.dhs.gov", }, { "username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea", @@ -83,16 +88,19 @@ class UserFixture: "username": "2a88a97b-be96-4aad-b99e-0b605b492c78", "first_name": "Rebecca", "last_name": "Hsieh", + "email": "rebecca.hsieh@truss.works", }, { "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52", "first_name": "David", "last_name": "Kennedy", + "email": "david.kennedy@ecstech.com", }, { "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d", "first_name": "Nicolle", "last_name": "LeClair", + "email": "nicolle.leclair@ecstech.com", }, { "username": "24840450-bf47-4d89-8aa9-c612fe68f9da", @@ -141,6 +149,7 @@ class UserFixture: "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", "first_name": "Aditi-Analyst", "last_name": "Green-Analyst", + "email": "aditidevelops+02@gmail.com", }, { "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", @@ -183,6 +192,7 @@ class UserFixture: "username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c", "first_name": "David-Analyst", "last_name": "Kennedy-Analyst", + "email": "david.kennedy@associates.cisa.dhs.gov", }, { "username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47", @@ -194,7 +204,7 @@ class UserFixture: "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3", "first_name": "Nicolle-Analyst", "last_name": "LeClair-Analyst", - "email": "nicolle.leclair@ecstech.com", + "email": "nicolle.leclair@gmail.com", }, { "username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9", @@ -240,6 +250,9 @@ class UserFixture: }, ] + # Additional emails to add to the AllowedEmail whitelist. + ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"] + def load_users(cls, users, group_name, are_superusers=False): logger.info(f"Going to load {len(users)} users in group {group_name}") for user_data in users: @@ -264,6 +277,32 @@ class UserFixture: logger.warning(e) logger.info(f"All users in group {group_name} loaded.") + def load_allowed_emails(cls, users, additional_emails): + """Populates a whitelist of allowed emails (as defined in this list)""" + logger.info(f"Going to load allowed emails for {len(users)} users") + if additional_emails: + logger.info(f"Going to load {len(additional_emails)} additional allowed emails") + + # Load user emails + allowed_emails = [] + for user_data in users: + user_email = user_data.get("email") + if user_email and user_email not in allowed_emails: + allowed_emails.append(AllowedEmail(email=user_email)) + else: + first_name = user_data.get("first_name") + last_name = user_data.get("last_name") + logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.") + + # Load additional emails + allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails]) + + if allowed_emails: + AllowedEmail.objects.bulk_create(allowed_emails) + logger.info(f"Loaded {len(allowed_emails)} allowed emails") + else: + logger.info("No allowed emails to load") + @classmethod def load(cls): # Lumped under .atomic to ensure we don't make redundant DB calls. @@ -275,3 +314,7 @@ class UserFixture: with transaction.atomic(): cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True) cls.load_users(cls, cls.STAFF, "cisa_analysts_group") + + # Combine ADMINS and STAFF lists + all_users = cls.ADMINS + cls.STAFF + cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index a7a006788..84fcbe973 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm): # This action should be blocked by the UI, as the text fields are readonly. # If they get past this point, we forbid it this way. # This could be malicious, so lets reserve information for the backend only. - raise ValueError("Senior Official cannot be modified for federal or tribal domains.") + raise ValueError("Senior official cannot be modified for federal or tribal domains.") elif db_so.has_more_than_one_join("information_senior_official"): # Handle the case where the domain information object is available and the SO Contact # has more than one joined object. diff --git a/src/registrar/migrations/0121_allowedemail.py b/src/registrar/migrations/0121_allowedemail.py new file mode 100644 index 000000000..ebed1ac15 --- /dev/null +++ b/src/registrar/migrations/0121_allowedemail.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.10 on 2024-08-29 18:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0120_add_domainrequest_submission_dates"), + ] + + operations = [ + migrations.CreateModel( + name="AllowedEmail", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("email", models.EmailField(max_length=320, unique=True)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/registrar/migrations/0122_create_groups_v16.py b/src/registrar/migrations/0122_create_groups_v16.py new file mode 100644 index 000000000..82c750976 --- /dev/null +++ b/src/registrar/migrations/0122_create_groups_v16.py @@ -0,0 +1,37 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0121_allowedemail"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index c1023cafe..a1738cc76 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -22,6 +22,7 @@ from .domain_group import DomainGroup from .suborganization import Suborganization from .senior_official import SeniorOfficial from .user_portfolio_permission import UserPortfolioPermission +from .allowed_email import AllowedEmail __all__ = [ @@ -48,6 +49,7 @@ __all__ = [ "Suborganization", "SeniorOfficial", "UserPortfolioPermission", + "AllowedEmail", ] auditlog.register(Contact) @@ -73,3 +75,4 @@ auditlog.register(DomainGroup) auditlog.register(Suborganization) auditlog.register(SeniorOfficial) auditlog.register(UserPortfolioPermission) +auditlog.register(AllowedEmail) diff --git a/src/registrar/models/allowed_email.py b/src/registrar/models/allowed_email.py new file mode 100644 index 000000000..6622bcc55 --- /dev/null +++ b/src/registrar/models/allowed_email.py @@ -0,0 +1,52 @@ +from django.db import models +from django.db.models import Q +import re +from .utility.time_stamped_model import TimeStampedModel + + +class AllowedEmail(TimeStampedModel): + """ + AllowedEmail is a whitelist for email addresses that we can send to + in non-production environments. + """ + + email = models.EmailField( + unique=True, + null=False, + blank=False, + max_length=320, + ) + + @classmethod + def is_allowed_email(cls, email): + """Given an email, check if this email exists within our AllowEmail whitelist""" + + if not email: + return False + + # Split the email into a local part and a domain part + local, domain = email.split("@") + + # If the email exists within the whitelist, then do nothing else. + email_exists = cls.objects.filter(email__iexact=email).exists() + if email_exists: + return True + + # Check if there's a '+' in the local part + if "+" in local: + base_local = local.split("+")[0] + base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists() + + # Given an example email, such as "joe.smoe+1@igorville.com" + # The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$" + pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$" + return base_email_exists and re.match(pattern, email) + else: + # Edge case, the +1 record exists but the base does not, + # and the record we are checking is the base record. + pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$" + plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists() + return plus_email_exists + + def __str__(self): + return str(self.email) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 594070468..babc955aa 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -581,6 +581,12 @@ class DomainRequest(TimeStampedModel): blank=True, ) + @classmethod + def get_statuses_that_send_emails(cls): + """Returns a list of statuses that send an email to the user""" + excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW] + return [status for status in cls.DomainRequestStatus if status not in excluded_statuses] + def sync_organization_type(self): """ Updates the organization_type (without saving) to match diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index a7ea1e14a..8d91c2a8c 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -245,6 +245,49 @@ class User(AbstractUser): return permission.portfolio return None + def has_edit_requests(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) + + def portfolio_role_summary(self, portfolio): + """Returns a list of roles based on the user's permissions.""" + roles = [] + + # Define the conditions and their corresponding roles + conditions_roles = [ + (self.has_edit_suborganization(portfolio), ["Admin"]), + ( + self.has_view_all_domains_permission(portfolio) + and self.has_domain_requests_portfolio_permission(portfolio) + and self.has_edit_requests(portfolio), + ["View-only admin", "Domain requestor"], + ), + ( + self.has_view_all_domains_permission(portfolio) + and self.has_domain_requests_portfolio_permission(portfolio), + ["View-only admin"], + ), + ( + self.has_base_portfolio_permission(portfolio) + and self.has_edit_requests(portfolio) + and self.has_domains_portfolio_permission(portfolio), + ["Domain requestor", "Domain manager"], + ), + (self.has_base_portfolio_permission(portfolio) and self.has_edit_requests(portfolio), ["Domain requestor"]), + ( + self.has_base_portfolio_permission(portfolio) and self.has_domains_portfolio_permission(portfolio), + ["Domain manager"], + ), + (self.has_base_portfolio_permission(portfolio), ["Member"]), + ] + + # Evaluate conditions and add roles + for condition, role_list in conditions_roles: + if condition: + roles.extend(role_list) + break + + return roles + @classmethod def needs_identity_verification(cls, email, uuid): """A method used by our oidc classes to test whether a user needs email/uuid verification diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index ea2fbce33..5ad2b27f7 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -17,7 +17,7 @@ Template for an input field with a clipboard > - Copy + Copy @@ -25,7 +25,7 @@ Template for an input field with a clipboard {% endif %} \ No newline at end of file diff --git a/src/registrar/templates/admin/model_descriptions.html b/src/registrar/templates/admin/model_descriptions.html index 4b61e21bd..9f13245fe 100644 --- a/src/registrar/templates/admin/model_descriptions.html +++ b/src/registrar/templates/admin/model_descriptions.html @@ -32,6 +32,8 @@ {% include "django/admin/includes/descriptions/website_description.html" %} {% elif opts.model_name == 'portfolioinvitation' %} {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} + {% elif opts.model_name == 'allowedemail' %} + {% include "django/admin/includes/descriptions/allowed_email_description.html" %} {% else %}

    This table does not have a description yet.

    {% endif %} diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index a7d59d22c..0396326d9 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -120,7 +120,7 @@ -

    +

    Requested domain: {{ original.requested_domain.name }}

    {{ block.super }} diff --git a/src/registrar/templates/django/admin/includes/descriptions/allowed_email_description.html b/src/registrar/templates/django/admin/includes/descriptions/allowed_email_description.html new file mode 100644 index 000000000..602935ab7 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/allowed_email_description.html @@ -0,0 +1,6 @@ +

    This table is an email allow list for non-production environments.

    +

    + If an email is sent out and the email does not exist within this table (or is not a subset of it), + then no email will be sent. +

    +

    If this table is populated in a production environment, no change will occur as it will simply be ignored.

    diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 7e19cd0d1..9b2c1b287 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} {% endwith %} + {% elif field.field.name == "display_admins" %} +
    {{ field.contents|safe }}
    + {% elif field.field.name == "display_members" %} +
    + {% if display_members_summary %} + {{ display_members_summary }} + {% else %} +

    No additional members found.

    + {% endif %} +
    {% else %}
    {{ field.contents }}
    {% endif %} @@ -330,7 +340,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endif %} {% endwith %} - {% elif field.field.name == "state_territory" %} + {% elif field.field.name == "display_members" and field.contents %} +
    + Details +
    + {{ field.contents|safe }} +
    +
    + {% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
    CISA region: diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 4eb941340..8dae8a080 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -1,4 +1,5 @@ {% extends 'django/admin/email_clipboard_change_form.html' %} +{% load custom_filters %} {% load i18n static %} {% block content %} @@ -16,10 +17,28 @@ This is a placeholder for now. Disclaimer: - When extending the fieldset view - *make a new one* that extends from detail_table_fieldset. - For instance, "portfolio_fieldset.html". + When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset. detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences. {% endcomment %} {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %} {% endfor %} {% endblock %} + +{% block submit_buttons_bottom %} +
    + + + + +

    + Organization Name: {{ original.organization_name }} +

    + {{ block.super }} +
    + +{% endblock %} diff --git a/src/registrar/templates/django/forms/widgets/combobox.html b/src/registrar/templates/django/forms/widgets/combobox.html index 107c2e14e..7ff31945b 100644 --- a/src/registrar/templates/django/forms/widgets/combobox.html +++ b/src/registrar/templates/django/forms/widgets/combobox.html @@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
    {% include "django/forms/widgets/select.html" %} diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index b62ad7ec5..1dd1e1abe 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -63,10 +63,10 @@
    -
    @@ -74,10 +74,10 @@ {% endfor %} -
    {% endfor %} - {% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more' diff --git a/src/registrar/templates/domain_org_name_address.html b/src/registrar/templates/domain_org_name_address.html index 1e6176aa0..a7eb02b59 100644 --- a/src/registrar/templates/domain_org_name_address.html +++ b/src/registrar/templates/domain_org_name_address.html @@ -42,7 +42,7 @@ {% input_with_errors form.state_territory %} - {% with add_class="usa-input--small" %} + {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %} {% input_with_errors form.zipcode %} {% endwith %} diff --git a/src/registrar/templates/domain_request_org_contact.html b/src/registrar/templates/domain_request_org_contact.html index f145ee3bf..d4f3c2071 100644 --- a/src/registrar/templates/domain_request_org_contact.html +++ b/src/registrar/templates/domain_request_org_contact.html @@ -33,7 +33,7 @@ {% input_with_errors forms.0.state_territory %} - {% with add_class="usa-input--small" %} + {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %} {% input_with_errors forms.0.zipcode %} {% endwith %} diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html index ad96f1d65..823629213 100644 --- a/src/registrar/templates/domain_suborganization.html +++ b/src/registrar/templates/domain_suborganization.html @@ -1,7 +1,7 @@ {% extends "domain_base.html" %} {% load static field_helpers%} -{% block title %}Suborganization{% endblock %} +{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %} {% block domain_content %} {# this is right after the messages block in the parent template #} diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 8e4f04fcd..412f4ee73 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -21,7 +21,7 @@ {% if domain.permissions %} -
    +

    Domain managers

    @@ -112,7 +112,7 @@ {% if domain.invitations.exists %} -
    +

    Invitations

    Domain managers
    diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index bd909350c..f73f8079f 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -3,7 +3,7 @@ {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get_domain_requests_json' as url %} -
    +
    {% if not has_domain_requests_portfolio_permission %}
    diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 48de2d98c..c00409ff8 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -5,8 +5,8 @@ {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get_domains_json' as url %} -
    -
    +
    +
    {% if not portfolio %}

    Domains

    @@ -14,7 +14,7 @@ {% endif %} -
    Domain invitations