diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 027ef4344..918e2c451 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1853,6 +1853,144 @@ class DomainRequestsTable extends LoadTableBase { } } +class MembersTable extends LoadTableBase { + + constructor() { + super('.members__table', '.members__table-wrapper', '#members__search-field', '#members__search-field-submit', '.members__reset-search', '.members__reset-filters', '.members__no-data', '.members__no-search-results'); + } + /** + * Loads rows in the members list, as well as updates pagination around the members list + * based on the supplied attributes. + * @param {*} page - the page number of the results (starts with 1) + * @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) { + + // fetch json of page of domais, given params + let baseUrl = document.getElementById("get_members_json_url"); + if (!baseUrl) { + return; + } + + let baseUrlValue = baseUrl.innerHTML; + if (!baseUrlValue) { + return; + } + + // fetch json of page of members, given params + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "status": status, + "search_term": searchTerm + } + ); + if (portfolio) + searchParams.append("portfolio", portfolio) + + let url = `${baseUrlValue}?${searchParams.toString()}` + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } + + // handle the display of proper messaging in the event that no members exist in the list or search returns no results + this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); + + // identify the DOM element where the domain list will be inserted into the DOM + const domainList = document.querySelector('.members__table tbody'); + domainList.innerHTML = ''; + + data.members.forEach(domain => { + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + const actionUrl = domain.action_url; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; + + const row = document.createElement('tr'); + + let markupForSuborganizationRow = ''; + + if (this.portfolioValue) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } + + row.innerHTML = ` + + ${domain.name} + + + ${expirationDateFormatted} + + + ${domain.state_display} + + + + + ${markupForSuborganizationRow} + + + + ${domain.action_label} ${domain.name} + + + `; + domainList.appendChild(row); + }); + // initialize tool tips immediately after the associated DOM elements are added + initializeTooltips(); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', 'members'); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + 'domain', + '#members-pagination', + '#members-pagination .usa-pagination__counter', + '#members', + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total, + ); + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; + }) + .catch(error => console.error('Error fetching members:', error)); + } +} + /** * An IIFE that listens for DOM Content to be loaded, then executes. This function @@ -1926,6 +2064,23 @@ const utcDateString = (dateString) => { }; + +/** + * An IIFE that listens for DOM Content to be loaded, then executes. This function + * initializes the domains list and associated functionality on the home page of the app. + * + */ +document.addEventListener('DOMContentLoaded', function() { + const isMembersPage = document.querySelector("#members") + if (isMembersPage){ + const membersTable = new MembersTable(); + if (membersTable.tableWrapper) { + // Initial load + membersTable.loadTable(1); + } + } +}); + /** * An IIFE that displays confirmation modal on the user profile page */ diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html new file mode 100644 index 000000000..980a30179 --- /dev/null +++ b/src/registrar/templates/includes/members_table.html @@ -0,0 +1,216 @@ +{% load static %} + +{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} +{% url 'get_portfolio_members_json' as url %} + +
+
+ {% if not portfolio %} +

Members

+ {% else %} + + + {% endif %} + + + + {% if portfolio_members_count and portfolio_members_count > 0 %} + + + {% endif %} +
+ {% if portfolio %} + + + + + {% endif %} + + + + + +
+ diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html new file mode 100644 index 000000000..f2d0b5650 --- /dev/null +++ b/src/registrar/templates/portfolio_members.html @@ -0,0 +1,20 @@ +{% extends 'portfolio_base.html' %} + +{% load static %} + +{% block title %} Members | {% endblock %} + +{% block wrapper_class %} + {{ block.super }} dashboard--grey-1 +{% endblock %} + +{% block portfolio_content %} +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock %} + +
+

Members

+ {% include "includes/members_table.html" with portfolio=portfolio portfolio_members_count=portfolio_members_count %} +
+{% endblock %} diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py new file mode 100644 index 000000000..de35d56b7 --- /dev/null +++ b/src/registrar/views/portfolio_members_json.py @@ -0,0 +1,171 @@ +from django.http import JsonResponse +from django.core.paginator import Paginator +from registrar.models import DomainRequest +from django.utils.dateformat import format +from django.contrib.auth.decorators import login_required +from django.urls import reverse +from django.db.models import Q + +from registrar.models.user_portfolio_permission import UserPortfolioPermission + + +@login_required +def get_portfolio_members_json(request): + """Given the current request, + get all members that are associated with the given portfolio""" + + member_ids = get_member_ids_from_request(request) + unfiltered_total = member_ids.count() + +# objects = apply_search(objects, request) +# objects = apply_status_filter(objects, request) +# objects = apply_sorting(objects, request) + + paginator = Paginator(member_ids, 10) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + members = [ + serialize_members(request, member, request.user) for member in page_obj.object_list + ] + +# return JsonResponse( +# { +# "domain_requests": domain_requests, +# "has_next": page_obj.has_next(), +# "has_previous": page_obj.has_previous(), +# "page": page_obj.number, +# "num_pages": paginator.num_pages, +# "total": paginator.count, +# "unfiltered_total": unfiltered_total, +# } +# ) + + +def get_member_ids_from_request(request): + """Given the current request, + get all members that are associated with the given portfolio""" + portfolio = request.GET.get("portfolio") + # filter_condition = Q(creator=request.user) + if portfolio: + # TODO: Permissions?? + # if request.user.is_org_user(request) and request.user.has_view_all_requests_portfolio_permission(portfolio): + # filter_condition = Q(portfolio=portfolio) + # else: + # filter_condition = Q(portfolio=portfolio, creator=request.user) + + member_ids = UserPortfolioPermission.objects.filter( + portfolio=portfolio + ).values_list("user__id", flat=True) + return member_ids + + +# def apply_search(queryset, request): +# search_term = request.GET.get("search_term") +# is_portfolio = request.GET.get("portfolio") + +# if search_term: +# search_term_lower = search_term.lower() +# new_domain_request_text = "new domain request" + +# # Check if the search term is a substring of 'New domain request' +# # If yes, we should return domain requests that do not have a +# # requested_domain (those display as New domain request in the UI) +# if search_term_lower in new_domain_request_text: +# queryset = queryset.filter( +# Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True) +# ) +# elif is_portfolio: +# queryset = queryset.filter( +# Q(requested_domain__name__icontains=search_term) +# | Q(creator__first_name__icontains=search_term) +# | Q(creator__last_name__icontains=search_term) +# | Q(creator__email__icontains=search_term) +# ) +# # For non org users +# else: +# queryset = queryset.filter(Q(requested_domain__name__icontains=search_term)) +# return queryset + + +# def apply_status_filter(queryset, request): +# status_param = request.GET.get("status") +# if status_param: +# status_list = status_param.split(",") +# statuses = [status for status in status_list if status in DomainRequest.DomainRequestStatus.values] +# # Construct Q objects for statuses that can be queried through ORM +# status_query = Q() +# if statuses: +# status_query |= Q(status__in=statuses) +# # Apply the combined query +# queryset = queryset.filter(status_query) + +# return queryset + + +# def apply_sorting(queryset, request): +# sort_by = request.GET.get("sort_by", "id") # Default to 'id' +# order = request.GET.get("order", "asc") # Default to 'asc' + +# if order == "desc": +# sort_by = f"-{sort_by}" +# return queryset.order_by(sort_by) + + +def serialize_members(request, member, user): + +# ------- DELETABLE +# deletable_statuses = [ +# DomainRequest.DomainRequestStatus.STARTED, +# DomainRequest.DomainRequestStatus.WITHDRAWN, +# ] + +# # Determine if the request is deletable +# if not user.is_org_user(request): +# is_deletable = member.status in deletable_statuses +# else: +# portfolio = request.session.get("portfolio") +# is_deletable = ( +# member.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio) +# ) and member.creator == user + + +# ------- EDIT / VIEW +# # Determine action label based on user permissions and request status +# editable_statuses = [ +# DomainRequest.DomainRequestStatus.STARTED, +# DomainRequest.DomainRequestStatus.ACTION_NEEDED, +# DomainRequest.DomainRequestStatus.WITHDRAWN, +# ] + +# if user.has_edit_request_portfolio_permission and member.creator == user: +# action_label = "Edit" if member.status in editable_statuses else "Manage" +# else: +# action_label = "View" + +# # Map the action label to corresponding URLs and icons +# action_url_map = { +# "Edit": reverse("edit-domain-request", kwargs={"id": member.id}), +# "Manage": reverse("domain-request-status", kwargs={"pk": member.id}), +# "View": "#", +# } + +# svg_icon_map = {"Edit": "edit", "Manage": "settings", "View": "visibility"} + + +# ------- INVITED +# TODO:.... + + +# ------- SERIALIZE +# return { +# "requested_domain": member.requested_domain.name if member.requested_domain else None, +# "last_submitted_date": member.last_submitted_date, +# "status": member.get_status_display(), +# "created_at": format(member.created_at, "c"), # Serialize to ISO 8601 +# "creator": member.creator.email, +# "id": member.id, +# "is_deletable": is_deletable, +# "action_url": action_url_map.get(action_label), +# "action_label": action_label, +# "svg_icon": svg_icon_map.get(action_label), +# } diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 885dca636..4037a72b8 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -41,6 +41,45 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View): return render(request, "portfolio_requests.html") +class PortfolioMembersView(PortfolioMembersPermissionView, View): + + template_name = "portfolio_members.html" + + def get(self, request): + """Add additional context data to the template.""" + # We can override the base class. This view only needs this item. + context = {} + portfolio = self.request.session.get("portfolio") + if portfolio: + + # # ------ Gets admin members + # admin_ids = UserPortfolioPermission.objects.filter( + # portfolio=portfolio, + # roles__overlap=[ + # UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + # ], + # ).values_list("user__id", flat=True) + + + # # ------ Gets non-admin members + # # 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) + + + # ------- Gets all members + member_ids = UserPortfolioPermission.objects.filter( + portfolio=portfolio + ).values_list("user__id", flat=True) + + all_members = User.objects.filter(id__in=member_ids) + context["portfolio_members"] = all_members + context["portfolio_members_count"] = all_members.count() + return render(request, "portfolio_members.html") + class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): """Some users have access to the underlying portfolio, but not any domains. This is a custom view which explains that to the user - and denotes who to contact.