Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3389-env-emails

This commit is contained in:
Rebecca Hsieh 2025-02-12 09:51:10 -08:00
commit ad29eecb45
No known key found for this signature in database
13 changed files with 525 additions and 99 deletions

View file

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

View file

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

View file

@ -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')
},
],
};

View file

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

View file

@ -46,7 +46,6 @@ body {
background-color: color('gray-1');
}
.section-outlined {
background-color: color('white');
border: 1px solid color('base-lighter');

View file

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

View file

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

View file

@ -11,4 +11,4 @@
</div>
</div>
{{ block.super }}
{% endblock %}
{% endblock %}

View file

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

View file

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

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

View file

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

View file

@ -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",
@ -1812,12 +1814,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")
@ -1854,6 +1856,143 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.notes, "Old notes")
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."""