Merge pull request #2968 from cisagov/rh/2764-kebob-extra-spicy

#2764: Adding Kebabs + Delete Functionality + Modals for the Members Table and Member Profile Page - [RH]
This commit is contained in:
Rebecca H. 2024-11-19 14:56:12 -08:00 committed by GitHub
commit 11db76496a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1681 additions and 853 deletions

File diff suppressed because it is too large Load diff

View file

@ -93,6 +93,11 @@ urlpatterns = [
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/delete",
views.PortfolioMemberDeleteView.as_view(),
name="member-delete",
),
path(
"member/<int:pk>/permissions",
views.PortfolioMemberEditView.as_view(),
@ -108,6 +113,11 @@ urlpatterns = [
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/delete",
views.PortfolioInvitedMemberDeleteView.as_view(),
name="invitedmember-delete",
),
path(
"invitedmember/<int:pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),

View file

@ -1,11 +1,12 @@
import logging
from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
from registrar.models import DomainInformation, UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
@ -471,3 +472,42 @@ class User(AbstractUser):
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
def get_active_requests_count_in_portfolio(self, request):
"""Return count of active requests for the portfolio associated with the request."""
# Get the portfolio from the session using the existing method
portfolio = request.session.get("portfolio")
if not portfolio:
return 0 # No portfolio found
allowed_states = [
DomainRequest.DomainRequestStatus.SUBMITTED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
]
# Now filter based on the portfolio retrieved
active_requests_count = self.domain_requests_created.filter(
status__in=allowed_states, portfolio=portfolio
).count()
return active_requests_count
def is_only_admin_of_portfolio(self, portfolio):
"""Check if the user is the only admin of the given portfolio."""
UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission")
admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission])
admin_count = admins.count()
# Check if the current user is in the list of admins
if admin_count == 1 and admins.first().user == self:
return True # The user is the only admin
# If there are other admins or the user is not the only one
return False

View file

@ -64,7 +64,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to extend the expiration date?
</h2>
<div class="usa-prose">
@ -128,7 +128,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to place this domain on hold?
</h2>
<div class="usa-prose">
@ -195,7 +195,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to remove this domain from the registry?
</h2>
<div class="usa-prose">

View file

@ -57,7 +57,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">

View file

@ -1,7 +1,7 @@
{% load static %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}"></span>
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}" data-has-edit-permission="{{ has_edit_members_portfolio_permission }}"></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>

View file

@ -2,7 +2,7 @@
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
{{ modal_heading }}
{%if domain_name_modal is not None %}
<span class="domain-name-wrap">
@ -16,7 +16,7 @@
{% endif %}
</h2>
<div class="usa-prose">
<p id="modal-1-description">
<p>
{{ modal_description }}
</p>
</div>

View file

@ -1,7 +1,9 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization member {% endblock %}
{% block title %}
Organization member
{% endblock %}
{% load static %}
@ -33,60 +35,30 @@
</h2>
{% if has_edit_members_portfolio_permission %}
{% if member %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Cancel invitation
</a>
{% endif %}
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
<h2>More options</h2>
{% if member %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Cancel invitation
</a>
{% endif %}
<div id="wrapper-delete-action"
data-member-name="{{ member.email }}"
data-member-type="member"
data-member-id="{{ member.id }}"
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
data-member-email="{{ member.email }}"
>
<!-- JS should inject member kebob here -->
</div>
{% elif portfolio_invitation %}
<div id="wrapper-delete-action"
data-member-name="{{ portfolio_invitation.email }}"
data-member-type="invitedmember"
data-member-id="{{ portfolio_invitation.id }}"
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
data-member-email="{{ portfolio_invitation.email }}"
>
<!-- JS should inject invited kebob here -->
</div>
{% endif %}
{% endif %}
</div>
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
<address>
<strong class="text-primary-dark">Last active:</strong>
{% if member and member.last_login %}

View file

@ -9,11 +9,15 @@
{% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
<div class="usa-alert__body usa-alert__body--widescreen">
<p class="usa-alert__text ">
<!-- alert message will be conditionally populated by javascript -->
</p>
</div>
</div>
<div class="grid-row grid-gap">
<div class="mobile:grid-col-12 tablet:grid-col-6">
<h1 id="members-header">Members</h1>

View file

@ -51,11 +51,11 @@ Edit your User Profile |
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Add contact information
</h2>
<div class="usa-prose">
<p id="modal-1-description">
<p>
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
Before you can manage your domain, we need you to add your contact information.
</p>

View file

@ -824,6 +824,92 @@ class TestUser(TestCase):
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
)
@less_console_noise_decorator
def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self):
# There is no portfolio referenced in session so should return 0
request = self.factory.get("/")
request.session = {}
count = self.user.get_active_requests_count_in_portfolio(request)
self.assertEqual(count, 0)
@less_console_noise_decorator
def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self):
request = self.factory.get("/")
request.session = {"portfolio": self.portfolio}
# Create active requests
domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov")
domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov")
domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov")
domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov")
# Create 3 active requests + 1 that isn't
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_1,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
portfolio=self.portfolio,
)
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_2,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
portfolio=self.portfolio,
)
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_3,
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
portfolio=self.portfolio,
)
DomainRequest.objects.create( # This one should not be counted
creator=self.user,
requested_domain=domain_4,
status=DomainRequest.DomainRequestStatus.REJECTED,
portfolio=self.portfolio,
)
count = self.user.get_active_requests_count_in_portfolio(request)
self.assertEqual(count, 3)
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_true(self):
# Create user as the only admin of the portfolio
UserPortfolioPermission.objects.create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self):
# No admin for the portfolio
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self):
# Create multiple admins for the same portfolio
UserPortfolioPermission.objects.create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Create another user within this test
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
UserPortfolioPermission.objects.create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self):
# Create other_user for same portfolio and is given admin access
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
UserPortfolioPermission.objects.create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# User doesn't have admin access so should return false
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
class TestContact(TestCase):
@less_console_noise_decorator

View file

@ -2,8 +2,9 @@ from django.urls import reverse
from api.tests.common import less_console_noise_decorator
from registrar.config import settings
from registrar.models import Portfolio, SeniorOfficial
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from django_webtest import WebTest # type: ignore
from django.core.handlers.wsgi import WSGIRequest
from registrar.models import (
DomainRequest,
Domain,
@ -959,7 +960,7 @@ class TestPortfolio(WebTest):
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest):
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest):
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
domain_request.delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_members_table_contains_hidden_permissions_js_hook(self):
# In the members_table.html we use data-has-edit-permission as a boolean
# to indicate if a user has permission to edit members in the specific portfolio
# 1. User w/ edit permission
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member under same portfolio
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Members Table
self.client.force_login(self.user)
# Specifically go to the Member Table page
response = self.client.get(reverse("members"))
self.assertContains(response, 'data-has-edit-permission="True"')
# 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed)
permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
# Remove the EDIT_MEMBERS additional permission
permission.additional_permissions = [
perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS
]
# Save the updated permissions list
permission.save()
# Re-fetch the page to check for updated permissions
response = self.client.get(reverse("members"))
self.assertContains(response, 'data-has-edit-permission="False"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self):
"""Test that the kebab wrapper displays for a member with edit permissions"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member under same portfolio
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Manage Member page
self.client.force_login(self.user)
# Specifically go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Check for email AND member type (which here is just member)
self.assertContains(response, f'data-member-name="{member_email}"')
self.assertContains(response, 'data-member-type="member"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self):
"""Test that the kebab wrapper displays for an invitedmember with edit permissions"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Invite a member under same portfolio
invited_member_email = "invited_member@example.com"
invitation = PortfolioInvitation.objects.create(
email=invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Manage Member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Assert the invited members email + invitedmember type
self.assertContains(response, f'data-member-name="{invited_member_email}"')
self.assertContains(response, 'data-member-type="invitedmember"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_does_not_have_kebab_wrapper(self):
"""Test that the kebab does not display."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member with only view access
member_email = "member_with_view_access@example.com"
member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# I log in as the Member with only view permissions to evaluate the pages behaviour
# when viewed by someone who doesn't have edit perms
self.client.force_login(member)
# Go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Assert that the kebab edit options are unavailable
self.assertNotContains(response, 'data-member-type="member"')
self.assertNotContains(response, 'data-member-type="invitedmember"')
self.assertNotContains(response, f'data-member-name="{member_email}"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_correct_form_wrapper(self):
"""Test that the manage members page the right form wrapper"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Login as the User to see the Manage Member page
self.client.force_login(self.user)
# Specifically go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
# Check for a 200 response
self.assertEqual(response.status_code, 200)
# Check for form method + that its "post" and id "member-delete-form"
self.assertContains(response, "<form")
self.assertContains(response, 'method="post"')
self.assertContains(response, 'id="member-delete-form"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_toggleable_alert_wrapper_exists_on_members_page(self):
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Login as the User to see the Members Table page
self.client.force_login(self.user)
# Specifically go to the Members Table page
response = self.client.get(reverse("members"))
# Assert that the toggleable alert ID exists
self.assertContains(response, '<div id="toggleable-alert"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_members_table_active_requests(self):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400) # Bad request due to active requests
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
self.assertContains(response, expected_error_message, status_code=400)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_members_table_only_admin(self):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400)
expected_error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
self.assertContains(response, expected_error_message, status_code=400)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_table_delete_view_success(self):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Creating a member that can be deleted (see patch)
member_email = "deleteable_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
# Set up the member in the portfolio
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
):
# Attempt to delete
self.client.force_login(self.user)
response = self.client.post(
# We check X_REQUESTED_WITH bc those return JSON responses
reverse("member-delete", kwargs={"pk": upp.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
# Check for a successful deletion
self.assertEqual(response.status_code, 200)
expected_success_message = f"You've removed {member.email} from the organization."
self.assertContains(response, expected_success_message, status_code=200)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_active_requests(self):
"""Error state when deleting a member with active requests on the Manage Members page"""
# I'm an admin user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member with active requests
member_email = "member_with_active_request@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}),
)
# We don't want to do follow=True in response bc that does automatic redirection
# We want 302 bc indicates redirect
self.assertEqual(response.status_code, 302)
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
args, kwargs = mock_error.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_error_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_only_admin(self):
"""Error state when trying to delete the only admin on the Manage Members page"""
# Create an admin with admin user perms
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Set them to be the only admin and attempt to delete
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}),
)
self.assertEqual(response.status_code, 302)
expected_error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
args, kwargs = mock_error.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_error_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_invitedmember(self):
"""Success state w/ deleting invited member on Manage Members page should redirect back to Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Invite a member under same portfolio
invited_member_email = "invited_member@example.com"
invitation = PortfolioInvitation.objects.create(
email=invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
)
self.assertEqual(response.status_code, 302)
expected_success_message = f"You've removed {invitation.email} from the organization."
args, kwargs = mock_success.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_success_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're now on Members Table page
self.assertEqual(response.headers["Location"], reverse("members"))
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
@classmethod

View file

@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
user__permissions__domain__domain_info__portfolio=portfolio
), # only include domains in portfolio
),
source=Value("permission", output_field=CharField()),
type=Value("member", output_field=CharField()),
)
.values(
"id",
@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"additional_permissions_display",
"member_display",
"domain_info",
"source",
"type",
)
)
return permissions
@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
distinct=True,
)
),
source=Value("invitation", output_field=CharField()),
type=Value("invitedmember", output_field=CharField()),
).values(
"id",
"first_name",
@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"additional_permissions_display",
"member_display",
"domain_info",
"source",
"type",
)
return invitations
@ -188,12 +188,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
# Serialize member data
member_json = {
"id": item.get("id", ""),
"source": item.get("source", ""),
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
"type": item.get("type", ""), # source is member or invitedmember
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": item.get("email_display", ""),
"member_display": item.get("member_display", ""),

View file

@ -1,13 +1,17 @@
import logging
from django.http import Http404
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.contrib import messages
from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.mixins import PortfolioMemberPermission
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
@ -81,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
)
class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
def post(self, request, pk):
"""
Find and delete the portfolio member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_member_permission.user
active_requests_count = member.get_active_requests_count_in_portfolio(request)
support_url = "https://get.gov/contact/"
error_message = ""
if active_requests_count > 0:
# If they have any in progress requests
error_message = mark_safe( # nosec
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
# If they are the last manager of a domain
error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
# From the Members Table page Else the Member Page
if error_message:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse(
{"error": error_message},
status=400,
)
else:
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
# passed all error conditions
portfolio_member_permission.delete()
# From the Members Table page Else the Member Page
success_message = f"You've removed {member.email} from the organization."
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
else:
messages.success(request, success_message)
return redirect(reverse("members"))
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
@ -177,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
)
class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
def post(self, request, pk):
"""
Find and delete the portfolio invited member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
portfolio_invitation.delete()
success_message = f"You've removed {portfolio_invitation.email} from the organization."
# From the Members Table page Else the Member Page
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
else:
messages.success(request, success_message)
return redirect(reverse("members"))
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"