diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 20c988cdc..5c1967d15 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1880,11 +1880,10 @@ class MembersTable extends LoadTableBase { * @param {*} sortBy - the sort column option * @param {*} order - the sort order {asc, desc} * @param {*} scroll - control for the scrollToElement functionality - * @param {*} status - control for the status filter * @param {*} searchTerm - the search term * @param {*} portfolio - the portfolio id */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { // --------- SEARCH let searchParams = new URLSearchParams( @@ -1892,7 +1891,6 @@ class MembersTable extends LoadTableBase { "page": page, "sort_by": sortBy, "order": order, - "status": status, "search_term": searchTerm } ); @@ -1932,21 +1930,34 @@ class MembersTable extends LoadTableBase { const member_name = member.name; const member_email = member.email; const options = { year: 'numeric', month: 'short', day: 'numeric' }; - // set last_active values - // default values - let last_active = null; + + // Handle last_active values + let last_active = member.last_active; let last_active_formatted = ''; let last_active_sort_value = ''; - // member.last_active could be null, Invited, or a date; below sets values for all scenarios - if (member.last_active && member.last_active != 'Invited') { - last_active = new Date(member.last_active); - last_active_formatted = last_active.toLocaleDateString('en-Us', options); - last_active_sort_value = last_active.getTime(); + + // Handle 'Invited' or null/empty values differently from valid dates + if (last_active && last_active !== 'Invited') { + try { + // Try to parse the last_active as a valid date + last_active = new Date(last_active); + if (!isNaN(last_active)) { + last_active_formatted = last_active.toLocaleDateString('en-US', options); + last_active_sort_value = last_active.getTime(); // For sorting purposes + } else { + last_active_formatted='Invalid date' + } + } catch (e) { + console.error(`Error parsing date: ${last_active}. Error: ${e}`); + last_active_formatted='Invalid date' + } } else { + // Handle 'Invited' or null last_active = 'Invited'; last_active_formatted = 'Invited'; - last_active_sort_value = 'Invited'; + last_active_sort_value = 'Invited'; // Keep 'Invited' as a sortable string } + const action_url = member.action_url; const action_label = member.action_label; const svg_icon = member.svg_icon; diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 4978d422b..627d82416 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -1,8 +1,9 @@ -from datetime import datetime from django.http import JsonResponse from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required +from django.db.models import Value, F, CharField, TextField, Q from django.urls import reverse +from django.db.models.functions import Cast from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission @@ -11,58 +12,85 @@ from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices @login_required def get_portfolio_members_json(request): - """Given the current request, - get all members that are associated with the given portfolio""" + """Fetch members (permissions and invitations) for the given portfolio.""" portfolio = request.GET.get("portfolio") + search_term = request.GET.get("search_term", "").lower() + + # Permissions queryset + permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + + if search_term: + permissions = permissions.filter( + Q(user__first_name__icontains=search_term) + | Q(user__last_name__icontains=search_term) + | Q(user__email__icontains=search_term) + ) permissions = ( - UserPortfolioPermission.objects.filter(portfolio=portfolio) - .select_related("user") - .values_list("pk", "user__first_name", "user__last_name", "user__email", "user__last_login", "roles") - ) - invitations = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( - "pk", "email", "roles", "additional_permissions", "status" + permissions.select_related("user") + .annotate( + first_name=F("user__first_name"), + last_name=F("user__last_name"), + email_display=F("user__email"), + last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + source=Value("permission", output_field=CharField()), + ) + .values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "source", + ) ) - # Convert the permissions queryset into a list of dictionaries - permission_list = [ - { - "id": perm[0], - "first_name": perm[1], - "last_name": perm[2], - "email": perm[3], - "last_active": perm[4], - "roles": perm[5], - "source": "permission", # Mark the source as permissions - } - for perm in permissions - ] + # Invitations queryset + invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) - # Convert the invitations queryset into a list of dictionaries - invitation_list = [ - { - "id": invite[0], - "first_name": None, # No first name in invitations - "last_name": None, # No last name in invitations - "email": invite[1], - "roles": invite[2], - "additional_permissions": invite[3], - "status": invite[4], - "last_active": "Invited", - "source": "invitation", # Mark the source as invitations - } - for invite in invitations - ] + if search_term: + invitations = invitations.filter(Q(email__icontains=search_term)) - # Combine both lists into one unified list - combined_list = permission_list + invitation_list + invitations = invitations.annotate( + first_name=Value(None, output_field=CharField()), + last_name=Value(None, output_field=CharField()), + email_display=F("email"), + last_active=Value("Invited", output_field=TextField()), # Use "Invited" as a text value + roles_display=F("roles"), + additional_permissions_display=F("additional_permissions"), + source=Value("invitation", output_field=CharField()), + ).values( + "id", + "first_name", + "last_name", + "email_display", + "last_active", + "roles_display", + "additional_permissions_display", + "source", + ) - unfiltered_total = len(combined_list) + # Union the two querysets after applying search filters + combined_queryset = permissions.union(invitations) - combined_list = apply_search(combined_list, request) - combined_list = apply_sorting(combined_list, request) + # Apply sorting + sort_by = request.GET.get("sort_by", "id") # Default to 'id' + order = request.GET.get("order", "asc") # Default to 'asc' - paginator = Paginator(combined_list, 10) + # Adjust sort_by to match the annotated fields in the unioned queryset + if sort_by == "member": + sort_by = "email_display" # Use email_display instead of email + + if order == "desc": + combined_queryset = combined_queryset.order_by(F(sort_by).desc()) + else: + combined_queryset = combined_queryset.order_by(sort_by) + + paginator = Paginator(combined_queryset, 10) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) @@ -76,91 +104,29 @@ def get_portfolio_members_json(request): "has_previous": page_obj.has_previous(), "has_next": page_obj.has_next(), "total": paginator.count, - "unfiltered_total": unfiltered_total, + "unfiltered_total": combined_queryset.count(), } ) -def apply_search(data_list, request): - search_term = request.GET.get("search_term", "").lower() - - if search_term: - # Filter the list based on the search term (case-insensitive) - data_list = [ - item - for item in data_list - if item.get("first_name", "") - and search_term in item.get("first_name", "").lower() - or item.get("last_name", "") - and search_term in item.get("last_name", "").lower() - or item.get("email", "") - and search_term in item.get("email", "").lower() - ] - - return data_list - - -def apply_sorting(data_list, request): - sort_by = request.GET.get("sort_by", "id") # Default to 'id' - order = request.GET.get("order", "asc") # Default to 'asc' - - if sort_by == "member": - sort_by = "email" - - # Custom key function that handles None, 'Invited', and datetime values for last_active - def sort_key(item): - value = item.get(sort_by) - if sort_by == "last_active": - # Return a tuple to ensure consistent data types for comparison - # First element: ordering value (0 for valid datetime, 1 for 'Invited', 2 for None) - # Second element: the actual value to sort by - if value is None: - return (2, value) # Position None last - if value == "Invited": - return (1, value) # Position 'Invited' before None but after valid datetimes - if isinstance(value, datetime): - return (0, value) # Position valid datetime values first - - # Default case: return the value as is for comparison - return value - - # Sort the list using the custom key function - data_list = sorted(data_list, key=sort_key, reverse=(order == "desc")) - - return data_list - - def serialize_members(request, portfolio, item, user): - # ------- VIEW ONLY - # If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link. - # If view_only (the user only has view user permissions), show the "View" link (no gear icon). - # We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin. - user_can_edit_other_users = False - for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]: - if user.has_perm(user_group_permission): - user_can_edit_other_users = True - break + # Check if the user can edit other users + user_can_edit_other_users = any( + user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"] + ) view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users - # ------- USER STATUSES - is_admin = False - if item["roles"]: - is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item["roles"] + is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in item.get("roles_display", []) + action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) - action_url = "#" - if item["source"] == "permission": - action_url = reverse("member", kwargs={"pk": item["id"]}) - elif item["source"] == "invitation": - action_url = reverse("invitedmember", kwargs={"pk": item["id"]}) - - # ------- SERIALIZE + # Serialize member data member_json = { "id": item.get("id", ""), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), - "email": item.get("email", ""), + "email": item.get("email_display", ""), "is_admin": is_admin, - "last_active": item.get("last_active", None), + "last_active": item.get("last_active", ""), "action_url": action_url, "action_label": ("View" if view_only else "Manage"), "svg_icon": ("visibility" if view_only else "settings"),