mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-18 18:39:21 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/2592-domain-request-export
This commit is contained in:
commit
ccbbc56b64
17 changed files with 822 additions and 25 deletions
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 == "/"}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
],
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
80
src/registrar/templates/includes/members_table.html
Normal file
80
src/registrar/templates/includes/members_table.html
Normal 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>
|
33
src/registrar/templates/portfolio_members.html
Normal file
33
src/registrar/templates/portfolio_members.html
Normal 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 %}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
175
src/registrar/tests/test_views_members_json.py
Normal file
175
src/registrar/tests/test_views_members_json.py
Normal 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)
|
|
@ -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):
|
||||
|
|
125
src/registrar/views/portfolio_members_json.py
Normal file
125
src/registrar/views/portfolio_members_json.py
Normal 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
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue