Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/2592-domain-request-export

This commit is contained in:
Rebecca Hsieh 2024-10-03 12:05:34 -07:00
commit ccbbc56b64
No known key found for this signature in database
17 changed files with 822 additions and 25 deletions

View file

@ -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 = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
row.innerHTML = `
<th scope="row" role="rowheader" data-label="member email">
${member_email ? member_email : member_name} ${admin_tagHTML}
</th>
<td data-sort-value="${last_active}" data-label="last_active">
${last_active}
</td>
<td>
<a href="${action_url}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${svg_icon}"></use>
</svg>
${action_label} <span class="usa-sr-only">${member_name}</span>
</a>
</td>
`;
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
*/

View file

@ -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

View file

@ -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 == "/"}

View file

@ -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,
),
),
]

View file

@ -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,
],

View file

@ -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"

View file

@ -91,9 +91,9 @@
</li>
{% endif %}
{% if has_organization_members_flag %}
{% if has_organization_members_flag and has_view_members_portfolio_permission %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members
</a>
</li>

View file

@ -0,0 +1,80 @@
{% load static %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}"></span>
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_portfolio_members_json' as url %}
<span id="get_members_json_url" class="display-none">{{url}}</span>
<section class="section-outlined members margin-top-0 section-outlined--border-base-light" id="members">
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 members__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label class="usa-sr-only" for="members__search-field">Search by member name</label>
<input
class="usa-input"
id="members__search-field"
type="search"
name="search"
placeholder="Search by member name"
/>
<button class="usa-button" type="submit" id="members__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
</div>
<!-- ---------- MAIN TABLE ---------- -->
<div class="members__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table">
<caption class="sr-only">Your registered members</caption>
<thead>
<tr>
<th data-sortable="member" scope="col" role="columnheader">Member</th>
<th data-sortable="last_active" scope="col" role="columnheader">Last Active</th>
<th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
</tr>
</thead>
<tbody>
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="members__no-data display-none">
<p>You don't have any members.</p>
</div>
<div class="members__no-search-results display-none">
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="members-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>

View file

@ -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 %}
<div id="main-content">
<div class="grid-row grid-gap">
<div class="mobile:grid-col-12 tablet:grid-col-6">
<h1 id="members-header">Members</h1>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="float-right-tablet tablet:margin-y-0">
<a href="#" class="usa-button"
>
Add a new member
</a>
</p>
</div>
</div>
{% include "includes/members_table.html" with portfolio=portfolio %}
</div>
{% endblock %}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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.

View file

@ -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()