mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 04:28:39 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3389-env-emails
This commit is contained in:
commit
ad29eecb45
13 changed files with 525 additions and 99 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. |
|
| 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. |
|
| 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. |
|
| 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 #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,
|
- 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,
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.db.models import (
|
||||||
Value,
|
Value,
|
||||||
When,
|
When,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from registrar.models.federal_agency import FederalAgency
|
from registrar.models.federal_agency import FederalAgency
|
||||||
|
@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import (
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.messages import get_messages
|
from django.contrib.messages import get_messages
|
||||||
from django.contrib.admin.helpers import AdminForm
|
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 django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
@ -1533,6 +1534,27 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
# Get the filtered values
|
# Get the filtered values
|
||||||
return super().changelist_view(request, extra_context=extra_context)
|
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):
|
def delete_view(self, request, object_id, extra_context=None):
|
||||||
"""
|
"""
|
||||||
Custom delete_view to perform additional actions or customize the template.
|
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
|
which will be successful if a single User exists for that email; otherwise, will
|
||||||
just continue to create the invitation.
|
just continue to create the invitation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not change:
|
if not change:
|
||||||
domain = obj.domain
|
domain = obj.domain
|
||||||
domain_org = getattr(domain.domain_info, "portfolio", None)
|
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,
|
/** 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
|
* attach the seleted start and end dates to a url that'll trigger the view, and finally
|
||||||
* redirect to that url.
|
* redirect to that url.
|
||||||
|
@ -58,6 +59,51 @@
|
||||||
/** An IIFE to initialize the analytics page
|
/** An IIFE to initialize the analytics page
|
||||||
*/
|
*/
|
||||||
(function () {
|
(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) {
|
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
|
||||||
var canvas = document.getElementById(canvasId);
|
var canvas = document.getElementById(canvasId);
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
|
@ -74,17 +120,20 @@
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: labelOne,
|
label: labelOne,
|
||||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
backgroundColor: "rgba(255, 99, 132, 0.3)",
|
||||||
borderColor: "rgba(255, 99, 132, 1)",
|
borderColor: "rgba(255, 99, 132, 1)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
data: listOne,
|
data: listOne,
|
||||||
|
// Set this line style to be rightToLeft for visual distinction
|
||||||
|
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: labelTwo,
|
label: labelTwo,
|
||||||
backgroundColor: "rgba(75, 192, 192, 0.2)",
|
backgroundColor: "rgba(75, 192, 192, 0.3)",
|
||||||
borderColor: "rgba(75, 192, 192, 1)",
|
borderColor: "rgba(75, 192, 192, 1)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
data: listTwo,
|
data: listTwo,
|
||||||
|
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -498,6 +498,28 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
|
||||||
font-size: 13px;
|
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 {
|
.module--custom {
|
||||||
a {
|
a {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
|
@ -46,7 +46,6 @@ body {
|
||||||
background-color: color('gray-1');
|
background-color: color('gray-1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.section-outlined {
|
.section-outlined {
|
||||||
background-color: color('white');
|
background-color: color('white');
|
||||||
border: 1px solid color('base-lighter');
|
border: 1px solid color('base-lighter');
|
||||||
|
|
|
@ -5,9 +5,16 @@ import logging
|
||||||
from django.core.management import BaseCommand, CommandError
|
from django.core.management import BaseCommand, CommandError
|
||||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
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 registrar.models.utility.generic_helper import normalize_string
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -21,6 +28,10 @@ class Command(BaseCommand):
|
||||||
self.updated_portfolios = set()
|
self.updated_portfolios = set()
|
||||||
self.skipped_portfolios = set()
|
self.skipped_portfolios = set()
|
||||||
self.failed_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):
|
def add_arguments(self, parser):
|
||||||
"""Add command line arguments to create federal portfolios.
|
"""Add command line arguments to create federal portfolios.
|
||||||
|
@ -38,6 +49,9 @@ class Command(BaseCommand):
|
||||||
Optional (mutually exclusive with parse options):
|
Optional (mutually exclusive with parse options):
|
||||||
--both: Shorthand for using both --parse_requests and --parse_domains
|
--both: Shorthand for using both --parse_requests and --parse_domains
|
||||||
Cannot be used with --parse_requests or --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 = parser.add_mutually_exclusive_group(required=True)
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
|
@ -64,23 +78,31 @@ class Command(BaseCommand):
|
||||||
action=argparse.BooleanOptionalAction,
|
action=argparse.BooleanOptionalAction,
|
||||||
help="Adds portfolio to both requests and domains",
|
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(
|
parser.add_argument(
|
||||||
"--skip_existing_portfolios",
|
"--skip_existing_portfolios",
|
||||||
action=argparse.BooleanOptionalAction,
|
action=argparse.BooleanOptionalAction,
|
||||||
help="Only add suborganizations to newly created portfolios, skip existing ones.",
|
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")
|
agency_name = options.get("agency_name")
|
||||||
branch = options.get("branch")
|
branch = options.get("branch")
|
||||||
parse_requests = options.get("parse_requests")
|
parse_requests = options.get("parse_requests")
|
||||||
parse_domains = options.get("parse_domains")
|
parse_domains = options.get("parse_domains")
|
||||||
both = options.get("both")
|
both = options.get("both")
|
||||||
|
add_managers = options.get("add_managers")
|
||||||
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
||||||
|
|
||||||
if not both:
|
if not both:
|
||||||
if not parse_requests and not parse_domains:
|
if not (parse_requests or parse_domains or add_managers):
|
||||||
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
raise CommandError(
|
||||||
|
"You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if parse_requests or parse_domains:
|
if parse_requests or parse_domains:
|
||||||
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||||
|
@ -96,7 +118,6 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
||||||
|
|
||||||
portfolios = []
|
portfolios = []
|
||||||
for federal_agency in agencies:
|
for federal_agency in agencies:
|
||||||
message = f"Processing federal agency '{federal_agency.agency}'..."
|
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
|
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
|
||||||
)
|
)
|
||||||
portfolios.append(portfolio)
|
portfolios.append(portfolio)
|
||||||
|
if add_managers:
|
||||||
|
self.add_managers_to_portfolio(portfolio)
|
||||||
except Exception as exec:
|
except Exception as exec:
|
||||||
self.failed_portfolios.add(federal_agency)
|
self.failed_portfolios.add(federal_agency)
|
||||||
logger.error(exec)
|
logger.error(exec)
|
||||||
|
@ -127,6 +150,26 @@ class Command(BaseCommand):
|
||||||
display_as_str=True,
|
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.
|
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
|
||||||
# We only do this for started domain requests.
|
# We only do this for started domain requests.
|
||||||
if parse_requests or both:
|
if parse_requests or both:
|
||||||
|
@ -147,6 +190,73 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
self.post_process_started_domain_requests(agencies, portfolios)
|
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):
|
def post_process_started_domain_requests(self, agencies, portfolios):
|
||||||
"""
|
"""
|
||||||
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
|
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.
|
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
|
||||||
# 3. The domain request is in status "started".
|
# 3. The domain request is in status "started".
|
||||||
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
||||||
|
|
||||||
domain_requests_to_update = DomainRequest.objects.filter(
|
domain_requests_to_update = DomainRequest.objects.filter(
|
||||||
federal_agency__in=agencies,
|
federal_agency__in=agencies,
|
||||||
federal_agency__agency__isnull=False,
|
federal_agency__agency__isnull=False,
|
||||||
|
|
|
@ -15,13 +15,28 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul>
|
<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>
|
<li>
|
||||||
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
|
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if opts.model_name == 'domainrequest' %}
|
{% if opts.model_name == 'domainrequest' %}
|
||||||
<li>
|
<li>
|
||||||
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
|
<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>
|
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- the span is targeted in JS, do not remove -->
|
<!-- the span is targeted in JS, do not remove -->
|
||||||
|
@ -32,4 +47,3 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -11,4 +11,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -1,5 +1,3 @@
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% if member %}
|
{% if member %}
|
||||||
<span
|
<span
|
||||||
id="portfolio-js-value"
|
id="portfolio-js-value"
|
||||||
|
@ -36,47 +34,9 @@
|
||||||
|
|
||||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||||
<!-- ---------- SEARCH ---------- -->
|
<!-- ---------- SEARCH ---------- -->
|
||||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
{% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %}
|
||||||
<section aria-label="Member domains search component">
|
{% include "includes/search.html" %}
|
||||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
{% endwith %}
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ---------- MAIN TABLE ---------- -->
|
<!-- ---------- MAIN TABLE ---------- -->
|
||||||
|
@ -85,7 +45,7 @@
|
||||||
<caption class="sr-only">member domains</caption>
|
<caption class="sr-only">member domains</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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 -->
|
<!-- 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>
|
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% if member %}
|
{% if member %}
|
||||||
<span
|
<span
|
||||||
id="portfolio-js-value"
|
id="portfolio-js-value"
|
||||||
|
@ -34,45 +32,19 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</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 ---------- -->
|
<!-- ---------- SEARCH ---------- -->
|
||||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
{% with label_text="Domains assigned to " %}
|
||||||
<section aria-label="Member domains search component">
|
{% if member %}
|
||||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
{% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||||
{% csrf_token %}
|
{% include "includes/search.html" %}
|
||||||
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
|
{% endwith %}
|
||||||
Search domains assigned to
|
{% else %}
|
||||||
{% if member %}
|
{% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||||
{{ member.email }}
|
{% include "includes/search.html" %}
|
||||||
{% else %}
|
{% endwith %}
|
||||||
{{ portfolio_invitation.email }}
|
{% endif %}
|
||||||
{% endif %}
|
{% endwith %}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ---------- MAIN TABLE ---------- -->
|
<!-- ---------- MAIN TABLE ---------- -->
|
||||||
|
|
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>
|
|
@ -12,6 +12,7 @@ from registrar.models import (
|
||||||
Domain,
|
Domain,
|
||||||
DomainRequest,
|
DomainRequest,
|
||||||
DomainInformation,
|
DomainInformation,
|
||||||
|
DomainInvitation,
|
||||||
User,
|
User,
|
||||||
Host,
|
Host,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
|
@ -495,6 +496,107 @@ class TestDomainInformationInline(MockEppLib):
|
||||||
self.assertIn("poopy@gov.gov", domain_managers)
|
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):
|
class TestDomainAdminWithClient(TestCase):
|
||||||
"""Test DomainAdmin class as super user.
|
"""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.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.senior_official import SeniorOfficial
|
from registrar.models.senior_official import SeniorOfficial
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
self.executive_so_2 = SeniorOfficial.objects.create(
|
self.executive_so_2 = SeniorOfficial.objects.create(
|
||||||
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
|
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):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
self.domain_request = completed_domain_request(
|
self.domain_request = completed_domain_request(
|
||||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
)
|
)
|
||||||
self.domain_request.approve()
|
self.domain_request.approve()
|
||||||
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
|
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(
|
self.domain_request_2 = completed_domain_request(
|
||||||
name="icecreamforigorville.gov",
|
name="icecreamforigorville.gov",
|
||||||
|
@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
FederalAgency.objects.all().delete()
|
FederalAgency.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
|
||||||
def run_create_federal_portfolio(self, **kwargs):
|
def run_create_federal_portfolio(self, **kwargs):
|
||||||
with patch(
|
with patch(
|
||||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
||||||
|
@ -1812,12 +1814,12 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
|
|
||||||
# We expect a error to be thrown when we dont pass parse requests or domains
|
# We expect a error to be thrown when we dont pass parse requests or domains
|
||||||
with self.assertRaisesRegex(
|
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")
|
self.run_create_federal_portfolio(branch="executive")
|
||||||
|
|
||||||
with self.assertRaisesRegex(
|
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")
|
self.run_create_federal_portfolio(agency_name="test")
|
||||||
|
|
||||||
|
@ -1854,6 +1856,143 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
self.assertEqual(existing_portfolio.notes, "Old notes")
|
self.assertEqual(existing_portfolio.notes, "Old notes")
|
||||||
self.assertEqual(existing_portfolio.creator, self.user)
|
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):
|
def test_skip_existing_portfolios(self):
|
||||||
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
||||||
suborgs, domain requests, and domain info."""
|
suborgs, domain requests, and domain info."""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue