From 6954c1c2c1ecb29f3fe1f2fd802a5736dce34a8e Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 20 Sep 2024 12:28:42 -0600 Subject: [PATCH 01/35] Initial Scaffolding (in progress) --- src/registrar/assets/js/get-gov.js | 155 +++++++++++++ .../templates/includes/members_table.html | 216 ++++++++++++++++++ .../templates/portfolio_members.html | 20 ++ src/registrar/views/portfolio_members_json.py | 171 ++++++++++++++ src/registrar/views/portfolios.py | 39 ++++ 5 files changed, 601 insertions(+) create mode 100644 src/registrar/templates/includes/members_table.html create mode 100644 src/registrar/templates/portfolio_members.html create mode 100644 src/registrar/views/portfolio_members_json.py diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 027ef4344d..918e2c451a 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 0000000000..980a301794 --- /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 0000000000..f2d0b56506 --- /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 0000000000..de35d56b74 --- /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 885dca6360..4037a72b85 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -41,6 +41,45 @@ def get(self, request): 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. From 64b62f151c263012290f38080094a1ca7d2414d8 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Sat, 21 Sep 2024 15:56:30 -0600 Subject: [PATCH 02/35] Initial scaffold for members page completed --- src/registrar/assets/js/get-gov.js | 98 +++++++------------ src/registrar/config/urls.py | 12 +++ .../templates/includes/header_extended.html | 2 +- .../templates/includes/members_table.html | 9 +- src/registrar/views/portfolio_members_json.py | 76 +++++++++----- src/registrar/views/portfolios.py | 1 + src/registrar/views/utility/mixins.py | 2 +- 7 files changed, 109 insertions(+), 91 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 918e2c451a..3b0da926cc 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1871,6 +1871,21 @@ class MembersTable extends LoadTableBase { */ loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + // --------- SEARCH + // let searchParams = new URLSearchParams( + // { + // "page": page, + // "sort_by": sortBy, + // "order": order, + // "status": status, + // "search_term": searchTerm + // } + // ); + // if (portfolio) + // searchParams.append("portfolio", portfolio) + + + // --------- FETCH DATA // fetch json of page of domais, given params let baseUrl = document.getElementById("get_members_json_url"); if (!baseUrl) { @@ -1882,19 +1897,6 @@ class MembersTable extends LoadTableBase { 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()) @@ -1908,60 +1910,34 @@ class MembersTable extends LoadTableBase { 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 memberList = document.querySelector('.members__table tbody'); + memberList.innerHTML = ''; + data.members.forEach(member => { + // const actionUrl = domain.action_url; + const row = document.createElement('tr'); - let markupForSuborganizationRow = ''; - - if (this.portfolioValue) { - markupForSuborganizationRow = ` - - ${suborganization} - - ` - } - row.innerHTML = ` - - ${domain.name} + + TEMP -- member ID - - ${expirationDateFormatted} - - - ${domain.state_display} - - - - - ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - + + ${member.id} `; - domainList.appendChild(row); + + // + // + // + // ${domain.action_label} ${domain.name} + // + // + // `; + + memberList.appendChild(row); }); // initialize tool tips immediately after the associated DOM elements are added initializeTooltips(); @@ -1973,7 +1949,7 @@ class MembersTable extends LoadTableBase { // update pagination this.updatePagination( - 'domain', + 'member', '#members-pagination', '#members-pagination .usa-pagination__counter', '#members', diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 9b9ed569ee..9035557d4d 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -73,6 +73,17 @@ views.PortfolioNoDomainsView.as_view(), name="no-portfolio-domains", ), + + path( + "members/", + views.PortfolioMembersView.as_view(), + name="members", + ), + # path( + # "no-organization-members/", + # views.PortfolioNoMembersView.as_view(), + # name="no-portfolio-members", + # ), path( "requests/", views.PortfolioDomainRequestsView.as_view(), @@ -264,6 +275,7 @@ ), path("get-domains-json/", get_domains_json, name="get_domains_json"), path("get-domain-requests-json/", get_domain_requests_json, name="get_domain_requests_json"), + path("get-portfolio-members-json/", get_domains_json, name="get_portfolio_members_json"), ] # Djangooidc strips out context data from that context, so we define a custom error diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 5a6a3fa3f7..0eeec924cb 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -93,7 +93,7 @@ {% if has_organization_members_flag %}
  • - + Members
  • diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 980a301794..95e13aebf6 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -12,7 +12,12 @@

    Members

    {% endif %} - {% if portfolio_members_count and portfolio_members_count > 0 %} - + {% comment %} - {% if portfolio_members_count and portfolio_members_count > 0 %} + {% if portfolio_members and portfolio_members|length > 0 %} {% endif %} + {% endcomment %} {% if portfolio %} @@ -188,19 +190,6 @@

    Status

    - {% include "includes/members_table.html" with portfolio=portfolio portfolio_members_count=portfolio_members_count %} + {% include "includes/members_table.html" with portfolio=portfolio %} {% endblock %} diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index d4c0f2ab52..7975928066 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -356,11 +356,6 @@ def test_domain_data_type_user_with_portfolio(self): self.assertIn(self.domain_3.name, csv_content) self.assertNotIn(self.domain_2.name, csv_content) - # Test the output for readonly admin - # portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] - # portfolio_permission.save() - # portfolio_permission.refresh_from_db() - # Get the csv content csv_content = self._run_domain_data_type_user_export(request) self.assertIn(self.domain_1.name, csv_content) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 7a06dd9fb8..587caba944 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -43,7 +43,6 @@ def get_portfolio_members_json(request): for member in page_obj.object_list ] - # DEVELOPER'S NOTE (9-24-24): # If you're wondering where these JSON values are used, check out the class "MembersTable" # in get-gov.js (specifically the "loadTable" function). # @@ -68,9 +67,6 @@ def get_portfolio_members_json(request): ) else: - # This was added to handle NoneType error - # In other examples of we assume there will never be zero entries returned...which is *fine*...until - # something goes wrong. return JsonResponse( { "members": [], diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 54438964a4..6d29d9006b 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -61,7 +61,6 @@ def get(self, request): 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", context) From b84beef6c4e18b27bd16d093af6dbf41a71c04a6 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:41:50 -0600 Subject: [PATCH 25/35] Update create_federal_portfolio.py --- src/registrar/management/commands/create_federal_portfolio.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index cf9339bec0..d05a2911bb 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -68,9 +68,6 @@ def handle(self, agency_name, **options): if parse_domains or both: self.handle_portfolio_domains(portfolio, federal_agency) - if parse_domains or both: - self.handle_portfolio_members(portfolio, federal_agency) - def create_or_modify_portfolio(self, federal_agency): """Creates or modifies a portfolio record based on a federal agency.""" portfolio_args = { From fe31dbdbade601d32622d9e11d9245748bc23cd7 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:44:05 -0600 Subject: [PATCH 26/35] Update members_table.html --- src/registrar/templates/includes/members_table.html | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 307bfcbf21..836d433ebb 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -42,12 +42,9 @@

    Members

    {% comment %} + Note - the following if check will need to be done in javascript. + This is because you can dynamically delete these fields. {% if portfolio_members and portfolio_members|length > 0 %} - - - - {% endif %} +
    +
    - - {% if portfolio %} - - - - {% endif %}