mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 18:56:15 +02:00
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:
commit
11db76496a
14 changed files with 1681 additions and 853 deletions
File diff suppressed because it is too large
Load diff
|
@ -93,6 +93,11 @@ urlpatterns = [
|
||||||
views.PortfolioMemberView.as_view(),
|
views.PortfolioMemberView.as_view(),
|
||||||
name="member",
|
name="member",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"member/<int:pk>/delete",
|
||||||
|
views.PortfolioMemberDeleteView.as_view(),
|
||||||
|
name="member-delete",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"member/<int:pk>/permissions",
|
"member/<int:pk>/permissions",
|
||||||
views.PortfolioMemberEditView.as_view(),
|
views.PortfolioMemberEditView.as_view(),
|
||||||
|
@ -108,6 +113,11 @@ urlpatterns = [
|
||||||
views.PortfolioInvitedMemberView.as_view(),
|
views.PortfolioInvitedMemberView.as_view(),
|
||||||
name="invitedmember",
|
name="invitedmember",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"invitedmember/<int:pk>/delete",
|
||||||
|
views.PortfolioInvitedMemberDeleteView.as_view(),
|
||||||
|
name="invitedmember-delete",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"invitedmember/<int:pk>/permissions",
|
"invitedmember/<int:pk>/permissions",
|
||||||
views.PortfolioInvitedMemberEditView.as_view(),
|
views.PortfolioInvitedMemberEditView.as_view(),
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from registrar.models import DomainInformation, UserDomainRole
|
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 .domain_invitation import DomainInvitation
|
||||||
from .portfolio_invitation import PortfolioInvitation
|
from .portfolio_invitation import PortfolioInvitation
|
||||||
|
@ -471,3 +472,42 @@ class User(AbstractUser):
|
||||||
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
||||||
else:
|
else:
|
||||||
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
|
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
|
||||||
|
|
|
@ -64,7 +64,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<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?
|
Are you sure you want to extend the expiration date?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
@ -128,7 +128,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<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?
|
Are you sure you want to place this domain on hold?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
@ -195,7 +195,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<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?
|
Are you sure you want to remove this domain from the registry?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
|
|
@ -57,7 +57,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<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?
|
Are you sure you want to select ineligible status?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<!-- Embedding the portfolio value in a data attribute -->
|
<!-- 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 %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get_portfolio_members_json' as url %}
|
{% url 'get_portfolio_members_json' as url %}
|
||||||
<span id="get_members_json_url" class="display-none">{{url}}</span>
|
<span id="get_members_json_url" class="display-none">{{url}}</span>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
{{ modal_heading }}
|
{{ modal_heading }}
|
||||||
{%if domain_name_modal is not None %}
|
{%if domain_name_modal is not None %}
|
||||||
<span class="domain-name-wrap">
|
<span class="domain-name-wrap">
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
<p id="modal-1-description">
|
<p>
|
||||||
{{ modal_description }}
|
{{ modal_description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
{% extends 'portfolio_base.html' %}
|
{% extends 'portfolio_base.html' %}
|
||||||
{% load static field_helpers%}
|
{% load static field_helpers%}
|
||||||
|
|
||||||
{% block title %}Organization member {% endblock %}
|
{% block title %}
|
||||||
|
Organization member
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
|
@ -33,60 +35,30 @@
|
||||||
</h2>
|
</h2>
|
||||||
{% if has_edit_members_portfolio_permission %}
|
{% if has_edit_members_portfolio_permission %}
|
||||||
{% if member %}
|
{% if member %}
|
||||||
<a
|
<div id="wrapper-delete-action"
|
||||||
role="button"
|
data-member-name="{{ member.email }}"
|
||||||
href="#"
|
data-member-type="member"
|
||||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
data-member-id="{{ member.id }}"
|
||||||
>
|
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
|
||||||
Remove member
|
data-member-email="{{ member.email }}"
|
||||||
</a>
|
>
|
||||||
{% else %}
|
<!-- JS should inject member kebob here -->
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
|
||||||
<address>
|
<address>
|
||||||
<strong class="text-primary-dark">Last active:</strong>
|
<strong class="text-primary-dark">Last active:</strong>
|
||||||
{% if member and member.last_login %}
|
{% if member and member.last_login %}
|
||||||
|
|
|
@ -9,11 +9,15 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block portfolio_content %}
|
{% block portfolio_content %}
|
||||||
{% block messages %}
|
|
||||||
{% include "includes/form_messages.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<div id="main-content">
|
<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="grid-row grid-gap">
|
||||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||||
<h1 id="members-header">Members</h1>
|
<h1 id="members-header">Members</h1>
|
||||||
|
|
|
@ -51,11 +51,11 @@ Edit your User Profile |
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Add contact information
|
Add contact information
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
<p id="modal-1-description">
|
<p>
|
||||||
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
|
.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.
|
Before you can manage your domain, we need you to add your contact information.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -824,6 +824,92 @@ class TestUser(TestCase):
|
||||||
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
|
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):
|
class TestContact(TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -2,8 +2,9 @@ from django.urls import reverse
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
from registrar.config import settings
|
from registrar.config import settings
|
||||||
from registrar.models import Portfolio, SeniorOfficial
|
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_webtest import WebTest # type: ignore
|
||||||
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
DomainRequest,
|
DomainRequest,
|
||||||
Domain,
|
Domain,
|
||||||
|
@ -959,7 +960,7 @@ class TestPortfolio(WebTest):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert buttons and links within the page are correct
|
# 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#edit") # test that Edit link is present
|
||||||
self.assertContains(response, "sprite.svg#settings") # test that Manage 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
|
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||||
|
@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest):
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
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
|
# 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#edit") # test that Edit link is present
|
||||||
self.assertContains(response, "sprite.svg#settings") # test that Manage 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
|
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())
|
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
|
||||||
domain_request.delete()
|
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):
|
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
user__permissions__domain__domain_info__portfolio=portfolio
|
user__permissions__domain__domain_info__portfolio=portfolio
|
||||||
), # only include domains in portfolio
|
), # only include domains in portfolio
|
||||||
),
|
),
|
||||||
source=Value("permission", output_field=CharField()),
|
type=Value("member", output_field=CharField()),
|
||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
"id",
|
"id",
|
||||||
|
@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
"additional_permissions_display",
|
"additional_permissions_display",
|
||||||
"member_display",
|
"member_display",
|
||||||
"domain_info",
|
"domain_info",
|
||||||
"source",
|
"type",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return permissions
|
return permissions
|
||||||
|
@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
distinct=True,
|
distinct=True,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
source=Value("invitation", output_field=CharField()),
|
type=Value("invitedmember", output_field=CharField()),
|
||||||
).values(
|
).values(
|
||||||
"id",
|
"id",
|
||||||
"first_name",
|
"first_name",
|
||||||
|
@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
"additional_permissions_display",
|
"additional_permissions_display",
|
||||||
"member_display",
|
"member_display",
|
||||||
"domain_info",
|
"domain_info",
|
||||||
"source",
|
"type",
|
||||||
)
|
)
|
||||||
return invitations
|
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
|
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 [])
|
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
|
# Serialize member data
|
||||||
member_json = {
|
member_json = {
|
||||||
"id": item.get("id", ""),
|
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
|
||||||
"source": item.get("source", ""),
|
"type": item.get("type", ""), # source is member or invitedmember
|
||||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||||
"email": item.get("email_display", ""),
|
"email": item.get("email_display", ""),
|
||||||
"member_display": item.get("member_display", ""),
|
"member_display": item.get("member_display", ""),
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import logging
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
||||||
from registrar.forms import portfolio as portfolioForms
|
from registrar.forms import portfolio as portfolioForms
|
||||||
from registrar.models import Portfolio, User
|
from registrar.models import Portfolio, User
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
from registrar.views.utility.mixins import PortfolioMemberPermission
|
||||||
from registrar.views.utility.permission_views import (
|
from registrar.views.utility.permission_views import (
|
||||||
PortfolioDomainRequestsPermissionView,
|
PortfolioDomainRequestsPermissionView,
|
||||||
PortfolioDomainsPermissionView,
|
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):
|
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
template_name = "portfolio_member_permissions.html"
|
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):
|
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
template_name = "portfolio_member_permissions.html"
|
template_name = "portfolio_member_permissions.html"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue