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.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 @@ def domain_requests(self, obj: models.Portfolio):
]
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 @@ def get_field_links_as_list(
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 @@ def get_readonly_fields(self, request, obj=None):
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/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 @@ def get_first_portfolio(self):
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 @@
>
- Copy
+ Copy
@@ -25,7 +25,7 @@
{% 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 @@
{% endfor %}
{% endwith %}
+ {% elif field.field.name == "display_admins" %}
+