diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 6b42cf96b..e3bd5c9f7 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -11,6 +11,7 @@ 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
@@ -2968,11 +2969,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"]}),
(
@@ -3020,15 +3017,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("
"
+ 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 = (
+ "
Name
Title
Email
"
+ + "
Phone
Roles
"
+ )
+ for member in members:
+ full_name = member.get_formatted_name()
+ member_details += "
"
+ member_details += f"
{escape(full_name)}
"
+ member_details += f"
{escape(member.title)}
"
+ member_details += f"
{escape(member.email)}
"
+ member_details += f"
{escape(member.phone)}
"
+ member_details += "
"
+ for role in member.portfolio_role_summary(obj):
+ member_details += f"{escape(role)} "
+ member_details += "
"
+ 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 "-"
@@ -3088,7 +3188,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.
@@ -3120,14 +3220,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'
{links}
') if links else "-"
@@ -3170,8 +3270,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):
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 0aebb7ff6..33e6ed384 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1219,7 +1219,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');
@@ -1228,7 +1228,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (portfolioValue) {
markupForSuborganizationRow = `
- ${suborganization}
+ ${suborganization}
`
}
@@ -1984,7 +1984,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;
@@ -2010,8 +2010,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";
}
}
@@ -2117,11 +2117,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);
}
});
});
@@ -2185,7 +2185,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/_base.scss b/src/registrar/assets/sass/_theme/_base.scss
index 16a63c41c..7df783604 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;
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/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/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/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 1c1a7c2a9..5e1057139 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" %}
+
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html
index 6c7aca0ea..8dae8a080 100644
--- a/src/registrar/templates/django/admin/portfolio_change_form.html
+++ b/src/registrar/templates/django/admin/portfolio_change_form.html
@@ -17,8 +17,7 @@
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 %}
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 %}
-
+ Add new record
+ Add new record
-
+ Delete
+ Delete
{% endfor %}
-
+ Add another name server
+ Add another name server
{% 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
Domain managers
@@ -112,7 +112,7 @@
{% if domain.invitations.exists %}
-
+
Invitations
Domain invitations
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index e5a3e046d..b01b74333 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 %}
{{url}}
-
+
{% if not portfolio %}
diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html
index a132d47c7..f4cf5a0fe 100644
--- a/src/registrar/templates/includes/domains_table.html
+++ b/src/registrar/templates/includes/domains_table.html
@@ -5,15 +5,15 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %}
{{url}}
-
-
+
+
{% if not portfolio %}
Domains
{% else %}
{% endif %}
-
+
{% if user_domain_count and user_domain_count > 0 %}
-