mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-24 11:38:39 +02:00
Merge branch 'main' into ms/2826-self-host-select2
This commit is contained in:
commit
d016571309
33 changed files with 1843 additions and 871 deletions
|
@ -16,6 +16,8 @@ The following set of rules should be followed while an incident is in progress.
|
|||
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
|
||||
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
|
||||
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
|
||||
- Uncomment the [banner on manage.get.gov's base template](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/base.html#L78).
|
||||
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
|
||||
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
|
||||
|
||||
## Post Incident
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "base" as *;
|
||||
|
||||
// Fixes some font size disparities with the Figma
|
||||
|
@ -29,3 +30,24 @@
|
|||
.usa-alert__body--widescreen {
|
||||
max-width: $widescreen-max-width !important;
|
||||
}
|
||||
|
||||
.usa-site-alert--hot-pink {
|
||||
.usa-alert {
|
||||
background-color: $hot-pink;
|
||||
border-left-color: $hot-pink;
|
||||
.usa-alert__body {
|
||||
color: color('base-darkest');
|
||||
background-color: $hot-pink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@supports ((-webkit-mask:url()) or (mask:url())) {
|
||||
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
|
||||
background-color: color('base-darkest');
|
||||
}
|
||||
}
|
||||
|
||||
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
|
||||
background-image: url('../img/usa-icons-bg/error.svg');
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
@use "cisa_colors" as *;
|
||||
|
||||
$widescreen-max-width: 1920px;
|
||||
$hot-pink: #FFC3F9;
|
||||
|
||||
/* Styles for making visible to screen reader / AT users only. */
|
||||
.sr-only {
|
||||
|
|
|
@ -119,7 +119,7 @@ in the form $setting: value,
|
|||
/*---------------------------
|
||||
## Emergency state
|
||||
----------------------------*/
|
||||
$theme-color-emergency: #FFC3F9,
|
||||
$theme-color-emergency: "red-warm-60v",
|
||||
|
||||
/*---------------------------
|
||||
# Input settings
|
||||
|
|
|
@ -93,6 +93,11 @@ urlpatterns = [
|
|||
views.PortfolioMemberView.as_view(),
|
||||
name="member",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/delete",
|
||||
views.PortfolioMemberDeleteView.as_view(),
|
||||
name="member-delete",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/permissions",
|
||||
views.PortfolioMemberEditView.as_view(),
|
||||
|
@ -108,6 +113,11 @@ urlpatterns = [
|
|||
views.PortfolioInvitedMemberView.as_view(),
|
||||
name="invitedmember",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/delete",
|
||||
views.PortfolioInvitedMemberDeleteView.as_view(),
|
||||
name="invitedmember-delete",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/permissions",
|
||||
views.PortfolioInvitedMemberEditView.as_view(),
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import logging
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from registrar.models import DomainInformation, UserDomainRole
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
from .domain_invitation import DomainInvitation
|
||||
from .portfolio_invitation import PortfolioInvitation
|
||||
|
@ -471,3 +472,42 @@ class User(AbstractUser):
|
|||
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
||||
else:
|
||||
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
|
||||
|
||||
def get_active_requests_count_in_portfolio(self, request):
|
||||
"""Return count of active requests for the portfolio associated with the request."""
|
||||
# Get the portfolio from the session using the existing method
|
||||
|
||||
portfolio = request.session.get("portfolio")
|
||||
|
||||
if not portfolio:
|
||||
return 0 # No portfolio found
|
||||
|
||||
allowed_states = [
|
||||
DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||
]
|
||||
|
||||
# Now filter based on the portfolio retrieved
|
||||
active_requests_count = self.domain_requests_created.filter(
|
||||
status__in=allowed_states, portfolio=portfolio
|
||||
).count()
|
||||
|
||||
return active_requests_count
|
||||
|
||||
def is_only_admin_of_portfolio(self, portfolio):
|
||||
"""Check if the user is the only admin of the given portfolio."""
|
||||
|
||||
UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission")
|
||||
|
||||
admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
|
||||
|
||||
admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission])
|
||||
admin_count = admins.count()
|
||||
|
||||
# Check if the current user is in the list of admins
|
||||
if admin_count == 1 and admins.first().user == self:
|
||||
return True # The user is the only admin
|
||||
|
||||
# If there are other admins or the user is not the only one
|
||||
return False
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{% block header %}
|
||||
{% if not IS_PRODUCTION %}
|
||||
{% with add_body_class="margin-left-1" %}
|
||||
{% include "includes/non-production-alert.html" %}
|
||||
{% include "includes/banner-non-production-alert.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -72,9 +72,28 @@
|
|||
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
|
||||
|
||||
{% if not IS_PRODUCTION %}
|
||||
{% include "includes/non-production-alert.html" %}
|
||||
{% include "includes/banner-non-production-alert.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
<!-- Site banner / red alert banner / emergency banner / incident banner - Remove one of those includes and place outside the comment block to activate the banner.
|
||||
DO NOT FORGET TO EDIT THE BANNER CONTENT -->
|
||||
|
||||
<!-- Red banner with exclamation mark in a circle: -->
|
||||
{% include "includes/banner-error.html" %}
|
||||
|
||||
<!-- Blue banner with 'i'' mark in a circle: -->
|
||||
{% include "includes/banner-info.html" %}
|
||||
|
||||
<!-- Marron banner with exclamation mark in a circle: -->
|
||||
{% include "includes/banner-service-disruption.html" %}
|
||||
{% include "includes/banner-site-alert.html" %}
|
||||
{% include "includes/banner-system-outage.html" %}
|
||||
|
||||
<!-- Yellow banner with exclamation mark in a triangle: -->
|
||||
{% include "includes/banner-warning.html" %}
|
||||
{% endcomment %}
|
||||
|
||||
<section class="usa-banner" aria-label="Official website of the United States government">
|
||||
<div class="usa-accordion">
|
||||
<header class="usa-banner__header">
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
Are you sure you want to extend the expiration date?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
|
@ -128,7 +128,7 @@
|
|||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
Are you sure you want to place this domain on hold?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
|
@ -195,7 +195,7 @@
|
|||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
Are you sure you want to remove this domain from the registry?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
Are you sure you want to select ineligible status?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
|
|
|
@ -359,7 +359,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="5">Other contact information</th>
|
||||
<th colspan="4">Other contact information</th>
|
||||
<th>Action</th>
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<th>Title</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Roles</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
@ -41,14 +41,16 @@
|
|||
|
||||
{% include "includes/domain_dates.html" %}
|
||||
|
||||
{% if is_portfolio_user and not is_domain_manager %}
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<p class="usa-alert__text ">
|
||||
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||
</p>
|
||||
{% if analyst_action != 'edit' or analyst_action_location != domain.pk %}
|
||||
{% if is_portfolio_user and not is_domain_manager %}
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<p class="usa-alert__text ">
|
||||
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
|
12
src/registrar/templates/includes/banner-error.html
Normal file
12
src/registrar/templates/includes/banner-error.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert usa-alert--error">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h4 class="usa-alert__heading">
|
||||
Header
|
||||
</h4>
|
||||
<p class="usa-alert__text maxw-none">
|
||||
Text here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
12
src/registrar/templates/includes/banner-info.html
Normal file
12
src/registrar/templates/includes/banner-info.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<section class="usa-site-alert usa-site-alert--info margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h4 class="usa-alert__heading">
|
||||
Header
|
||||
</h4>
|
||||
<p class="usa-alert__text maxw-none">
|
||||
Text here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,9 @@
|
|||
<section class="usa-site-alert usa-site-alert--emergency usa-site-alert--hot-pink margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<p class="usa-alert__text maxw-none">
|
||||
<strong>Attention:</strong> You are on a test site.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,12 @@
|
|||
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h3 class="usa-alert__heading">
|
||||
Service disruption
|
||||
</h3>
|
||||
<p class="usa-alert__text maxw-none">
|
||||
Month day, time-in-24-hour-notation UTC: We're investigating a service disruption on the .gov registrar. The .gov zone and individual domains remain online. However, the registrar is running slower than usual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
12
src/registrar/templates/includes/banner-site-alert.html
Normal file
12
src/registrar/templates/includes/banner-site-alert.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h3 class="usa-alert__heading">
|
||||
Header here
|
||||
</h3>
|
||||
<p class="usa-alert__tex maxw-none">
|
||||
Text here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
12
src/registrar/templates/includes/banner-system-outage.html
Normal file
12
src/registrar/templates/includes/banner-system-outage.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h3 class="usa-alert__heading">
|
||||
System outage
|
||||
</h3>
|
||||
<p class="usa-alert__text maxw-none">
|
||||
Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
12
src/registrar/templates/includes/banner-warning.html
Normal file
12
src/registrar/templates/includes/banner-warning.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert usa-alert--warning">
|
||||
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<h4 class="usa-alert__heading">
|
||||
Header
|
||||
</h4>
|
||||
<p class="usa-alert__text maxw-none">
|
||||
Text here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,7 +1,7 @@
|
|||
{% load static %}
|
||||
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}"></span>
|
||||
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}" data-has-edit-permission="{{ has_edit_members_portfolio_permission }}"></span>
|
||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_portfolio_members_json' as url %}
|
||||
<span id="get_members_json_url" class="display-none">{{url}}</span>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
{{ modal_heading }}
|
||||
{%if domain_name_modal is not None %}
|
||||
<span class="domain-name-wrap">
|
||||
|
@ -16,7 +16,7 @@
|
|||
{% endif %}
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
<p id="modal-1-description">
|
||||
<p>
|
||||
{{ modal_description }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||
<b>Attention:</b> You are on a test site.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,7 +1,9 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member {% endblock %}
|
||||
{% block title %}
|
||||
Organization member
|
||||
{% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
|
@ -33,60 +35,30 @@
|
|||
</h2>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions"
|
||||
>
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||
<h2>More options</h2>
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
<div id="wrapper-delete-action"
|
||||
data-member-name="{{ member.email }}"
|
||||
data-member-type="member"
|
||||
data-member-id="{{ member.id }}"
|
||||
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
|
||||
data-member-email="{{ member.email }}"
|
||||
>
|
||||
<!-- JS should inject member kebob here -->
|
||||
</div>
|
||||
{% elif portfolio_invitation %}
|
||||
<div id="wrapper-delete-action"
|
||||
data-member-name="{{ portfolio_invitation.email }}"
|
||||
data-member-type="invitedmember"
|
||||
data-member-id="{{ portfolio_invitation.id }}"
|
||||
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
|
||||
data-member-email="{{ portfolio_invitation.email }}"
|
||||
>
|
||||
<!-- JS should inject invited kebob here -->
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
|
||||
<address>
|
||||
<strong class="text-primary-dark">Last active:</strong>
|
||||
{% if member and member.last_login %}
|
||||
|
|
|
@ -9,11 +9,15 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="main-content">
|
||||
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
|
||||
<div class="usa-alert__body usa-alert__body--widescreen">
|
||||
<p class="usa-alert__text ">
|
||||
<!-- alert message will be conditionally populated by javascript -->
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<h1 id="members-header">Members</h1>
|
||||
|
|
|
@ -51,11 +51,11 @@ Edit your User Profile |
|
|||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
<h2 class="usa-modal__heading">
|
||||
Add contact information
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
<p id="modal-1-description">
|
||||
<p>
|
||||
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
|
||||
Before you can manage your domain, we need you to add your contact information.
|
||||
</p>
|
||||
|
|
|
@ -824,6 +824,92 @@ class TestUser(TestCase):
|
|||
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self):
|
||||
# There is no portfolio referenced in session so should return 0
|
||||
request = self.factory.get("/")
|
||||
request.session = {}
|
||||
|
||||
count = self.user.get_active_requests_count_in_portfolio(request)
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self):
|
||||
request = self.factory.get("/")
|
||||
request.session = {"portfolio": self.portfolio}
|
||||
|
||||
# Create active requests
|
||||
domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov")
|
||||
domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov")
|
||||
domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov")
|
||||
domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov")
|
||||
|
||||
# Create 3 active requests + 1 that isn't
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=domain_1,
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=domain_2,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=domain_3,
|
||||
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
DomainRequest.objects.create( # This one should not be counted
|
||||
creator=self.user,
|
||||
requested_domain=domain_4,
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
|
||||
count = self.user.get_active_requests_count_in_portfolio(request)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_is_only_admin_of_portfolio_returns_true(self):
|
||||
# Create user as the only admin of the portfolio
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self):
|
||||
# No admin for the portfolio
|
||||
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self):
|
||||
# Create multiple admins for the same portfolio
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
# Create another user within this test
|
||||
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self):
|
||||
# Create other_user for same portfolio and is given admin access
|
||||
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
|
||||
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
# User doesn't have admin access so should return false
|
||||
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||
|
||||
|
||||
class TestContact(TestCase):
|
||||
@less_console_noise_decorator
|
||||
|
|
|
@ -323,6 +323,27 @@ class TestDomainDetail(TestDomainOverview):
|
|||
self.assertContains(detail_page, "noinformation.gov")
|
||||
self.assertContains(detail_page, "Domain missing domain information")
|
||||
|
||||
def test_domain_detail_with_analyst_managing_domain(self):
|
||||
"""Test that domain management page returns 200 and does not display
|
||||
blue error message when an analyst is managing the domain"""
|
||||
with less_console_noise():
|
||||
staff_user = create_user()
|
||||
self.client.force_login(staff_user)
|
||||
|
||||
# need to set the analyst_action and analyst_action_location
|
||||
# in the session to emulate user clicking Manage Domain
|
||||
# in the admin interface
|
||||
session = self.client.session
|
||||
session["analyst_action"] = "edit"
|
||||
session["analyst_action_location"] = self.domain.id
|
||||
session.save()
|
||||
|
||||
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
|
||||
|
||||
self.assertNotContains(
|
||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_domain_readonly_on_detail_page(self):
|
||||
|
|
|
@ -2,8 +2,9 @@ from django.urls import reverse
|
|||
from api.tests.common import less_console_noise_decorator
|
||||
from registrar.config import settings
|
||||
from registrar.models import Portfolio, SeniorOfficial
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django_webtest import WebTest # type: ignore
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from registrar.models import (
|
||||
DomainRequest,
|
||||
Domain,
|
||||
|
@ -959,7 +960,7 @@ class TestPortfolio(WebTest):
|
|||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(
|
||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest):
|
|||
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
|
||||
domain_request.delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_members_table_contains_hidden_permissions_js_hook(self):
|
||||
# In the members_table.html we use data-has-edit-permission as a boolean
|
||||
# to indicate if a user has permission to edit members in the specific portfolio
|
||||
|
||||
# 1. User w/ edit permission
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Create a member under same portfolio
|
||||
member_email = "a_member@example.com"
|
||||
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# I log in as the User so I can see the Members Table
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Specifically go to the Member Table page
|
||||
response = self.client.get(reverse("members"))
|
||||
|
||||
self.assertContains(response, 'data-has-edit-permission="True"')
|
||||
|
||||
# 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed)
|
||||
permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
|
||||
|
||||
# Remove the EDIT_MEMBERS additional permission
|
||||
permission.additional_permissions = [
|
||||
perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS
|
||||
]
|
||||
|
||||
# Save the updated permissions list
|
||||
permission.save()
|
||||
|
||||
# Re-fetch the page to check for updated permissions
|
||||
response = self.client.get(reverse("members"))
|
||||
|
||||
self.assertContains(response, 'data-has-edit-permission="False"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self):
|
||||
"""Test that the kebab wrapper displays for a member with edit permissions"""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Create a member under same portfolio
|
||||
member_email = "a_member@example.com"
|
||||
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
|
||||
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# I log in as the User so I can see the Manage Member page
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Specifically go to the Manage Member page
|
||||
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check for email AND member type (which here is just member)
|
||||
self.assertContains(response, f'data-member-name="{member_email}"')
|
||||
self.assertContains(response, 'data-member-type="member"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self):
|
||||
"""Test that the kebab wrapper displays for an invitedmember with edit permissions"""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Invite a member under same portfolio
|
||||
invited_member_email = "invited_member@example.com"
|
||||
invitation = PortfolioInvitation.objects.create(
|
||||
email=invited_member_email,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# I log in as the User so I can see the Manage Member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert the invited members email + invitedmember type
|
||||
self.assertContains(response, f'data-member-name="{invited_member_email}"')
|
||||
self.assertContains(response, 'data-member-type="invitedmember"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_page_does_not_have_kebab_wrapper(self):
|
||||
"""Test that the kebab does not display."""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# That creates a member with only view access
|
||||
member_email = "member_with_view_access@example.com"
|
||||
member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email)
|
||||
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# I log in as the Member with only view permissions to evaluate the pages behaviour
|
||||
# when viewed by someone who doesn't have edit perms
|
||||
self.client.force_login(member)
|
||||
|
||||
# Go to the Manage Member page
|
||||
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert that the kebab edit options are unavailable
|
||||
self.assertNotContains(response, 'data-member-type="member"')
|
||||
self.assertNotContains(response, 'data-member-type="invitedmember"')
|
||||
self.assertNotContains(response, f'data-member-name="{member_email}"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_page_has_correct_form_wrapper(self):
|
||||
"""Test that the manage members page the right form wrapper"""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# That creates a member
|
||||
member_email = "a_member@example.com"
|
||||
member, _ = User.objects.get_or_create(email=member_email)
|
||||
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Login as the User to see the Manage Member page
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Specifically go to the Manage Member page
|
||||
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||
|
||||
# Check for a 200 response
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check for form method + that its "post" and id "member-delete-form"
|
||||
self.assertContains(response, "<form")
|
||||
self.assertContains(response, 'method="post"')
|
||||
self.assertContains(response, 'id="member-delete-form"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_toggleable_alert_wrapper_exists_on_members_page(self):
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# That creates a member
|
||||
member_email = "a_member@example.com"
|
||||
member, _ = User.objects.get_or_create(email=member_email)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Login as the User to see the Members Table page
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Specifically go to the Members Table page
|
||||
response = self.client.get(reverse("members"))
|
||||
|
||||
# Assert that the toggleable alert ID exists
|
||||
self.assertContains(response, '<div id="toggleable-alert"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_delete_view_members_table_active_requests(self):
|
||||
"""Error state w/ deleting a member with active request on Members Table"""
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
# That creates a member
|
||||
member_email = "a_member@example.com"
|
||||
member, _ = User.objects.get_or_create(email=member_email)
|
||||
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
|
||||
self.client.force_login(self.user)
|
||||
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||
response = self.client.post(
|
||||
reverse("member-delete", kwargs={"pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400) # Bad request due to active requests
|
||||
support_url = "https://get.gov/contact/"
|
||||
expected_error_message = (
|
||||
f"This member has an active domain request and can't be removed from the organization. "
|
||||
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||
)
|
||||
|
||||
self.assertContains(response, expected_error_message, status_code=400)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_delete_view_members_table_only_admin(self):
|
||||
"""Error state w/ deleting a member that's the only admin on Members Table"""
|
||||
|
||||
# I'm a user with admin permission
|
||||
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
|
||||
self.client.force_login(self.user)
|
||||
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||
response = self.client.post(
|
||||
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
expected_error_message = (
|
||||
"There must be at least one admin in your organization. Give another member admin "
|
||||
"permissions, make sure they log into the registrar, and then remove this member."
|
||||
)
|
||||
self.assertContains(response, expected_error_message, status_code=400)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_table_delete_view_success(self):
|
||||
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Creating a member that can be deleted (see patch)
|
||||
member_email = "deleteable_member@example.com"
|
||||
member, _ = User.objects.get_or_create(email=member_email)
|
||||
|
||||
# Set up the member in the portfolio
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# And set that the member has no active requests AND it's not the only admin
|
||||
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
|
||||
User, "is_only_admin_of_portfolio", return_value=False
|
||||
):
|
||||
|
||||
# Attempt to delete
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||
reverse("member-delete", kwargs={"pk": upp.pk}),
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
|
||||
# Check for a successful deletion
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
expected_success_message = f"You've removed {member.email} from the organization."
|
||||
self.assertContains(response, expected_success_message, status_code=200)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_delete_view_manage_members_page_active_requests(self):
|
||||
"""Error state when deleting a member with active requests on the Manage Members page"""
|
||||
|
||||
# I'm an admin user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Create a member with active requests
|
||||
member_email = "member_with_active_request@example.com"
|
||||
member, _ = User.objects.get_or_create(email=member_email)
|
||||
|
||||
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("member-delete", kwargs={"pk": upp.pk}),
|
||||
)
|
||||
# We don't want to do follow=True in response bc that does automatic redirection
|
||||
|
||||
# We want 302 bc indicates redirect
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
support_url = "https://get.gov/contact/"
|
||||
expected_error_message = (
|
||||
f"This member has an active domain request and can't be removed from the organization. "
|
||||
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||
)
|
||||
|
||||
args, kwargs = mock_error.call_args
|
||||
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||
self.assertIsInstance(args[0], WSGIRequest)
|
||||
# Check that the error message matches the expected error message
|
||||
self.assertEqual(args[1], expected_error_message)
|
||||
|
||||
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||
# and then confirm that we're still on the Manage Members page
|
||||
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk}))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_delete_view_manage_members_page_only_admin(self):
|
||||
"""Error state when trying to delete the only admin on the Manage Members page"""
|
||||
|
||||
# Create an admin with admin user perms
|
||||
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Set them to be the only admin and attempt to delete
|
||||
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
expected_error_message = (
|
||||
"There must be at least one admin in your organization. Give another member admin "
|
||||
"permissions, make sure they log into the registrar, and then remove this member."
|
||||
)
|
||||
|
||||
args, kwargs = mock_error.call_args
|
||||
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||
self.assertIsInstance(args[0], WSGIRequest)
|
||||
# Check that the error message matches the expected error message
|
||||
self.assertEqual(args[1], expected_error_message)
|
||||
|
||||
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||
# and then confirm that we're still on the Manage Members page
|
||||
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk}))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_portfolio_member_delete_view_manage_members_page_invitedmember(self):
|
||||
"""Success state w/ deleting invited member on Manage Members page should redirect back to Members Table"""
|
||||
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Invite a member under same portfolio
|
||||
invited_member_email = "invited_member@example.com"
|
||||
invitation = PortfolioInvitation.objects.create(
|
||||
email=invited_member_email,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
with patch("django.contrib.messages.success") as mock_success:
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
expected_success_message = f"You've removed {invitation.email} from the organization."
|
||||
args, kwargs = mock_success.call_args
|
||||
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||
self.assertIsInstance(args[0], WSGIRequest)
|
||||
# Check that the error message matches the expected error message
|
||||
self.assertEqual(args[1], expected_success_message)
|
||||
|
||||
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||
# and then confirm that we're now on Members Table page
|
||||
self.assertEqual(response.headers["Location"], reverse("members"))
|
||||
|
||||
|
||||
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
|
|
|
@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
user__permissions__domain__domain_info__portfolio=portfolio
|
||||
), # only include domains in portfolio
|
||||
),
|
||||
source=Value("permission", output_field=CharField()),
|
||||
type=Value("member", output_field=CharField()),
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
|
@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
"type",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
|
@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
distinct=True,
|
||||
)
|
||||
),
|
||||
source=Value("invitation", output_field=CharField()),
|
||||
type=Value("invitedmember", output_field=CharField()),
|
||||
).values(
|
||||
"id",
|
||||
"first_name",
|
||||
|
@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
"type",
|
||||
)
|
||||
return invitations
|
||||
|
||||
|
@ -188,12 +188,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
|
||||
|
||||
# Serialize member data
|
||||
member_json = {
|
||||
"id": item.get("id", ""),
|
||||
"source": item.get("source", ""),
|
||||
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
|
||||
"type": item.get("type", ""), # source is member or invitedmember
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import logging
|
||||
from django.http import Http404
|
||||
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.contrib import messages
|
||||
|
||||
from registrar.forms import portfolio as portfolioForms
|
||||
from registrar.models import Portfolio, User
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.views.utility.mixins import PortfolioMemberPermission
|
||||
from registrar.views.utility.permission_views import (
|
||||
PortfolioDomainRequestsPermissionView,
|
||||
PortfolioDomainsPermissionView,
|
||||
|
@ -81,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
|||
)
|
||||
|
||||
|
||||
class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
|
||||
|
||||
def post(self, request, pk):
|
||||
"""
|
||||
Find and delete the portfolio member using the provided primary key (pk).
|
||||
Redirect to a success page after deletion (or any other appropriate page).
|
||||
"""
|
||||
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
member = portfolio_member_permission.user
|
||||
|
||||
active_requests_count = member.get_active_requests_count_in_portfolio(request)
|
||||
|
||||
support_url = "https://get.gov/contact/"
|
||||
|
||||
error_message = ""
|
||||
|
||||
if active_requests_count > 0:
|
||||
# If they have any in progress requests
|
||||
error_message = mark_safe( # nosec
|
||||
f"This member has an active domain request and can't be removed from the organization. "
|
||||
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||
)
|
||||
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
|
||||
# If they are the last manager of a domain
|
||||
error_message = (
|
||||
"There must be at least one admin in your organization. Give another member admin "
|
||||
"permissions, make sure they log into the registrar, and then remove this member."
|
||||
)
|
||||
|
||||
# From the Members Table page Else the Member Page
|
||||
if error_message:
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return JsonResponse(
|
||||
{"error": error_message},
|
||||
status=400,
|
||||
)
|
||||
else:
|
||||
messages.error(request, error_message)
|
||||
return redirect(reverse("member", kwargs={"pk": pk}))
|
||||
|
||||
# passed all error conditions
|
||||
portfolio_member_permission.delete()
|
||||
|
||||
# From the Members Table page Else the Member Page
|
||||
success_message = f"You've removed {member.email} from the organization."
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return JsonResponse({"success": success_message}, status=200)
|
||||
else:
|
||||
messages.success(request, success_message)
|
||||
return redirect(reverse("members"))
|
||||
|
||||
|
||||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
|
@ -177,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
|
|||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
|
||||
|
||||
def post(self, request, pk):
|
||||
"""
|
||||
Find and delete the portfolio invited member using the provided primary key (pk).
|
||||
Redirect to a success page after deletion (or any other appropriate page).
|
||||
"""
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
|
||||
portfolio_invitation.delete()
|
||||
|
||||
success_message = f"You've removed {portfolio_invitation.email} from the organization."
|
||||
# From the Members Table page Else the Member Page
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return JsonResponse({"success": success_message}, status=200)
|
||||
else:
|
||||
messages.success(request, success_message)
|
||||
return redirect(reverse("members"))
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue