This commit is contained in:
David Kennedy 2024-11-20 12:05:32 -05:00
commit bc855de792
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
24 changed files with 1765 additions and 901 deletions

File diff suppressed because it is too large Load diff

View file

@ -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(),
@ -339,9 +349,9 @@ urlpatterns = [
name="user-profile", name="user-profile",
), ),
path( path(
"invitation/<int:pk>/delete", "invitation/<int:pk>/cancel",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-delete", name="invitation-cancel",
), ),
path( path(
"domain-request/<int:pk>/delete", "domain-request/<int:pk>/delete",

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.10 on 2024-11-18 16:47
from django.db import migrations
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0137_suborganization_city_suborganization_state_territory"),
]
operations = [
migrations.AlterField(
model_name="domaininvitation",
name="status",
field=django_fsm.FSMField(
choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")],
default="invited",
max_length=50,
protected=True,
),
),
]

View file

@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel):
class DomainInvitationStatus(models.TextChoices): class DomainInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited" INVITED = "invited", "Invited"
RETRIEVED = "retrieved", "Retrieved" RETRIEVED = "retrieved", "Retrieved"
CANCELED = "canceled", "Canceled"
email = models.EmailField( email = models.EmailField(
null=False, null=False,
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
# something strange happened and this role already existed when # something strange happened and this role already existed when
# the invitation was retrieved. Log that this occurred. # the invitation was retrieved. Log that this occurred.
logger.warn("Invitation %s was retrieved for a role that already exists.", self) logger.warn("Invitation %s was retrieved for a role that already exists.", self)
@transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED)
def cancel_invitation(self):
"""When an invitation is canceled, change the status to canceled"""
pass
@transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED)
def update_cancellation_status(self):
"""When an invitation is canceled but reinvited, update the status to invited"""
pass

View file

@ -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
@ -474,3 +475,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

View file

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

View file

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

View file

@ -164,7 +164,7 @@
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %} {% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td> <td>
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %} {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-delete" pk=invitation.domain_invitation.id %}"> <form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel"> {% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form> </form>
{% endif %} {% endif %}

View file

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

View file

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

View file

@ -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 }}"
data-member-email="{{ member.email }}"
> >
Remove member <!-- JS should inject member kebob here -->
</a> </div>
{% else %} {% elif portfolio_invitation %}
<a <div id="wrapper-delete-action"
role="button" data-member-name="{{ portfolio_invitation.email }}"
href="#" data-member-type="invitedmember"
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="{{ portfolio_invitation.id }}"
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
data-member-email="{{ portfolio_invitation.email }}"
> >
Cancel invitation <!-- JS should inject invited kebob here -->
</a> </div>
{% endif %} {% 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>
{% 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 %}

View file

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

View file

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

View file

@ -200,7 +200,7 @@ def is_domain_subpage(path):
"domain-users-add", "domain-users-add",
"domain-request-delete", "domain-request-delete",
"domain-user-delete", "domain-user-delete",
"invitation-delete", "invitation-cancel",
] ]
return get_url_name(path) in url_names return get_url_name(path) in url_names

View file

@ -833,6 +833,92 @@ class TestUser(TestCase):
) )
self.assertTrue(self.user.is_portfolio_admin(portfolio)) self.assertTrue(self.user.is_portfolio_admin(portfolio))
@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

View file

@ -763,21 +763,18 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view deletes an invitation.""" """Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
mock_client = MockSESClient() self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
with boto3_mocking.clients.handler_for("sesv2", mock_client): invitation = DomainInvitation.objects.get(id=invitation.id)
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
mock_client.EMAILS_SENT.clear()
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_invitation_cancel_retrieved_invitation(self): def test_domain_invitation_cancel_retrieved_invitation(self):
"""Posting to the delete view when invitation retrieved returns an error message""" """Posting to the cancel view when invitation retrieved returns an error message"""
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create( invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
) )
response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True) response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
# Assert that an error message is displayed to the user # Assert that an error message is displayed to the user
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
# Assert that the Cancel link is not displayed # Assert that the Cancel link is not displayed
@ -788,7 +785,7 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_invitation_cancel_no_permissions(self): def test_domain_invitation_cancel_no_permissions(self):
"""Posting to the delete view as a different user should fail.""" """Posting to the cancel view as a different user should fail."""
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
@ -797,7 +794,7 @@ class TestDomainManagers(TestDomainOverview):
self.client.force_login(other_user) self.client.force_login(other_user)
mock_client = MagicMock() mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client): with boto3_mocking.clients.handler_for("sesv2", mock_client):
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
self.assertEqual(result.status_code, 403) self.assertEqual(result.status_code, 403)

View file

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

View file

@ -11,7 +11,7 @@ from .domain import (
DomainSecurityEmailView, DomainSecurityEmailView,
DomainUsersView, DomainUsersView,
DomainAddUserView, DomainAddUserView,
DomainInvitationDeleteView, DomainInvitationCancelView,
DomainDeleteUserView, DomainDeleteUserView,
) )
from .user_profile import UserProfileView, FinishProfileSetupView from .user_profile import UserProfileView, FinishProfileSetupView

View file

@ -2,7 +2,7 @@
Authorization is handled by the `DomainPermissionView`. To ensure that only Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView). inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
""" """
from datetime import date from datetime import date
@ -63,7 +63,7 @@ from epplibwrapper import (
) )
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -914,7 +914,9 @@ class DomainUsersView(DomainBaseView):
has_admin_flag = True has_admin_flag = True
break # Once we find one match, no need to check further break # Once we find one match, no need to check further
# Add the role along with the computed flag to the list # Add the role along with the computed flag to the list if the domain invitation
# if the status is not canceled
if domain_invitation.status != "canceled":
invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag})
# Pass roles_with_flags to the context # Pass roles_with_flags to the context
@ -985,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
existing_org_invitation and existing_org_invitation.portfolio != requestor_org existing_org_invitation and existing_org_invitation.portfolio != requestor_org
) )
def _check_invite_status(self, invite, email):
"""Check if invitation status is canceled or retrieved, and gives the appropiate response"""
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
messages.warning(
self.request,
f"{email} is already a manager for this domain.",
)
return False
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
invite.update_cancellation_status()
invite.save()
return True
else:
# else if it has been sent but not accepted
messages.warning(self.request, f"{email} has already been invited to this domain")
return False
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
"""Performs the sending of the domain invitation email, """Performs the sending of the domain invitation email,
does not make a domain information object does not make a domain information object
@ -1020,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
# Check to see if an invite has already been sent # Check to see if an invite has already been sent
try: try:
invite = DomainInvitation.objects.get(email=email, domain=self.object) invite = DomainInvitation.objects.get(email=email, domain=self.object)
# check if the invite has already been accepted # check if the invite has already been accepted or has a canceled invite
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: add_success = self._check_invite_status(invite, email)
add_success = False
messages.warning(
self.request,
f"{email} is already a manager for this domain.",
)
else:
add_success = False
# else if it has been sent but not accepted
messages.warning(self.request, f"{email} has already been invited to this domain")
except Exception: except Exception:
logger.error("An error occured") logger.error("An error occured")
@ -1052,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
self.object, self.object,
exc_info=True, exc_info=True,
) )
logger.info(exc)
raise EmailSendingError("Could not send email invitation.") from exc raise EmailSendingError("Could not send email invitation.") from exc
else: else:
if add_success: if add_success:
@ -1127,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
# The order of the superclasses matters here. BaseDeleteView has a bug where the class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin. object: DomainInvitation
# The workaround is to use SuccessMessageMixin first. fields = []
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
object: DomainInvitation # workaround for type mismatch in DeleteView
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Override post method in order to error in the case when the """Override post method in order to error in the case when the
@ -1139,6 +1148,8 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() form = self.get_form()
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
self.object.cancel_invitation()
self.object.save()
return self.form_valid(form) return self.form_valid(form)
else: else:
# Produce an error message if the domain invatation status is RETRIEVED # Produce an error message if the domain invatation status is RETRIEVED

View file

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

View file

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

View file

@ -5,9 +5,9 @@ from .permission_views import (
DomainPermissionView, DomainPermissionView,
DomainRequestPermissionView, DomainRequestPermissionView,
DomainRequestPermissionWithdrawView, DomainRequestPermissionWithdrawView,
DomainInvitationPermissionDeleteView,
DomainRequestWizardPermissionView, DomainRequestWizardPermissionView,
PortfolioMembersPermission, PortfolioMembersPermission,
DomainRequestPortfolioViewonlyView, DomainRequestPortfolioViewonlyView,
DomainInvitationPermissionCancelView,
) )
from .api_views import get_senior_official_from_federal_agency_json from .api_views import get_senior_official_from_federal_agency_json

View file

@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
id=self.kwargs["pk"], domain__permissions__user=self.request.user id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists(): ).exists():
return False return False
return True return True

View file

@ -2,7 +2,7 @@
import abc # abstract base class import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
from registrar.models.user import User from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
raise NotImplementedError raise NotImplementedError
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC): class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
"""Abstract view for deleting a domain invitation. """Abstract view for cancelling a DomainInvitation."""
This one is fairly specialized, but this is the only thing that we do
right now with domain invitations. We still have the full
`DomainInvitationPermission` class, but here we just pair it with a
DeleteView.
"""
model = DomainInvitation model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView object: DomainInvitation
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC): class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):