mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-25 03:58:39 +02:00
merge main
This commit is contained in:
commit
ed683baaab
20 changed files with 574 additions and 133 deletions
|
@ -914,7 +914,8 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi
|
|||
| 3 | **both** | If True, runs parse_requests and parse_domains. |
|
||||
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
|
||||
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
|
||||
| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
|
||||
| 6 | **add_managers** | If True, then the created portfolio will add all managers of the portfolio domains as members of the portfolio, including invited managers. |
|
||||
| 7 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
|
||||
|
||||
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
|
||||
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
|
||||
|
|
|
@ -79,6 +79,8 @@ services:
|
|||
- POSTGRES_DB=app
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=feedabee
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
node:
|
||||
build:
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.db.models import (
|
|||
Value,
|
||||
When,
|
||||
)
|
||||
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
|
@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import (
|
|||
from django.conf import settings
|
||||
from django.contrib.messages import get_messages
|
||||
from django.contrib.admin.helpers import AdminForm
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -1533,6 +1534,27 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Override the change_view to add the invitation obj for the change_form_object_tools template"""
|
||||
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
|
||||
# Get the domain invitation object
|
||||
invitation = get_object_or_404(DomainInvitation, id=object_id)
|
||||
extra_context["invitation"] = invitation
|
||||
|
||||
if request.method == "POST" and "cancel_invitation" in request.POST:
|
||||
if invitation.status == DomainInvitation.DomainInvitationStatus.INVITED:
|
||||
invitation.cancel_invitation()
|
||||
invitation.save(update_fields=["status"])
|
||||
messages.success(request, _("Invitation canceled successfully."))
|
||||
|
||||
# Redirect back to the change view
|
||||
return redirect(reverse("admin:registrar_domaininvitation_change", args=[object_id]))
|
||||
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def delete_view(self, request, object_id, extra_context=None):
|
||||
"""
|
||||
Custom delete_view to perform additional actions or customize the template.
|
||||
|
@ -1551,6 +1573,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
which will be successful if a single User exists for that email; otherwise, will
|
||||
just continue to create the invitation.
|
||||
"""
|
||||
|
||||
if not change:
|
||||
domain = obj.domain
|
||||
domain_org = getattr(domain.domain_info, "portfolio", None)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
|
||||
* attach the seleted start and end dates to a url that'll trigger the view, and finally
|
||||
* redirect to that url.
|
||||
|
@ -58,6 +59,51 @@
|
|||
/** An IIFE to initialize the analytics page
|
||||
*/
|
||||
(function () {
|
||||
|
||||
/**
|
||||
* Creates a diagonal stripe pattern for chart.js
|
||||
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
|
||||
* and https://github.com/ashiguruma/patternomaly
|
||||
* @param {string} backgroundColor - Background color of the pattern
|
||||
* @param {string} [lineColor="white"] - Color of the diagonal lines
|
||||
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
|
||||
* @param {number} [lineGap=1] - Gap between lines
|
||||
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
|
||||
*/
|
||||
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
|
||||
// Define the canvas and the 2d context so we can draw on it
|
||||
let shape = document.createElement("canvas");
|
||||
shape.width = 20;
|
||||
shape.height = 20;
|
||||
let context = shape.getContext("2d");
|
||||
|
||||
// Fill with specified background color
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, shape.width, shape.height);
|
||||
|
||||
// Set stroke properties
|
||||
context.strokeStyle = lineColor;
|
||||
context.lineWidth = 2;
|
||||
|
||||
// Rotate canvas for a right-to-left pattern
|
||||
if (rightToLeft) {
|
||||
context.translate(shape.width, 0);
|
||||
context.rotate(90 * Math.PI / 180);
|
||||
};
|
||||
|
||||
// First diagonal line
|
||||
let halfSize = shape.width / 2;
|
||||
context.moveTo(halfSize - lineGap, -lineGap);
|
||||
context.lineTo(shape.width + lineGap, halfSize + lineGap);
|
||||
|
||||
// Second diagonal line (x,y are swapped)
|
||||
context.moveTo(-lineGap, halfSize - lineGap);
|
||||
context.lineTo(halfSize + lineGap, shape.width + lineGap);
|
||||
|
||||
context.stroke();
|
||||
return context.createPattern(shape, "repeat");
|
||||
}
|
||||
|
||||
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
|
||||
var canvas = document.getElementById(canvasId);
|
||||
if (!canvas) {
|
||||
|
@ -74,17 +120,20 @@
|
|||
datasets: [
|
||||
{
|
||||
label: labelOne,
|
||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||
backgroundColor: "rgba(255, 99, 132, 0.3)",
|
||||
borderColor: "rgba(255, 99, 132, 1)",
|
||||
borderWidth: 1,
|
||||
data: listOne,
|
||||
// Set this line style to be rightToLeft for visual distinction
|
||||
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
|
||||
},
|
||||
{
|
||||
label: labelTwo,
|
||||
backgroundColor: "rgba(75, 192, 192, 0.2)",
|
||||
backgroundColor: "rgba(75, 192, 192, 0.3)",
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderWidth: 1,
|
||||
data: listTwo,
|
||||
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -116,10 +116,10 @@ export class DomainRequestsTable extends BaseTable {
|
|||
<td data-label="Status">
|
||||
${request.status}
|
||||
</td>
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<td class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-wrap">
|
||||
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>
|
||||
</svg>
|
||||
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
||||
|
|
|
@ -56,13 +56,15 @@ export class DomainsTable extends BaseTable {
|
|||
</svg>
|
||||
</td>
|
||||
${markupForSuborganizationRow}
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||
</svg>
|
||||
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
||||
</a>
|
||||
<td class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center margin-right-2">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||
</svg>
|
||||
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
|
|
@ -48,18 +48,6 @@ export class MembersTable extends BaseTable {
|
|||
// Get whether the logged in user has edit members permission
|
||||
const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null;
|
||||
|
||||
let existingExtraActionsHeader = document.querySelector('.extra-actions-header');
|
||||
|
||||
if (hasEditPermission && !existingExtraActionsHeader) {
|
||||
const extraActionsHeader = document.createElement('th');
|
||||
extraActionsHeader.setAttribute('id', 'extra-actions');
|
||||
extraActionsHeader.setAttribute('role', 'columnheader');
|
||||
extraActionsHeader.setAttribute('class', 'extra-actions-header width-5');
|
||||
extraActionsHeader.innerHTML = `
|
||||
<span class="usa-sr-only">Extra Actions</span>`;
|
||||
let tableHeaderRow = this.tableWrapper.querySelector('thead tr');
|
||||
tableHeaderRow.appendChild(extraActionsHeader);
|
||||
}
|
||||
return {
|
||||
'hasAdditionalActions': hasEditPermission,
|
||||
'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices
|
||||
|
@ -121,15 +109,17 @@ export class MembersTable extends BaseTable {
|
|||
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
|
||||
${last_active.display_value}
|
||||
</td>
|
||||
<td headers="header-action row-header-${unique_id}">
|
||||
<a href="${member.action_url}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
||||
</svg>
|
||||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||
</a>
|
||||
<td headers="header-action row-header-${unique_id}" class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center">
|
||||
<a href="${member.action_url}">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
||||
</svg>
|
||||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||
</a>
|
||||
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
${customTableOptions.hasAdditionalActions ? '<td>'+kebabHTML+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
|
|
|
@ -498,6 +498,28 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-tools li button {
|
||||
font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif;
|
||||
text-transform: none !important;
|
||||
font-size: 14px !important;
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 3px 12px;
|
||||
background: var(--object-tools-bg) !important;
|
||||
color: var(--object-tools-fg);
|
||||
font-weight: 400;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
line-height: 20px;
|
||||
&:focus, &:hover{
|
||||
background: var(--object-tools-hover-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.module--custom {
|
||||
a {
|
||||
font-size: 13px;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "cisa_colors" as *;
|
||||
|
||||
$widescreen-max-width: 1920px;
|
||||
$widescreen-max-width: 1536px;
|
||||
$widescreen-x-padding: 4.5rem;
|
||||
|
||||
$hot-pink: #FFC3F9;
|
||||
|
@ -46,12 +46,11 @@ body {
|
|||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
|
||||
.section-outlined {
|
||||
background-color: color('white');
|
||||
border: 1px solid color('base-lighter');
|
||||
border-radius: 4px;
|
||||
padding: 0 units(4) units(3) units(2);
|
||||
padding: 0 units(2) units(3) units(2);
|
||||
margin-top: units(3);
|
||||
|
||||
&.margin-top-0 {
|
||||
|
@ -275,3 +274,12 @@ abbr[title] {
|
|||
.width-quarter {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/*
|
||||
NOTE: width: 3% basically forces a fit-content effect in the table.
|
||||
Fit-content itself does not work.
|
||||
*/
|
||||
.width--action-column {
|
||||
width: 3%;
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
|
|
|
@ -5,9 +5,16 @@ import logging
|
|||
from django.core.management import BaseCommand, CommandError
|
||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.generic_helper import normalize_string
|
||||
from django.db.models import F, Q
|
||||
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -21,6 +28,10 @@ class Command(BaseCommand):
|
|||
self.updated_portfolios = set()
|
||||
self.skipped_portfolios = set()
|
||||
self.failed_portfolios = set()
|
||||
self.added_managers = set()
|
||||
self.added_invitations = set()
|
||||
self.skipped_invitations = set()
|
||||
self.failed_managers = set()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add command line arguments to create federal portfolios.
|
||||
|
@ -38,6 +49,9 @@ class Command(BaseCommand):
|
|||
Optional (mutually exclusive with parse options):
|
||||
--both: Shorthand for using both --parse_requests and --parse_domains
|
||||
Cannot be used with --parse_requests or --parse_domains
|
||||
|
||||
Optional:
|
||||
--add_managers: Add all domain managers of the portfolio's domains to the organization.
|
||||
"""
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
|
@ -64,23 +78,31 @@ class Command(BaseCommand):
|
|||
action=argparse.BooleanOptionalAction,
|
||||
help="Adds portfolio to both requests and domains",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add_managers",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Add all domain managers of the portfolio's domains to the organization.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip_existing_portfolios",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Only add suborganizations to newly created portfolios, skip existing ones.",
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
def handle(self, **options): # noqa: C901
|
||||
agency_name = options.get("agency_name")
|
||||
branch = options.get("branch")
|
||||
parse_requests = options.get("parse_requests")
|
||||
parse_domains = options.get("parse_domains")
|
||||
both = options.get("both")
|
||||
add_managers = options.get("add_managers")
|
||||
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
||||
|
||||
if not both:
|
||||
if not parse_requests and not parse_domains:
|
||||
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
||||
if not (parse_requests or parse_domains or add_managers):
|
||||
raise CommandError(
|
||||
"You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||
)
|
||||
else:
|
||||
if parse_requests or parse_domains:
|
||||
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||
|
@ -96,7 +118,6 @@ class Command(BaseCommand):
|
|||
)
|
||||
else:
|
||||
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
||||
|
||||
portfolios = []
|
||||
for federal_agency in agencies:
|
||||
message = f"Processing federal agency '{federal_agency.agency}'..."
|
||||
|
@ -107,6 +128,8 @@ class Command(BaseCommand):
|
|||
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
|
||||
)
|
||||
portfolios.append(portfolio)
|
||||
if add_managers:
|
||||
self.add_managers_to_portfolio(portfolio)
|
||||
except Exception as exec:
|
||||
self.failed_portfolios.add(federal_agency)
|
||||
logger.error(exec)
|
||||
|
@ -127,6 +150,26 @@ class Command(BaseCommand):
|
|||
display_as_str=True,
|
||||
)
|
||||
|
||||
if add_managers:
|
||||
TerminalHelper.log_script_run_summary(
|
||||
self.added_managers,
|
||||
self.failed_managers,
|
||||
[], # can't skip managers, can only add or fail
|
||||
log_header="----- MANAGERS ADDED -----",
|
||||
debug=False,
|
||||
display_as_str=True,
|
||||
)
|
||||
|
||||
TerminalHelper.log_script_run_summary(
|
||||
self.added_invitations,
|
||||
[],
|
||||
self.skipped_invitations,
|
||||
log_header="----- INVITATIONS ADDED -----",
|
||||
debug=False,
|
||||
skipped_header="----- INVITATIONS SKIPPED (ALREADY EXISTED) -----",
|
||||
display_as_str=True,
|
||||
)
|
||||
|
||||
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
|
||||
# We only do this for started domain requests.
|
||||
if parse_requests or both:
|
||||
|
@ -147,6 +190,73 @@ class Command(BaseCommand):
|
|||
)
|
||||
self.post_process_started_domain_requests(agencies, portfolios)
|
||||
|
||||
def add_managers_to_portfolio(self, portfolio: Portfolio):
|
||||
"""
|
||||
Add all domain managers of the portfolio's domains to the organization.
|
||||
This includes adding them to the correct group and creating portfolio invitations.
|
||||
"""
|
||||
logger.info(f"Adding managers for portfolio {portfolio}")
|
||||
|
||||
# Fetch all domains associated with the portfolio
|
||||
domains = Domain.objects.filter(domain_info__portfolio=portfolio)
|
||||
domain_managers: set[UserDomainRole] = set()
|
||||
|
||||
# Fetch all users with manager roles for the domains
|
||||
# select_related means that a db query will not be occur when you do user_domain_role.user
|
||||
# Its similar to a set or dict in that it costs slightly more upfront in exchange for perf later
|
||||
user_domain_roles = UserDomainRole.objects.select_related("user").filter(
|
||||
domain__in=domains, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
domain_managers.update(user_domain_roles)
|
||||
|
||||
invited_managers: set[str] = set()
|
||||
|
||||
# Get the emails of invited managers
|
||||
domain_invitations = DomainInvitation.objects.filter(
|
||||
domain__in=domains, status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||
).values_list("email", flat=True)
|
||||
invited_managers.update(domain_invitations)
|
||||
|
||||
for user_domain_role in domain_managers:
|
||||
try:
|
||||
# manager is a user id
|
||||
user = user_domain_role.user
|
||||
_, created = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio,
|
||||
user=user,
|
||||
defaults={"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]},
|
||||
)
|
||||
self.added_managers.add(user)
|
||||
if created:
|
||||
logger.info(f"Added manager '{user}' to portfolio '{portfolio}'")
|
||||
else:
|
||||
logger.info(f"Manager '{user}' already exists in portfolio '{portfolio}'")
|
||||
except User.DoesNotExist:
|
||||
self.failed_managers.add(user)
|
||||
logger.debug(f"User '{user}' does not exist")
|
||||
|
||||
for email in invited_managers:
|
||||
self.create_portfolio_invitation(portfolio, email)
|
||||
|
||||
def create_portfolio_invitation(self, portfolio: Portfolio, email: str):
|
||||
"""
|
||||
Create a portfolio invitation for the given email.
|
||||
"""
|
||||
_, created = PortfolioInvitation.objects.get_or_create(
|
||||
portfolio=portfolio,
|
||||
email=email,
|
||||
defaults={
|
||||
"status": PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||
"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
},
|
||||
)
|
||||
if created:
|
||||
self.added_invitations.add(email)
|
||||
logger.info(f"Created portfolio invitation for '{email}' to portfolio '{portfolio}'")
|
||||
else:
|
||||
self.skipped_invitations.add(email)
|
||||
logger.info(f"Found existing portfolio invitation for '{email}' to portfolio '{portfolio}'")
|
||||
|
||||
def post_process_started_domain_requests(self, agencies, portfolios):
|
||||
"""
|
||||
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
|
||||
|
@ -160,6 +270,7 @@ class Command(BaseCommand):
|
|||
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
|
||||
# 3. The domain request is in status "started".
|
||||
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
||||
|
||||
domain_requests_to_update = DomainRequest.objects.filter(
|
||||
federal_agency__in=agencies,
|
||||
federal_agency__agency__isnull=False,
|
||||
|
|
|
@ -1,8 +1,21 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{% comment %}
|
||||
Overrides the breadcrumb styles found in this file:
|
||||
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/base.html
|
||||
{% endcomment %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url "admin:index" %}">{% trans "Home" %}</a>
|
||||
›
|
||||
<span>{% trans "Analytics Dashboard" %}</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
|
|
|
@ -15,13 +15,28 @@
|
|||
</ul>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% if opts.model_name == 'domaininvitation' %}
|
||||
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
|
||||
<li>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="cancel_invitation" value="true">
|
||||
<button type="submit" class="usa-button--dja">
|
||||
Cancel invitation
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li>
|
||||
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
|
||||
</li>
|
||||
|
||||
{% if opts.model_name == 'domainrequest' %}
|
||||
<li>
|
||||
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
|
||||
<svg class="usa-icon" >
|
||||
<svg class="usa-icon">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
|
@ -32,4 +47,3 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -11,4 +11,4 @@
|
|||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -1,5 +1,3 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
|
@ -36,47 +34,9 @@
|
|||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
Search all domains
|
||||
{% else %}
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="edit-member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
|
@ -85,7 +45,7 @@
|
|||
<caption class="sr-only">member domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th>
|
||||
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6"><span class="sr-only">Assigned domains</span></th>
|
||||
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
|
||||
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
|
@ -34,45 +32,19 @@
|
|||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% with label_text="Domains assigned to " %}
|
||||
{% if member %}
|
||||
{% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
role="columnheader"
|
||||
id="header-action"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
34
src/registrar/templates/includes/search.html
Normal file
34
src/registrar/templates/includes/search.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% load static %}
|
||||
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="{{aria_label_text}}">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05 maxw-none" for="{{item_name}}__search-field">
|
||||
{{ label_text }}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper flex-align-self-end">
|
||||
<input
|
||||
class="usa-input minw-15"
|
||||
id="{{item_name}}__search-field"
|
||||
type="search"
|
||||
name="{{item_name}}-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="{{item_name}}__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
<button class="usa-button usa-button--unstyled margin-left-3 display-none flex-1" id="{{item_name}}__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
|
@ -9,7 +9,7 @@
|
|||
{# the entire logged in page goes here #}
|
||||
|
||||
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-11 desktop:grid-col-10 {% if is_widescreen_centered %}tablet:grid-offset-1{% endif %}">
|
||||
<div class="desktop:grid-col-10 {% if not is_widescreen_centered %}tablet:grid-col-11 {% else %}tablet:padding-left-4 tablet:padding-right-4 tablet:grid-col-12 desktop:grid-offset-1{% endif %}">
|
||||
|
||||
{% block portfolio_content %}{% endblock %}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ from registrar.models import (
|
|||
Domain,
|
||||
DomainRequest,
|
||||
DomainInformation,
|
||||
DomainInvitation,
|
||||
User,
|
||||
Host,
|
||||
Portfolio,
|
||||
|
@ -495,6 +496,107 @@ class TestDomainInformationInline(MockEppLib):
|
|||
self.assertIn("poopy@gov.gov", domain_managers)
|
||||
|
||||
|
||||
class TestDomainInvitationAdmin(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.staffuser = create_user(email="staffdomainmanager@meoward.com", is_staff=True)
|
||||
cls.site = AdminSite()
|
||||
cls.admin = DomainAdmin(model=Domain, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.staffuser)
|
||||
super().setUp()
|
||||
|
||||
def test_successful_cancel_invitation_flow_in_admin(self):
|
||||
"""Testing canceling a domain invitation in Django Admin."""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="cancelinvitationflowviaadmin.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="inviteddomainmanager@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you can see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Cancel invitation")
|
||||
|
||||
# 5. Click the cancel invitation button
|
||||
response = self.client.post(domain_invitation_change_url, {"cancel_invitation": "true"}, follow=True)
|
||||
|
||||
# 6. Make sure we're redirect back to the change view page in /admin
|
||||
self.assertRedirects(response, domain_invitation_change_url)
|
||||
|
||||
# 7. Confirm cancellation confirmation message appears
|
||||
expected_message = f"Invitation for {invitation.email} on {domain.name} is canceled"
|
||||
self.assertContains(response, expected_message)
|
||||
|
||||
def test_no_cancel_invitation_button_in_retrieved_state(self):
|
||||
"""Shouldn't be able to see the "Cancel invitation" button if invitation is RETRIEVED state"""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="retrieved.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain and NOT in invited state
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="retrievedinvitation@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you CANNOT see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Cancel invitation")
|
||||
|
||||
def test_no_cancel_invitation_button_in_canceled_state(self):
|
||||
"""Shouldn't be able to see the "Cancel invitation" button if invitation is CANCELED state"""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="canceled.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain and NOT in invited state
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="canceledinvitation@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.CANCELED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you CANNOT see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Cancel invitation")
|
||||
|
||||
|
||||
class TestDomainAdminWithClient(TestCase):
|
||||
"""Test DomainAdmin class as super user.
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from registrar.models.domain_group import DomainGroup
|
|||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.senior_official import SeniorOfficial
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
|
@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
self.executive_so_2 = SeniorOfficial.objects.create(
|
||||
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
|
||||
)
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
self.domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
|
@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
)
|
||||
self.domain_request.approve()
|
||||
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
|
||||
self.domain = Domain.objects.get(name="city.gov")
|
||||
|
||||
self.domain_request_2 = completed_domain_request(
|
||||
name="icecreamforigorville.gov",
|
||||
|
@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
FederalAgency.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def run_create_federal_portfolio(self, **kwargs):
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
||||
|
@ -1820,12 +1822,12 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
|
||||
# We expect a error to be thrown when we dont pass parse requests or domains
|
||||
with self.assertRaisesRegex(
|
||||
CommandError, "You must specify at least one of --parse_requests or --parse_domains."
|
||||
CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||
):
|
||||
self.run_create_federal_portfolio(branch="executive")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
CommandError, "You must specify at least one of --parse_requests or --parse_domains."
|
||||
CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||
):
|
||||
self.run_create_federal_portfolio(agency_name="test")
|
||||
|
||||
|
@ -1865,6 +1867,142 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
self.assertEqual(existing_portfolio.creator, self.user)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_add_managers_from_domains(self):
|
||||
"""Test that all domain managers are added as portfolio managers."""
|
||||
|
||||
# Create users and assign them as domain managers
|
||||
manager1 = User.objects.create(username="manager1", email="manager1@example.com")
|
||||
manager2 = User.objects.create(username="manager2", email="manager2@example.com")
|
||||
UserDomainRole.objects.create(user=manager1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||
UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Run the management command
|
||||
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
|
||||
|
||||
# Check that the portfolio was created
|
||||
self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
|
||||
|
||||
# Check that the users have been added as portfolio managers
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
|
||||
|
||||
# Check that the users have been added as portfolio managers
|
||||
self.assertEqual(permissions.count(), 2)
|
||||
for perm in permissions:
|
||||
self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_add_invited_managers(self):
|
||||
"""Test that invited domain managers receive portfolio invitations."""
|
||||
|
||||
# create a domain invitation for the manager
|
||||
_ = DomainInvitation.objects.create(
|
||||
domain=self.domain, email="manager1@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||
)
|
||||
|
||||
# Run the management command
|
||||
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
|
||||
|
||||
# Check that the portfolio was created
|
||||
self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
|
||||
|
||||
# Check that a PortfolioInvitation has been created for the invited email
|
||||
invitation = PortfolioInvitation.objects.get(email="manager1@example.com", portfolio=self.portfolio)
|
||||
|
||||
# Verify the status of the invitation remains INVITED
|
||||
self.assertEqual(
|
||||
invitation.status,
|
||||
PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||
"PortfolioInvitation status should remain INVITED for non-existent users.",
|
||||
)
|
||||
|
||||
# Verify that no duplicate invitations are created
|
||||
self.run_create_federal_portfolio(
|
||||
agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
|
||||
)
|
||||
invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
|
||||
self.assertEqual(
|
||||
invitations.count(),
|
||||
1,
|
||||
"Duplicate PortfolioInvitation should not be created for the same email and portfolio.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_no_duplicate_managers_added(self):
|
||||
"""Test that duplicate managers are not added multiple times."""
|
||||
# Create a manager
|
||||
manager = User.objects.create(username="manager", email="manager@example.com")
|
||||
UserDomainRole.objects.create(user=manager, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Create a pre-existing portfolio
|
||||
self.portfolio = Portfolio.objects.create(
|
||||
organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
|
||||
)
|
||||
|
||||
# Manually add the manager to the portfolio
|
||||
UserPortfolioPermission.objects.create(
|
||||
portfolio=self.portfolio, user=manager, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||
)
|
||||
|
||||
# Run the management command
|
||||
self.run_create_federal_portfolio(
|
||||
agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
|
||||
)
|
||||
|
||||
# Ensure that the manager is not duplicated
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user=manager)
|
||||
self.assertEqual(permissions.count(), 1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_add_managers_skip_existing_portfolios(self):
|
||||
"""Test that managers are skipped when the portfolio already exists."""
|
||||
|
||||
# Create a pre-existing portfolio
|
||||
self.portfolio = Portfolio.objects.create(
|
||||
organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
|
||||
)
|
||||
|
||||
domain_request_1 = completed_domain_request(
|
||||
name="domain1.gov",
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
generic_org_type=DomainRequest.OrganizationChoices.CITY,
|
||||
federal_agency=self.federal_agency,
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
domain_request_1.approve()
|
||||
domain1 = Domain.objects.get(name="domain1.gov")
|
||||
|
||||
domain_request_2 = completed_domain_request(
|
||||
name="domain2.gov",
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
generic_org_type=DomainRequest.OrganizationChoices.CITY,
|
||||
federal_agency=self.federal_agency,
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
domain_request_2.approve()
|
||||
domain2 = Domain.objects.get(name="domain2.gov")
|
||||
|
||||
# Create users and assign them as domain managers
|
||||
manager1 = User.objects.create(username="manager1", email="manager1@example.com")
|
||||
manager2 = User.objects.create(username="manager2", email="manager2@example.com")
|
||||
UserDomainRole.objects.create(user=manager1, domain=domain1, role=UserDomainRole.Roles.MANAGER)
|
||||
UserDomainRole.objects.create(user=manager2, domain=domain2, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Run the management command
|
||||
self.run_create_federal_portfolio(
|
||||
agency_name=self.federal_agency.agency,
|
||||
parse_requests=True,
|
||||
add_managers=True,
|
||||
skip_existing_portfolios=True,
|
||||
)
|
||||
|
||||
# Check that managers were added to the portfolio
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
|
||||
self.assertEqual(permissions.count(), 2)
|
||||
for perm in permissions:
|
||||
self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
|
||||
|
||||
def test_skip_existing_portfolios(self):
|
||||
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
||||
suborgs, domain requests, and domain info."""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue