diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 99de55427..8a07b3f27 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1868,6 +1868,125 @@ 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) { + + // --------- 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) { + return; + } + + let baseUrlValue = baseUrl.innerHTML; + if (!baseUrlValue) { + return; + } + + let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function + 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 memberList = document.querySelector('.members__table tbody'); + memberList.innerHTML = ''; + + data.members.forEach(member => { + // const actionUrl = domain.action_url; + const member_name = member.name; + const member_email = member.email; + const last_active = member.last_active; + const action_url = member.action_url; + const action_label = member.action_label; + const svg_icon = member.svg_icon; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + row.innerHTML = ` + + ${member_email ? member_email : member_name} ${admin_tagHTML} + + + ${last_active} + + + + + ${action_label} ${member_name} + + + `; + memberList.appendChild(row); + }); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', 'members'); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + 'member', + '#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 @@ -1941,6 +2060,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/config/urls.py b/src/registrar/config/urls.py index d0fef3b34..436ca3ae0 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -23,19 +23,21 @@ from registrar.views.report_views import ( ExportDataTypeRequests, ) -from registrar.views.domain_request import Step +# --jsons from registrar.views.domain_requests_json import get_domain_requests_json -from registrar.views.transfer_user import TransferUserView +from registrar.views.domains_json import get_domains_json +from registrar.views.portfolio_members_json import get_portfolio_members_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, ) -from registrar.views.domains_json import get_domains_json + +from registrar.views.domain_request import Step +from registrar.views.transfer_user import TransferUserView from registrar.views.utility import always_404 from api.views import available, rdap, get_current_federal, get_current_full - DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE domain_request_urls = [ path("", views.DomainRequestWizard.as_view(), name=""), @@ -75,6 +77,16 @@ urlpatterns = [ 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(), @@ -282,6 +294,7 @@ urlpatterns = [ ), 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_portfolio_members_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/context_processors.py b/src/registrar/context_processors.py index c62c9a7c4..53f6e8ae7 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -97,5 +97,5 @@ def portfolio_permissions(request): def is_widescreen_mode(request): - widescreen_paths = ["/domains/", "/requests/"] + widescreen_paths = ["/domains/", "/requests/", "/members/"] return {"is_widescreen_mode": any(path in request.path for path in widescreen_paths) or request.path == "/"} diff --git a/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py new file mode 100644 index 000000000..dae994f8e --- /dev/null +++ b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.10 on 2024-09-25 00:49 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0128_alter_domaininformation_state_territory_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="portfolio_roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index f7bafc8c6..6acd651db 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -15,8 +15,6 @@ class UserPortfolioPermission(TimeStampedModel): PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, @@ -25,14 +23,6 @@ class UserPortfolioPermission(TimeStampedModel): UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], - UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - UserPortfolioPermissionChoices.VIEW_MEMBERS, - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - # Domain: field specific permissions - UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, - ], UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, ], diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 7f34221fd..29e2a377c 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -7,7 +7,6 @@ class UserPortfolioRoleChoices(models.TextChoices): """ ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" ORGANIZATION_MEMBER = "organization_member", "Member" diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index ab53dd5cf..a3b2364a9 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -91,9 +91,9 @@ {% endif %} - {% if has_organization_members_flag %} + {% if has_organization_members_flag and has_view_members_portfolio_permission %}
  • - + Members
  • diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html new file mode 100644 index 000000000..529d2629d --- /dev/null +++ b/src/registrar/templates/includes/members_table.html @@ -0,0 +1,80 @@ +{% load static %} + + + +{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} +{% url 'get_portfolio_members_json' as url %} + +
    +
    + + +
    + + + + + +
    + diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html new file mode 100644 index 000000000..82e06c808 --- /dev/null +++ b/src/registrar/templates/portfolio_members.html @@ -0,0 +1,33 @@ +{% 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 %} +
    +{% endblock %} diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index c6c7c97d1..2cf056858 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -239,3 +239,14 @@ def is_portfolio_subpage(path): "senior-official", ] return get_url_name(path) in url_names + + +@register.filter(name="is_members_subpage") +def is_members_subpage(path): + """Checks if the given page is a subpage of members. + Takes a path name, like '/organization/'.""" + # Since our pages aren't unified under a common path, we need this approach for now. + url_names = [ + "members", + ] + return get_url_name(path) in url_names diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index e26b077fd..e1c1178b3 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -358,11 +358,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): 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/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 8fb92df72..127b78a4a 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -1568,7 +1568,7 @@ class TestDomainSuborganization(TestDomainOverview): # Add portfolio perms to the user object portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( - user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] + user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] ) self.assertEqual(self.domain_information.sub_organization, suborg) diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py new file mode 100644 index 000000000..75c3a3a66 --- /dev/null +++ b/src/registrar/tests/test_views_members_json.py @@ -0,0 +1,175 @@ +from django.urls import reverse + +from registrar.models.portfolio import Portfolio +from registrar.models.user import User +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from .test_views import TestWithUser +from django_webtest import WebTest # type: ignore + + +class GetPortfolioMembersJsonTest(TestWithUser, WebTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create additional users + cls.user2 = User.objects.create( + username="test_user2", + first_name="Second", + last_name="User", + email="second@example.com", + phone="8003112345", + title="Member", + ) + cls.user3 = User.objects.create( + username="test_user3", + first_name="Third", + last_name="User", + email="third@example.com", + phone="8003113456", + title="Member", + ) + cls.user4 = User.objects.create( + username="test_user4", + first_name="Fourth", + last_name="User", + email="fourth@example.com", + phone="8003114567", + title="Admin", + ) + + # Create Portfolio + cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") + + # Assign permissions + UserPortfolioPermission.objects.create( + user=cls.user, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + UserPortfolioPermission.objects.create( + user=cls.user2, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + UserPortfolioPermission.objects.create( + user=cls.user3, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + UserPortfolioPermission.objects.create( + user=cls.user4, + portfolio=cls.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + def setUp(self): + super().setUp() + self.app.set_user(self.user.username) + + def test_get_portfolio_members_json_authenticated(self): + """Test that portfolio members are returned properly for an authenticated user.""" + response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertFalse(data["has_previous"]) + self.assertFalse(data["has_next"]) + self.assertEqual(data["num_pages"], 1) + self.assertEqual(data["total"], 4) + self.assertEqual(data["unfiltered_total"], 4) + + # Check the number of members + self.assertEqual(len(data["members"]), 4) + + # Check member fields + expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email} + actual_emails = {member["email"] for member in data["members"]} + self.assertEqual(expected_emails, actual_emails) + + def test_pagination(self): + """Test that pagination works properly when there are more members than page size.""" + # Create additional members to exceed page size of 10 + for i in range(5, 15): + user, _ = User.objects.get_or_create( + username=f"test_user{i}", + first_name=f"User{i}", + last_name=f"Last{i}", + email=f"user{i}@example.com", + phone=f"80031156{i}", + title="Member", + ) + UserPortfolioPermission.objects.create( + user=user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + response = self.app.get( + reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 1} + ) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertTrue(data["has_next"]) + self.assertFalse(data["has_previous"]) + self.assertEqual(data["num_pages"], 2) + self.assertEqual(data["total"], 14) + self.assertEqual(data["unfiltered_total"], 14) + + # Check the number of members on page 1 + self.assertEqual(len(data["members"]), 10) + + response = self.app.get( + reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 2} + ) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info for page 2 + self.assertEqual(data["page"], 2) + self.assertFalse(data["has_next"]) + self.assertTrue(data["has_previous"]) + self.assertEqual(data["num_pages"], 2) + + # Check the number of members on page 2 + self.assertEqual(len(data["members"]), 4) + + def test_search(self): + """Test search functionality for portfolio members.""" + # Search by name + response = self.app.get( + reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"} + ) + self.assertEqual(response.status_code, 200) + data = response.json + self.assertEqual(len(data["members"]), 1) + self.assertEqual(data["members"][0]["name"], "Second User") + self.assertEqual(data["members"][0]["email"], "second@example.com") + + # Search by email + response = self.app.get( + reverse("get_portfolio_members_json"), + params={"portfolio": self.portfolio.id, "search_term": "fourth@example.com"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json + self.assertEqual(len(data["members"]), 1) + self.assertEqual(data["members"][0]["email"], "fourth@example.com") + + # Search with no matching results + response = self.app.get( + reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "NonExistent"} + ) + self.assertEqual(response.status_code, 200) + data = response.json + self.assertEqual(len(data["members"]), 0) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index e7c593a45..dfb0469d0 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -10,6 +10,7 @@ from registrar.models import ( UserDomainRole, User, ) +from registrar.models.user_group import UserGroup from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .common import MockSESClient, completed_domain_request, create_test_user @@ -666,6 +667,195 @@ class TestPortfolio(WebTest): self.assertContains(home, "Hotel California") self.assertContains(home, "Members") + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_cannot_view_members_table(self): + """Test that user without proper permission is denied access to members view""" + + # Users can only view the members table if they have + # Portfolio Permission "view_members" selected. + # NOTE: Admins, by default, do NOT have permission + # to view/edit members. This must be enabled explicitly + # in the "additional permissions" section for a portfolio + # permission. + # + # Scenarios to test include; + # (1) - User is not admin and can view portfolio, but not the members table + # (1) - User is admin and can view portfolio, but not the members table + + # --- non-admin + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + ], + ) + # Verify that the user cannot access the members page + # This will redirect the user to the members page. + self.client.force_login(self.user) + response = self.client.get(reverse("members"), follow=True) + # Assert the response is a 403 Forbidden + self.assertEqual(response.status_code, 403) + + # --- admin + UserPortfolioPermission.objects.filter(user=self.user, portfolio=self.portfolio).update( + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Verify that the user cannot access the members page + # This will redirect the user to the members page. + response = self.client.get(reverse("members"), follow=True) + # Assert the response is a 403 Forbidden + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_view_members_table(self): + """Test that user with proper permission is able to access members view""" + + self.app.set_user(self.user.username) + + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Verify that the user can access the members page + # This will redirect the user to the members page. + self.client.force_login(self.user) + response = self.client.get(reverse("members"), follow=True) + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # ---- Useful debugging stub to see what "assertContains" is finding + # pattern = r'Members' + # matches = re.findall(pattern, response.content.decode('utf-8')) + # for match in matches: + # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{match}") + + # Make sure the page loaded + self.assertContains(response, "Members") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_can_manage_members(self): + """Test that user with proper permission is able to manage members""" + user = self.user + self.app.set_user(user.username) + + # give user permissions to view AND manage members + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Give user permissions to modify user objects in the DB + group, _ = UserGroup.objects.get_or_create(name="full_access_group") + # Add the user to the group + user.groups.set([group]) + + # Verify that the user can access the members page + # This will redirect the user to the members page. + self.client.force_login(self.user) + response = self.client.get(reverse("members"), follow=True) + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # Verify that manage settings are sent in the dynamic HTML + self.client.force_login(self.user) + response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}") + self.assertContains(response, '"action_label": "Manage"') + self.assertContains(response, '"svg_icon": "settings"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_view_only_members(self): + """Test that user with view only permission settings can only + view members (not manage them)""" + user = self.user + self.app.set_user(user.username) + + # give user permissions to view AND manage members + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + # Give user permissions to modify user objects in the DB + group, _ = UserGroup.objects.get_or_create(name="full_access_group") + # Add the user to the group + user.groups.set([group]) + + # Verify that the user can access the members page + # This will redirect the user to the members page. + self.client.force_login(self.user) + response = self.client.get(reverse("members"), follow=True) + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # Verify that view-only settings are sent in the dynamic HTML + response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}") + print(response.content) + self.assertContains(response, '"action_label": "View"') + self.assertContains(response, '"svg_icon": "visibility"') + + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_members_admin_detection(self): + """Test that user with proper permission is able to manage members""" + user = self.user + self.app.set_user(user.username) + + # give user permissions to view AND manage members + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Give user permissions to modify user objects in the DB + group, _ = UserGroup.objects.get_or_create(name="full_access_group") + # Add the user to the group + user.groups.set([group]) + + # Verify that the user can access the members page + # This will redirect the user to the members page. + self.client.force_login(self.user) + response = self.client.get(reverse("members"), follow=True) + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + # Verify that admin info is sent in the dynamic HTML + response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}") + # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}") + self.assertContains(response, '"is_admin": true') + @less_console_noise_decorator @override_flag("organization_feature", active=True) def test_portfolio_domain_requests_page_when_user_has_no_permissions(self): diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py new file mode 100644 index 000000000..133e6750e --- /dev/null +++ b/src/registrar/views/portfolio_members_json.py @@ -0,0 +1,125 @@ +from django.http import JsonResponse +from django.core.paginator import Paginator +from django.contrib.auth.decorators import login_required +from django.db.models import Q + +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user import User +from registrar.models.user_portfolio_permission import UserPortfolioPermission +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""" + portfolio = request.GET.get("portfolio") + member_ids = get_member_ids_from_request(request, portfolio) + objects = User.objects.filter(id__in=member_ids) + + admin_ids = UserPortfolioPermission.objects.filter( + portfolio=portfolio, + roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ], + ).values_list("user__id", flat=True) + portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list( + "email", flat=True + ) + + unfiltered_total = objects.count() + + objects = apply_search(objects, request) + # objects = apply_status_filter(objects, request) + objects = apply_sorting(objects, request) + + paginator = Paginator(objects, 10) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + members = [ + serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails) + for member in page_obj.object_list + ] + + return JsonResponse( + { + "members": members, + "page": page_obj.number, + "num_pages": paginator.num_pages, + "has_previous": page_obj.has_previous(), + "has_next": page_obj.has_next(), + "total": paginator.count, + "unfiltered_total": unfiltered_total, + } + ) + + +def get_member_ids_from_request(request, portfolio): + """Given the current request, + get all members that are associated with the given portfolio""" + member_ids = [] + if portfolio: + 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") + + if search_term: + queryset = queryset.filter( + Q(username__icontains=search_term) + | Q(first_name__icontains=search_term) + | Q(last_name__icontains=search_term) + | Q(email__icontains=search_term) + ) + 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 sort_by == "member": + sort_by = ["email", "first_name", "middle_name", "last_name"] + else: + sort_by = [sort_by] + + if order == "desc": + sort_by = [f"-{field}" for field in sort_by] + + return queryset.order_by(*sort_by) + + +def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails): + # ------- 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 + + view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users + + # ------- USER STATUSES + is_invited = member.email in portfolio_invitation_emails + last_active = "Invited" if is_invited else "Unknown" + if member.last_login: + last_active = member.last_login.strftime("%b. %d, %Y") + is_admin = member.id in admin_ids + + # ------- SERIALIZE + member_json = { + "id": member.id, + "name": member.get_formatted_name(), + "email": member.email, + "is_admin": is_admin, + "last_active": last_active, + "action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket? + "action_label": ("View" if view_only else "Manage"), + "svg_icon": ("visibility" if view_only else "settings"), + } + return member_json diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 885dca636..552fdb6ff 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -12,6 +12,7 @@ from registrar.views.utility.permission_views import ( PortfolioDomainsPermissionView, PortfolioBasePermissionView, NoPortfolioDomainsPermissionView, + PortfolioMembersPermissionView, ) from django.views.generic import View from django.views.generic.edit import FormMixin @@ -41,6 +42,15 @@ 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.""" + 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. diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index d8c48e01e..2cb2a599b 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -490,7 +490,7 @@ class PortfolioMembersPermission(PortfolioBasePermission): up from the portfolio's primary key in self.kwargs["pk"]""" portfolio = self.request.session.get("portfolio") - if not self.request.user.has_view_members(portfolio): + if not self.request.user.has_view_members_portfolio_permission(portfolio): return False return super().has_permission()