From 1c1a3f7effe15faa397a16d4a98f2cecf248159e Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 23 Jan 2025 16:12:06 -0800
Subject: [PATCH 03/98] Add alert to deleting multiple user domain roles
---
src/registrar/admin.py | 2 +-
.../domain_invitation_delete_confirmation.html | 0
...er_domain_role_delete_selected_confirmation.html | 13 +++++++++++++
3 files changed, 14 insertions(+), 1 deletion(-)
create mode 100644 src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
create mode 100644 src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index f0e435090..407532943 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1370,7 +1370,7 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/user_domain_role_change_form.html"
# Override for the delete confirmation page on the domain table (bulk delete action)
- delete_selected_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
+ delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html"
# Fixes a bug where non-superusers are redirected to the main page
def delete_view(self, request, object_id, extra_context=None):
diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html
new file mode 100644
index 000000000..392d1aebc
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html
@@ -0,0 +1,13 @@
+{% extends 'admin/delete_selected_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you remove someone from a domain here, it won't trigger any emails when you click "save."
+
+
+
+ {{ block.super }}
+{% endblock %}
From 7fe12d1a22cb077d93a6695da36f9ca0f72ac611 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 23 Jan 2025 16:19:26 -0800
Subject: [PATCH 04/98] Add alerts to deleting domain invitations
---
src/registrar/admin.py | 13 +++++++++++++
.../domain_invitation_delete_confirmation.html | 16 ++++++++++++++++
..._invitation_delete_selected_confirmation.html | 16 ++++++++++++++++
3 files changed, 45 insertions(+)
create mode 100644 src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 407532943..bb7c26fa8 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1507,6 +1507,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["domain"]
change_form_template = "django/admin/domain_invitation_change_form.html"
+ # Override for the delete confirmation page on the domain table (bulk delete action)
+ delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
# Select domain invitations to change -> Domain invitations
def changelist_view(self, request, extra_context=None):
@@ -1515,6 +1517,17 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
extra_context["tabtitle"] = "Domain invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
+
+
+ def delete_view(self, request, object_id, extra_context=None):
+ """
+ Custom delete_view to perform additional actions or customize the template.
+ """
+ # Set the delete template to a custom one
+ self.delete_confirmation_template = "django/admin/domain_invitation_delete_confirmation.html"
+ response = super().delete_view(request, object_id, extra_context=extra_context)
+
+ return response
def save_model(self, request, obj, form, change):
"""
diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
index e69de29bb..abd0721de 100644
--- a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
+++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
@@ -0,0 +1,16 @@
+{% extends 'admin/delete_confirmation.html' %}
+{% load i18n static %}
+
+{% block content_subtitle %}
+
+
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+ If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
+ their domain management privileges if they already have that role assigned. Go to the
+ User Domain Roles table
+ if you want to remove the user from a domain.
+
+
+
+ {{ block.super }}
+{% endblock %}
From 89d52d984c86efd1374cf0e00065e485ab3115fe Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Mon, 27 Jan 2025 17:31:45 -0600
Subject: [PATCH 05/98] initial updated script
---
.../commands/create_federal_portfolio.py | 260 +++++++++++++++++-
1 file changed, 249 insertions(+), 11 deletions(-)
diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py
index 389662ead..c3e78874e 100644
--- a/src/registrar/management/commands/create_federal_portfolio.py
+++ b/src/registrar/management/commands/create_federal_portfolio.py
@@ -5,7 +5,12 @@ 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.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
logger = logging.getLogger(__name__)
@@ -20,6 +25,8 @@ class Command(BaseCommand):
self.updated_portfolios = set()
self.skipped_portfolios = set()
self.failed_portfolios = set()
+ self.added_managers = set()
+ self.added_invitations = set()
def add_arguments(self, parser):
"""Add command line arguments to create federal portfolios.
@@ -37,6 +44,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(
@@ -63,6 +73,11 @@ 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.",
+ )
def handle(self, **options):
agency_name = options.get("agency_name")
@@ -70,7 +85,7 @@ class Command(BaseCommand):
parse_requests = options.get("parse_requests")
parse_domains = options.get("parse_domains")
both = options.get("both")
-
+ add_managers = options.get("add_managers")
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.")
@@ -98,18 +113,26 @@ class Command(BaseCommand):
# C901 'Command.handle' is too complex (12)
portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
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)
message = f"Failed to create portfolio '{federal_agency.agency}'"
TerminalHelper.colorful_logger(logger.info, TerminalColors.FAIL, message)
+ # POST PROCESS STEP: Add additional suborg info where applicable.
+ updated_suborg_count = self.post_process_all_suborganization_fields(agencies)
+ message = f"Added city and state_territory information to {updated_suborg_count} suborgs."
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
+
TerminalHelper.log_script_run_summary(
self.updated_portfolios,
self.failed_portfolios,
self.skipped_portfolios,
debug=False,
- skipped_header="----- SOME PORTFOLIOS WERE SKIPPED -----",
+ skipped_header="----- SOME PORTFOLIOS WERENT CREATED -----",
display_as_str=True,
)
@@ -126,6 +149,87 @@ 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("Adding managers for portfolio '{portfolio}'")
+
+ # Fetch all domains associated with the portfolio
+ domains = portfolio.get_domains()
+
+ domain_managers = set()
+
+ # Fetch all users with manager roles for the domains
+ managers = UserDomainRole.objects.filter(
+ domain__in=domains,
+ role=UserDomainRole.Roles.MANAGER
+ ).values_list('user', flat=True)
+
+ domain_managers.update(managers)
+
+ # Fetch PortfolioInvitations with manager roles for the portfolio
+ manager_invitations = PortfolioInvitation.objects.filter(
+ portfolio=portfolio,
+ roles__contains=[UserPortfolioPermission.RoleChoices.MANAGER]
+ ).values_list('email', flat=True)
+
+ # Fetch user IDs for existing users with emails in manager_invitations
+ invited_users = User.objects.filter(email__in=manager_invitations)
+ invited_user_ids = invited_users.values_list('id', flat=True)
+ domain_managers.update(invited_user_ids)
+
+ for user_id in domain_managers:
+ try:
+ user = User.objects.get(id=user_id)
+ _, created = UserPortfolioPermission.objects.get_or_create(
+ portfolio=portfolio,
+ user=user,
+ defaults={"role": UserPortfolioPermission.RoleChoices.MANAGER},
+ )
+ if created:
+ logger.info("Added manager '{user}' to portfolio '{portfolio}'")
+ else:
+ logger.info("Manager '{user}' already exists in portfolio '{portfolio}'")
+ except User.DoesNotExist:
+ self.create_portfolio_invitation(portfolio, user)
+
+ def create_portfolio_invitation(self, portfolio: Portfolio, user: User):
+ """
+ Create a portfolio invitation for the given user.
+ If the user already has a portfolio invitation, retreive their invitation and create a portfolio permission.
+ """
+ try:
+ user = User.objects.get(email=user.email)
+ _, created = PortfolioInvitation.objects.get_or_create(
+ portfolio=portfolio,
+ user=user,
+ defaults={"status": PortfolioInvitation.PortfolioInvitationStatus.PENDING},
+ )
+ if created:
+ logger.info("Created portfolio invitation for '{user}' to portfolio '{portfolio}'")
+ else:
+ logger.info("Retrieved existing portfolio invitation for '{user}' to portfolio '{portfolio}'")
+
+ # Assign portfolio permissions
+ _, created = UserPortfolioPermission.objects.get_or_create(
+ portfolio=portfolio,
+ user=user,
+ defaults={"role": UserPortfolioPermission.RoleChoices.MANAGER},
+ )
+ if created:
+ logger.info("Created portfolio permission for '{user}' to portfolio '{portfolio}'")
+ else:
+ logger.info("Retrieved existing portfolio permission for '{user}' to portfolio '{portfolio}'")
+ except User.DoesNotExist:
+ PortfolioInvitation.objects.create(
+ portfolio=portfolio,
+ user=user,
+ status=PortfolioInvitation.PortfolioInvitationStatus.PENDING,
+ )
+ logger.info("Created portfolio invitation for '{user}' 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.
@@ -169,14 +273,11 @@ class Command(BaseCommand):
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both):
"""Attempts to create a portfolio. If successful, this function will
- also create new suborganizations.
- Returns the portfolio for the given federal_agency.
- """
- portfolio, created = self.create_portfolio(federal_agency)
- if created:
- self.create_suborganizations(portfolio, federal_agency)
- if parse_domains or both:
- self.handle_portfolio_domains(portfolio, federal_agency)
+ also create new suborganizations"""
+ portfolio, _ = self.create_portfolio(federal_agency)
+ self.create_suborganizations(portfolio, federal_agency)
+ if parse_domains or both:
+ self.handle_portfolio_domains(portfolio, federal_agency)
if parse_requests or both:
self.handle_portfolio_requests(portfolio, federal_agency)
@@ -233,7 +334,6 @@ class Command(BaseCommand):
federal_agency=federal_agency, organization_name__isnull=False
)
org_names = set(valid_agencies.values_list("organization_name", flat=True))
-
if not org_names:
message = (
"Could not add any suborganizations."
@@ -352,3 +452,141 @@ class Command(BaseCommand):
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains."
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
+
+ def post_process_all_suborganization_fields(self, agencies):
+ """Batch updates suborganization locations from domain and request data.
+
+ Args:
+ agencies: List of FederalAgency objects to process
+
+ Returns:
+ int: Number of suborganizations updated
+
+ Priority for location data:
+ 1. Domain information
+ 2. Domain request suborganization fields
+ 3. Domain request standard fields
+ """
+ # Common filter between domaininformation / domain request.
+ # Filter by only the agencies we've updated thus far.
+ # Then, only process records without null portfolio, org name, or suborg name.
+ base_filter = Q(
+ federal_agency__in=agencies,
+ portfolio__isnull=False,
+ organization_name__isnull=False,
+ sub_organization__isnull=False,
+ ) & ~Q(organization_name__iexact=F("portfolio__organization_name"))
+
+ # First: Remove null city / state_territory values on domain info / domain requests.
+ # We want to add city data if there is data to add to begin with!
+ domains = DomainInformation.objects.filter(
+ base_filter,
+ Q(city__isnull=False, state_territory__isnull=False),
+ )
+ requests = DomainRequest.objects.filter(
+ base_filter,
+ (
+ Q(city__isnull=False, state_territory__isnull=False)
+ | Q(suborganization_city__isnull=False, suborganization_state_territory__isnull=False)
+ ),
+ )
+
+ # Second: Group domains and requests by normalized organization name.
+ # This means that later down the line we have to account for "duplicate" org names.
+ domains_dict = {}
+ requests_dict = {}
+ for domain in domains:
+ normalized_name = normalize_string(domain.organization_name)
+ domains_dict.setdefault(normalized_name, []).append(domain)
+
+ for request in requests:
+ normalized_name = normalize_string(request.organization_name)
+ requests_dict.setdefault(normalized_name, []).append(request)
+
+ # Third: Get suborganizations to update
+ suborgs_to_edit = Suborganization.objects.filter(
+ Q(id__in=domains.values_list("sub_organization", flat=True))
+ | Q(id__in=requests.values_list("sub_organization", flat=True))
+ )
+
+ # Fourth: Process each suborg to add city / state territory info
+ for suborg in suborgs_to_edit:
+ self.post_process_suborganization_fields(suborg, domains_dict, requests_dict)
+
+ # Fifth: Perform a bulk update
+ return Suborganization.objects.bulk_update(suborgs_to_edit, ["city", "state_territory"])
+
+ def post_process_suborganization_fields(self, suborg, domains_dict, requests_dict):
+ """Updates a single suborganization's location data if valid.
+
+ Args:
+ suborg: Suborganization to update
+ domains_dict: Dict of domain info records grouped by org name
+ requests_dict: Dict of domain requests grouped by org name
+
+ Priority matches parent method. Updates are skipped if location data conflicts
+ between multiple records of the same type.
+ """
+ normalized_suborg_name = normalize_string(suborg.name)
+ domains = domains_dict.get(normalized_suborg_name, [])
+ requests = requests_dict.get(normalized_suborg_name, [])
+
+ # Try to get matching domain info
+ domain = None
+ if domains:
+ reference = domains[0]
+ use_location_for_domain = all(
+ d.city == reference.city and d.state_territory == reference.state_territory for d in domains
+ )
+ if use_location_for_domain:
+ domain = reference
+
+ # Try to get matching request info
+ # Uses consensus: if all city / state_territory info matches, then we can assume the data is "good".
+ # If not, take the safe route and just skip updating this particular record.
+ request = None
+ use_suborg_location_for_request = True
+ use_location_for_request = True
+ if requests:
+ reference = requests[0]
+ use_suborg_location_for_request = all(
+ r.suborganization_city
+ and r.suborganization_state_territory
+ and r.suborganization_city == reference.suborganization_city
+ and r.suborganization_state_territory == reference.suborganization_state_territory
+ for r in requests
+ )
+ use_location_for_request = all(
+ r.city
+ and r.state_territory
+ and r.city == reference.city
+ and r.state_territory == reference.state_territory
+ for r in requests
+ )
+ if use_suborg_location_for_request or use_location_for_request:
+ request = reference
+
+ if not domain and not request:
+ message = f"Skipping adding city / state_territory information to suborg: {suborg}. Bad data."
+ TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message)
+ return
+
+ # PRIORITY:
+ # 1. Domain info
+ # 2. Domain request requested suborg fields
+ # 3. Domain request normal fields
+ if domain:
+ suborg.city = normalize_string(domain.city, lowercase=False)
+ suborg.state_territory = domain.state_territory
+ elif request and use_suborg_location_for_request:
+ suborg.city = normalize_string(request.suborganization_city, lowercase=False)
+ suborg.state_territory = request.suborganization_state_territory
+ elif request and use_location_for_request:
+ suborg.city = normalize_string(request.city, lowercase=False)
+ suborg.state_territory = request.state_territory
+
+ message = (
+ f"Added city/state_territory to suborg: {suborg}. "
+ f"city - {suborg.city}, state - {suborg.state_territory}"
+ )
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
From 4bac837dbcc36292a65c7c536ce74b4587e738b8 Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Tue, 28 Jan 2025 15:40:32 -0600
Subject: [PATCH 06/98] fix tests for portfolio script
---
.../commands/create_federal_portfolio.py | 67 +++++++-------
.../tests/test_management_scripts.py | 90 ++++++++++++++++++-
2 files changed, 126 insertions(+), 31 deletions(-)
diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py
index c3e78874e..095d6d9f5 100644
--- a/src/registrar/management/commands/create_federal_portfolio.py
+++ b/src/registrar/management/commands/create_federal_portfolio.py
@@ -6,12 +6,15 @@ 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__)
@@ -114,7 +117,9 @@ class Command(BaseCommand):
portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
portfolios.append(portfolio)
+ logger.debug(f"add_managers: {add_managers}")
if add_managers:
+ logger.debug("Adding managers to portfolio")
self.add_managers_to_portfolio(portfolio)
except Exception as exec:
self.failed_portfolios.add(federal_agency)
@@ -154,11 +159,11 @@ class Command(BaseCommand):
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("Adding managers for portfolio '{portfolio}'")
+ logger.info(f"Adding managers for portfolio {portfolio}")
# Fetch all domains associated with the portfolio
- domains = portfolio.get_domains()
-
+ domains = Domain.objects.filter(domain_info__portfolio=portfolio)
+ logger.debug(f"domains: {domains}")
domain_managers = set()
# Fetch all users with manager roles for the domains
@@ -166,51 +171,53 @@ class Command(BaseCommand):
domain__in=domains,
role=UserDomainRole.Roles.MANAGER
).values_list('user', flat=True)
-
domain_managers.update(managers)
- # Fetch PortfolioInvitations with manager roles for the portfolio
- manager_invitations = PortfolioInvitation.objects.filter(
- portfolio=portfolio,
- roles__contains=[UserPortfolioPermission.RoleChoices.MANAGER]
- ).values_list('email', flat=True)
+ invited_managers = set()
- # Fetch user IDs for existing users with emails in manager_invitations
- invited_users = User.objects.filter(email__in=manager_invitations)
- invited_user_ids = invited_users.values_list('id', flat=True)
- domain_managers.update(invited_user_ids)
+ # Get the emails of invited managers
+ for domain in domains:
+ domain_invitations = DomainInvitation.objects.filter(domain=domain, status=DomainInvitation.DomainInvitationStatus.INVITED).values_list('email', flat=True)
+ invited_managers.update(domain_invitations)
- for user_id in domain_managers:
+ logger.debug(f"invited_managers: {invited_managers}")
+ for manager in domain_managers:
try:
- user = User.objects.get(id=user_id)
+ # manager is a user id
+ user = User.objects.get(id=manager)
_, created = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio,
user=user,
- defaults={"role": UserPortfolioPermission.RoleChoices.MANAGER},
+ defaults={"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]},
)
if created:
- logger.info("Added manager '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Added manager '{user}' to portfolio '{portfolio}'")
else:
- logger.info("Manager '{user}' already exists in portfolio '{portfolio}'")
+ logger.info(f"Manager '{user}' already exists in portfolio '{portfolio}'")
except User.DoesNotExist:
- self.create_portfolio_invitation(portfolio, user)
+ logger.debug(f"User '{user}' does not exist")
+
+ for manager in invited_managers:
+ self.create_portfolio_invitation(portfolio, manager)
- def create_portfolio_invitation(self, portfolio: Portfolio, user: User):
+ def create_portfolio_invitation(self, portfolio: Portfolio, email: str):
"""
Create a portfolio invitation for the given user.
If the user already has a portfolio invitation, retreive their invitation and create a portfolio permission.
"""
try:
- user = User.objects.get(email=user.email)
+ logger.debug(f"Creating portfolio invitation for user '{email}'")
+ user = User.objects.get(email=email)
+ logger.debug(f"user: {user}")
_, created = PortfolioInvitation.objects.get_or_create(
portfolio=portfolio,
user=user,
- defaults={"status": PortfolioInvitation.PortfolioInvitationStatus.PENDING},
+ defaults={"status": PortfolioInvitation.PortfolioInvitationStatus.INVITED},
)
if created:
- logger.info("Created portfolio invitation for '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Created portfolio invitation for '{user}' to portfolio '{portfolio}'")
else:
- logger.info("Retrieved existing portfolio invitation for '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Retrieved existing portfolio invitation for '{user}' to portfolio '{portfolio}'")
# Assign portfolio permissions
_, created = UserPortfolioPermission.objects.get_or_create(
@@ -219,16 +226,16 @@ class Command(BaseCommand):
defaults={"role": UserPortfolioPermission.RoleChoices.MANAGER},
)
if created:
- logger.info("Created portfolio permission for '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Created portfolio permission for '{user}' to portfolio '{portfolio}'")
else:
- logger.info("Retrieved existing portfolio permission for '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Retrieved existing portfolio permission for '{user}' to portfolio '{portfolio}'")
except User.DoesNotExist:
- PortfolioInvitation.objects.create(
+ PortfolioInvitation.objects.get_or_create(
portfolio=portfolio,
- user=user,
- status=PortfolioInvitation.PortfolioInvitationStatus.PENDING,
+ email=email,
+ defaults={"status": PortfolioInvitation.PortfolioInvitationStatus.INVITED},
)
- logger.info("Created portfolio invitation for '{user}' to portfolio '{portfolio}'")
+ logger.info(f"Created portfolio invitation for '{email}' to portfolio '{portfolio}'")
def post_process_started_domain_requests(self, agencies, portfolios):
"""
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index 8ecb7cbea..b2b217044 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -3,7 +3,10 @@ import boto3_mocking # type: ignore
from datetime import date, datetime, time
from django.core.management import call_command
from django.test import TestCase, override_settings
+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
@@ -1456,37 +1459,46 @@ 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
)
+
+ self.portfolio = Portfolio.objects.create(organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user)
+
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
self.domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
+ portfolio=self.portfolio,
user=self.user,
)
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",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
+ portfolio=self.portfolio,
user=self.user,
organization_name="Test Federal Agency",
)
self.domain_request_2.approve()
self.domain_info_2 = DomainInformation.objects.filter(domain_request=self.domain_request_2).get()
+ self.domain_2 = Domain.objects.get(name="icecreamforigorville.gov")
self.domain_request_3 = completed_domain_request(
name="exec_1.gov",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.executive_agency_1,
+ portfolio=self.portfolio,
user=self.user,
organization_name="Executive Agency 1",
)
self.domain_request_3.approve()
self.domain_info_3 = self.domain_request_3.DomainRequest_info
+ self.domain = Domain.objects.get(name="exec_1.gov")
self.domain_request_4 = completed_domain_request(
name="exec_2.gov",
@@ -1494,10 +1506,12 @@ class TestCreateFederalPortfolio(TestCase):
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.executive_agency_2,
user=self.user,
+ portfolio=self.portfolio,
organization_name="Executive Agency 2",
)
self.domain_request_4.approve()
self.domain_info_4 = self.domain_request_4.DomainRequest_info
+ self.domain_4 = Domain.objects.get(name="exec_2.gov")
def tearDown(self):
DomainInformation.objects.all().delete()
@@ -1508,7 +1522,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",
@@ -1844,3 +1857,78 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency)
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.portfolio.organization_name, parse_requests=True, add_managers=True)
+
+ # Check that the users have been added as portfolio managers
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
+ print(UserPortfolioPermission.objects.all())
+
+ # 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
+ )
+
+ # Ensure no existing PortfolioInvitation for the invited email
+ self.assertFalse(PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio).exists())
+
+ # Run the management command
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True)
+
+ # 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)
+ duplicated_invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
+ self.assertEqual(
+ duplicated_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)
+
+ # 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)
\ No newline at end of file
From 691220698deb3744f4754edb1c133e07743a8db6 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Wed, 29 Jan 2025 11:59:21 -0800
Subject: [PATCH 07/98] Email domain managers after domain manager is removed
---
.../domain_manager_deleted_notification.txt | 29 ++++++++++++++
...n_manager_deleted_notification_subject.txt | 1 +
src/registrar/views/domain.py | 39 +++++++++++++++++++
3 files changed, 69 insertions(+)
create mode 100644 src/registrar/templates/emails/domain_manager_deleted_notification.txt
create mode 100644 src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
new file mode 100644
index 000000000..542087057
--- /dev/null
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
@@ -0,0 +1,29 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
+
+A domain manager was was removed from {{ domain.name }}.
+REMOVED BY: {{ removed_by.email }}
+REMOVED ON: {{ date }}
+MANAGER REMOVED: {{ manager_removed.email }}
+
+
+----------------------------------------------------------------
+
+
+WHY DID YOU RECEIVE THIS EMAIL?
+You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain.
+If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
+
+THANK YOU
+.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
+
+
+----------------------------------------------------------------
+
+The .gov team
+Contact us:
+Learn about .gov
+
+The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
+(CISA)
+{% endautoescape %}
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt
new file mode 100644
index 000000000..c84a20f18
--- /dev/null
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification_subject.txt
@@ -0,0 +1 @@
+A domain manager was removed from {{ domain.name }}
\ No newline at end of file
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index f82d7005d..6bbeca5f8 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1335,10 +1335,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Is the user deleting themselves? If so, display a different message
delete_self = self.request.user == self.object.user
+
+ # Email all domain managers that domain manager has been removed
+ domain = self.object.domain
+
+ context = {
+ "domain": domain,
+ "removed_by": self.request.user,
+ "manager_removed": self.object.user,
+ "date": date.today(),
+ "changes": "Domain Manager"
+ }
+ self.email_domain_managers(
+ domain,
+ "emails/domain_manager_deleted_notification.txt",
+ "emails/domain_manager_deleted_notification_subject.txt",
+ context,
+ )
# Add a success message
messages.success(self.request, self.get_success_message(delete_self))
return redirect(self.get_success_url())
+
+ def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
+ manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
+ "user", flat=True
+ )
+ emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
+
+ for email in emails:
+ try:
+ send_templated_email(
+ template,
+ subject_template,
+ to_address=email, # type: ignore
+ context=context,
+ )
+ except EmailSendingError:
+ logger.warning(
+ "Could not sent notification email to %s for domain %s",
+ email,
+ domain.name,
+ exc_info=True,
+ )
def post(self, request, *args, **kwargs):
"""Custom post implementation to redirect to home in the event that the user deletes themselves"""
From 59bda0b3cca0157027cee875d177750579a74ea6 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Wed, 29 Jan 2025 15:10:06 -0800
Subject: [PATCH 08/98] Initialize email tests
---
src/registrar/admin.py | 3 +--
src/registrar/tests/test_views_domain.py | 8 ++++++++
src/registrar/views/domain.py | 10 +++++-----
3 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index bb7c26fa8..f1b419d49 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1375,7 +1375,7 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Fixes a bug where non-superusers are redirected to the main page
def delete_view(self, request, object_id, extra_context=None):
"""Custom delete_view implementation that specifies redirect behaviour"""
- self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
+ self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
response = super().delete_view(request, object_id, extra_context)
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
@@ -1517,7 +1517,6 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
extra_context["tabtitle"] = "Domain invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
-
def delete_view(self, request, object_id, extra_context=None):
"""
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 45758e502..4ebebd001 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -980,6 +980,14 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "Failed to send email.")
+ @boto3_mocking.patching
+ @less_console_noise_decorator
+ def test_domain_remove_manager(self):
+ """Removing a domain manager sends notification email to other domain managers."""
+ print("self:", self)
+ response = self.client.get(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}))
+ print("response: ", response)
+
@less_console_noise_decorator
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_invitation_created(self, mock_send_domain_email):
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 6bbeca5f8..0ce0eb4d3 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -1335,16 +1335,16 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Is the user deleting themselves? If so, display a different message
delete_self = self.request.user == self.object.user
-
+
# Email all domain managers that domain manager has been removed
domain = self.object.domain
context = {
"domain": domain,
"removed_by": self.request.user,
- "manager_removed": self.object.user,
+ "manager_removed": self.object.user,
"date": date.today(),
- "changes": "Domain Manager"
+ "changes": "Domain Manager",
}
self.email_domain_managers(
domain,
@@ -1356,7 +1356,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Add a success message
messages.success(self.request, self.get_success_message(delete_self))
return redirect(self.get_success_url())
-
+
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
"user", flat=True
@@ -1368,7 +1368,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
send_templated_email(
template,
subject_template,
- to_address=email, # type: ignore
+ to_address=email,
context=context,
)
except EmailSendingError:
From 0a276748388ef948574adc6eecf34862715e8124 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Wed, 29 Jan 2025 15:37:44 -0800
Subject: [PATCH 09/98] Add tests for domain manager removal email
---
src/registrar/tests/test_views_domain.py | 21 +++++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index d45e09964..55417a44c 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -983,11 +983,24 @@ class TestDomainManagers(TestDomainOverview):
@boto3_mocking.patching
@less_console_noise_decorator
- def test_domain_remove_manager(self):
+ @patch("registrar.views.domain.send_templated_email")
+ def test_domain_remove_manager(self, mock_send_templated_email):
"""Removing a domain manager sends notification email to other domain managers."""
- print("self:", self)
- response = self.client.get(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}))
- print("response: ", response)
+ self.manager, _ = User.objects.get_or_create(
+ email="mayor@igorville.com", first_name="Hello", last_name="World"
+ )
+ self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(
+ user=self.manager, domain=self.domain
+ )
+ response = self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
+
+ # Verify that the notification emails were sent to domain manager
+ mock_send_templated_email.assert_called_once_with(
+ "emails/domain_manager_deleted_notification.txt",
+ "emails/domain_manager_deleted_notification_subject.txt",
+ to_address="info@example.com",
+ context=ANY,
+ )
@less_console_noise_decorator
@patch("registrar.views.domain.send_domain_invitation_email")
From b490b13a81d98c45cd861163ce9ae4f04c1ecdf7 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Wed, 29 Jan 2025 15:42:56 -0800
Subject: [PATCH 10/98] Fix lint errors
---
src/registrar/tests/test_views_domain.py | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 55417a44c..5397a702d 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -986,13 +986,11 @@ class TestDomainManagers(TestDomainOverview):
@patch("registrar.views.domain.send_templated_email")
def test_domain_remove_manager(self, mock_send_templated_email):
"""Removing a domain manager sends notification email to other domain managers."""
- self.manager, _ = User.objects.get_or_create(
- email="mayor@igorville.com", first_name="Hello", last_name="World"
+ self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
+ self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
+ response = self.client.post(
+ reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id})
)
- self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(
- user=self.manager, domain=self.domain
- )
- response = self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
# Verify that the notification emails were sent to domain manager
mock_send_templated_email.assert_called_once_with(
From 95adc264d28c59a8a572a2f733bbad476f8e6ed2 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:13:49 -0800
Subject: [PATCH 11/98] Fix lint errors
---
src/registrar/tests/test_views_domain.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 5397a702d..55e238182 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -988,7 +988,7 @@ class TestDomainManagers(TestDomainOverview):
"""Removing a domain manager sends notification email to other domain managers."""
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
- response = self.client.post(
+ response = self.client.post( # noqa: F841
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id})
)
From 4866e4ab12819d2355da663ac4c3b5f532323210 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:14:37 -0800
Subject: [PATCH 12/98] Remove unused variable
---
src/registrar/tests/test_views_domain.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 55e238182..e465afacb 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -988,7 +988,7 @@ class TestDomainManagers(TestDomainOverview):
"""Removing a domain manager sends notification email to other domain managers."""
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
- response = self.client.post( # noqa: F841
+ self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id})
)
From eef77fd6a9940d0d83cfeb4a44a6bdbe8c9b9f38 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:15:07 -0800
Subject: [PATCH 13/98] Remove unused variables
---
src/registrar/tests/test_models_requests.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py
index c3528311d..b19b245e5 100644
--- a/src/registrar/tests/test_models_requests.py
+++ b/src/registrar/tests/test_models_requests.py
@@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
federal_agency=fed_agency,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
)
- user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
+ UserPortfolioPermission.objects.create(
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Adds cc'ed email in this test's allow list
From 64930edea054bb1c5e9710ae58695fa46e8de514 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:23:36 -0800
Subject: [PATCH 14/98] Fix linting errors
---
src/registrar/tests/test_views_domain.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index e465afacb..ea39d92f6 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -988,9 +988,7 @@ class TestDomainManagers(TestDomainOverview):
"""Removing a domain manager sends notification email to other domain managers."""
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
- self.client.post(
- reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id})
- )
+ self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
# Verify that the notification emails were sent to domain manager
mock_send_templated_email.assert_called_once_with(
From d069e3537bbff60c29ac5449097d4d13b575dda9 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 14:53:46 -0800
Subject: [PATCH 15/98] Add content edits to deleted manager email
---
.../templates/emails/domain_manager_deleted_notification.txt | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
index 542087057..b16b74dec 100644
--- a/src/registrar/templates/emails/domain_manager_deleted_notification.txt
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
@@ -1,15 +1,13 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
-A domain manager was was removed from {{ domain.name }}.
+A domain manager was removed from {{ domain.name }}.
REMOVED BY: {{ removed_by.email }}
REMOVED ON: {{ date }}
MANAGER REMOVED: {{ manager_removed.email }}
-
----------------------------------------------------------------
-
WHY DID YOU RECEIVE THIS EMAIL?
You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain.
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
@@ -17,7 +15,6 @@ If you have questions or concerns, reach out to the person who removed the domai
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
-
----------------------------------------------------------------
The .gov team
From 0834a9701ee6756e46555387718d32744fef9299 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Thu, 30 Jan 2025 15:33:55 -0800
Subject: [PATCH 16/98] Update link to message in domain invitation delete
confirmation
---
.../django/admin/domain_invitation_delete_confirmation.html | 2 +-
.../admin/domain_invitation_delete_selected_confirmation.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
index abd0721de..215bf5ada 100644
--- a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
+++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html
@@ -7,7 +7,7 @@
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
- User Domain Roles table
+ User Domain Roles table
if you want to remove the user from a domain.
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
- User Domain Roles table
+ User Domain Roles table
if you want to remove the user from a domain.
From 117bfa9b169a07876354323f1b6b3ba1c5b0ee97 Mon Sep 17 00:00:00 2001
From: Erin Song <121973038+erinysong@users.noreply.github.com>
Date: Fri, 31 Jan 2025 14:07:16 -0800
Subject: [PATCH 17/98] Add space to domain manager deleted email template
---
.../templates/emails/domain_manager_deleted_notification.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/registrar/templates/emails/domain_manager_deleted_notification.txt b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
index b16b74dec..af0b92a8c 100644
--- a/src/registrar/templates/emails/domain_manager_deleted_notification.txt
+++ b/src/registrar/templates/emails/domain_manager_deleted_notification.txt
@@ -1,7 +1,7 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
-A domain manager was removed from {{ domain.name }}.
+A domain manager was removed from {{ domain.name }}.
REMOVED BY: {{ removed_by.email }}
REMOVED ON: {{ date }}
MANAGER REMOVED: {{ manager_removed.email }}
From b28ddf296ecf6985e3145d8dfc529d12bd12a4e9 Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Sun, 2 Feb 2025 17:54:53 -0600
Subject: [PATCH 18/98] fix tests
---
.../commands/create_federal_portfolio.py | 54 +++++++++++++++++-
.../tests/test_management_scripts.py | 55 +++++++++++++------
2 files changed, 88 insertions(+), 21 deletions(-)
diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py
index 095d6d9f5..7a90deed1 100644
--- a/src/registrar/management/commands/create_federal_portfolio.py
+++ b/src/registrar/management/commands/create_federal_portfolio.py
@@ -30,6 +30,8 @@ class Command(BaseCommand):
self.failed_portfolios = set()
self.added_managers = set()
self.added_invitations = set()
+ self.failed_managers = set()
+ self.failed_invitations = set()
def add_arguments(self, parser):
"""Add command line arguments to create federal portfolios.
@@ -114,9 +116,15 @@ class Command(BaseCommand):
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
try:
# C901 'Command.handle' is too complex (12)
- portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
- portfolios.append(portfolio)
-
+ # if the portfolio is already created, we don't want to create it again
+ portfolio = Portfolio.objects.filter(organization_name=federal_agency.agency)
+ if portfolio.exists():
+ portfolio = portfolio.first()
+ message = f"Portfolio '{federal_agency.agency}' already exists. Skipping create."
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
+ else:
+ portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
+ portfolios.append(portfolio)
logger.debug(f"add_managers: {add_managers}")
if add_managers:
logger.debug("Adding managers to portfolio")
@@ -141,6 +149,25 @@ 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.failed_invitations,
+ [], # can't skip invitations, can only add or fail
+ log_header="----- INVITATIONS ADDED -----",
+ debug=False,
+ 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:
@@ -190,11 +217,13 @@ class Command(BaseCommand):
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 manager in invited_managers:
@@ -229,13 +258,20 @@ class Command(BaseCommand):
logger.info(f"Created portfolio permission for '{user}' to portfolio '{portfolio}'")
else:
logger.info(f"Retrieved existing portfolio permission for '{user}' to portfolio '{portfolio}'")
+
+ self.added_invitations.add(user)
except User.DoesNotExist:
PortfolioInvitation.objects.get_or_create(
portfolio=portfolio,
email=email,
defaults={"status": PortfolioInvitation.PortfolioInvitationStatus.INVITED},
)
+ self.added_invitations.add(email)
logger.info(f"Created portfolio invitation for '{email}' to portfolio '{portfolio}'")
+ except Exception as exc:
+ self.failed_invitations.add(email)
+ logger.error(exc, exc_info=True)
+ logger.error(f"Failed to create portfolio invitation for '{email}' to portfolio '{portfolio}'")
def post_process_started_domain_requests(self, agencies, portfolios):
"""
@@ -250,13 +286,20 @@ 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.
+ message = f"agencies: {agencies}"
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
+
domain_requests_to_update = DomainRequest.objects.filter(
federal_agency__in=agencies,
federal_agency__agency__isnull=False,
status=DomainRequest.DomainRequestStatus.STARTED,
organization_name__isnull=False,
)
+ message = (f"domain_requests_to_update: {domain_requests_to_update}")
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio}
+ message = f"portfolio_set: {portfolio_set}"
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
# Update the request, assuming the given agency name matches the portfolio name
updated_requests = []
@@ -265,6 +308,9 @@ class Command(BaseCommand):
if agency_name in portfolio_set:
req.federal_agency = None
updated_requests.append(req)
+
+ message = f"updated_requests: {updated_requests}"
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
# Execute the update and Log the results
if TerminalHelper.prompt_for_execution(
@@ -275,6 +321,8 @@ class Command(BaseCommand):
),
prompt_title="Do you wish to commit this update to the database?",
):
+ message = f"prompted for execution"
+ TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"])
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.")
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index b2b217044..515c32782 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -1460,14 +1460,11 @@ class TestCreateFederalPortfolio(TestCase):
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
)
- self.portfolio = Portfolio.objects.create(organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user)
-
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
self.domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
- portfolio=self.portfolio,
user=self.user,
)
self.domain_request.approve()
@@ -1479,26 +1476,22 @@ class TestCreateFederalPortfolio(TestCase):
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
- portfolio=self.portfolio,
user=self.user,
organization_name="Test Federal Agency",
)
self.domain_request_2.approve()
self.domain_info_2 = DomainInformation.objects.filter(domain_request=self.domain_request_2).get()
- self.domain_2 = Domain.objects.get(name="icecreamforigorville.gov")
self.domain_request_3 = completed_domain_request(
name="exec_1.gov",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.executive_agency_1,
- portfolio=self.portfolio,
user=self.user,
organization_name="Executive Agency 1",
)
self.domain_request_3.approve()
self.domain_info_3 = self.domain_request_3.DomainRequest_info
- self.domain = Domain.objects.get(name="exec_1.gov")
self.domain_request_4 = completed_domain_request(
name="exec_2.gov",
@@ -1506,12 +1499,10 @@ class TestCreateFederalPortfolio(TestCase):
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.executive_agency_2,
user=self.user,
- portfolio=self.portfolio,
organization_name="Executive Agency 2",
)
self.domain_request_4.approve()
self.domain_info_4 = self.domain_request_4.DomainRequest_info
- self.domain_4 = Domain.objects.get(name="exec_2.gov")
def tearDown(self):
DomainInformation.objects.all().delete()
@@ -1529,7 +1520,7 @@ class TestCreateFederalPortfolio(TestCase):
):
call_command("create_federal_portfolio", **kwargs)
- @less_console_noise_decorator
+ # @less_console_noise_decorator
def test_post_process_started_domain_requests_existing_portfolio(self):
"""Ensures that federal agency is cleared when agency name matches portfolio name.
As the name implies, this implicitly tests the "post_process_started_domain_requests" function.
@@ -1869,7 +1860,10 @@ class TestCreateFederalPortfolio(TestCase):
UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
# Run the management command
- self.run_create_federal_portfolio(agency_name=self.portfolio.organization_name, parse_requests=True, add_managers=True)
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_requests=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])
@@ -1890,13 +1884,13 @@ class TestCreateFederalPortfolio(TestCase):
email="manager1@example.com",
status=DomainInvitation.DomainInvitationStatus.INVITED
)
-
- # Ensure no existing PortfolioInvitation for the invited email
- self.assertFalse(PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio).exists())
-
+
# Run the management command
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_requests=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)
@@ -1909,9 +1903,9 @@ class TestCreateFederalPortfolio(TestCase):
# Verify that no duplicate invitations are created
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True)
- duplicated_invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
+ invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
self.assertEqual(
- duplicated_invitations.count(),
+ invitations.count(),
1,
"Duplicate PortfolioInvitation should not be created for the same email and portfolio."
)
@@ -1922,6 +1916,9 @@ class TestCreateFederalPortfolio(TestCase):
# 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])
@@ -1931,4 +1928,26 @@ class TestCreateFederalPortfolio(TestCase):
# Ensure that the manager is not duplicated
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user=manager)
- self.assertEqual(permissions.count(), 1)
\ No newline at end of file
+ self.assertEqual(permissions.count(), 1)
+
+ @less_console_noise_decorator
+ def test_add_managers_portfolio_already_exists(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)
+
+ # 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_requests=True, add_managers=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)
From bdd57c8cfda0abbaa7f3e3a0e8e1b9b8460ceb93 Mon Sep 17 00:00:00 2001
From: matthewswspence
Date: Mon, 3 Feb 2025 11:20:09 -0600
Subject: [PATCH 19/98] linter and final test fixes
---
.../commands/create_federal_portfolio.py | 42 ++++---
.../tests/test_management_scripts.py | 118 +++++++++++-------
2 files changed, 94 insertions(+), 66 deletions(-)
diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py
index 2f2cc2625..71004dc49 100644
--- a/src/registrar/management/commands/create_federal_portfolio.py
+++ b/src/registrar/management/commands/create_federal_portfolio.py
@@ -89,7 +89,7 @@ class Command(BaseCommand):
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")
@@ -116,7 +116,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}'..."
@@ -127,6 +126,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)
@@ -192,31 +193,32 @@ class Command(BaseCommand):
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)
logger.debug(f"domains: {domains}")
- domain_managers = set()
+ domain_managers: set[int] = set()
# Fetch all users with manager roles for the domains
- managers = UserDomainRole.objects.filter(
- domain__in=domains,
- role=UserDomainRole.Roles.MANAGER
- ).values_list('user', flat=True)
+ managers = UserDomainRole.objects.filter(domain__in=domains, role=UserDomainRole.Roles.MANAGER).values_list(
+ "user", flat=True
+ )
domain_managers.update(managers)
- invited_managers = set()
+ invited_managers: set[str] = set()
# Get the emails of invited managers
for domain in domains:
- domain_invitations = DomainInvitation.objects.filter(domain=domain, status=DomainInvitation.DomainInvitationStatus.INVITED).values_list('email', flat=True)
+ domain_invitations = DomainInvitation.objects.filter(
+ domain=domain, status=DomainInvitation.DomainInvitationStatus.INVITED
+ ).values_list("email", flat=True)
invited_managers.update(domain_invitations)
logger.debug(f"invited_managers: {invited_managers}")
- for manager in domain_managers:
+ for id in domain_managers:
try:
# manager is a user id
- user = User.objects.get(id=manager)
+ user = User.objects.get(id=id)
_, created = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio,
user=user,
@@ -230,9 +232,9 @@ class Command(BaseCommand):
except User.DoesNotExist:
self.failed_managers.add(user)
logger.debug(f"User '{user}' does not exist")
-
- for manager in invited_managers:
- self.create_portfolio_invitation(portfolio, manager)
+
+ for email in invited_managers:
+ self.create_portfolio_invitation(portfolio, email)
def create_portfolio_invitation(self, portfolio: Portfolio, email: str):
"""
@@ -252,18 +254,18 @@ class Command(BaseCommand):
logger.info(f"Created portfolio invitation for '{user}' to portfolio '{portfolio}'")
else:
logger.info(f"Retrieved existing portfolio invitation for '{user}' to portfolio '{portfolio}'")
-
+
# Assign portfolio permissions
_, created = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio,
user=user,
- defaults={"role": UserPortfolioPermission.RoleChoices.MANAGER},
+ defaults={"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER},
)
if created:
logger.info(f"Created portfolio permission for '{user}' to portfolio '{portfolio}'")
else:
logger.info(f"Retrieved existing portfolio permission for '{user}' to portfolio '{portfolio}'")
-
+
self.added_invitations.add(user)
except User.DoesNotExist:
PortfolioInvitation.objects.get_or_create(
@@ -316,7 +318,7 @@ class Command(BaseCommand):
if agency_name in portfolio_set:
req.federal_agency = None
updated_requests.append(req)
-
+
message = f"updated_requests: {updated_requests}"
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
@@ -329,7 +331,7 @@ class Command(BaseCommand):
),
prompt_title="Do you wish to commit this update to the database?",
):
- message = f"prompted for execution"
+ message = "prompted for execution"
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"])
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.")
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index 2ca4e4ffe..ac507cdf2 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -3,17 +3,11 @@ import boto3_mocking # type: ignore
from datetime import date, datetime, time
from django.core.management import call_command
from django.test import TestCase, override_settings
-<<<<<<< HEAD
-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.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
->>>>>>> origin/main
+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
@@ -1472,7 +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,
@@ -1533,7 +1527,7 @@ class TestCreateFederalPortfolio(TestCase):
):
call_command("create_federal_portfolio", **kwargs)
- # @less_console_noise_decorator
+ @less_console_noise_decorator
def test_post_process_started_domain_requests_existing_portfolio(self):
"""Ensures that federal agency is cleared when agency name matches portfolio name.
As the name implies, this implicitly tests the "post_process_started_domain_requests" function.
@@ -1862,7 +1856,6 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.notes, "Old notes")
self.assertEqual(existing_portfolio.creator, self.user)
-<<<<<<< HEAD
@less_console_noise_decorator
def test_add_managers_from_domains(self):
"""Test that all domain managers are added as portfolio managers."""
@@ -1872,56 +1865,55 @@ class TestCreateFederalPortfolio(TestCase):
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_requests=True, add_managers=True)
+ 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])
- print(UserPortfolioPermission.objects.all())
- # Check that the users have been added as portfolio managers
+ # 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
- )
-
+ 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_requests=True, add_managers=True)
-
+ 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."
+ "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)
+ 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."
+ "Duplicate PortfolioInvitation should not be created for the same email and portfolio.",
)
@less_console_noise_decorator
@@ -1931,41 +1923,76 @@ class TestCreateFederalPortfolio(TestCase):
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)
-
+ # 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])
-
+ 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)
-
+ 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_portfolio_already_exists(self):
+ 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)
-
+
+ # 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=self.domain, role=UserDomainRole.Roles.MANAGER)
- UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+ 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)
-
+ 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."""
@@ -2466,4 +2493,3 @@ class TestRemovePortfolios(TestCase):
# Check that the portfolio was deleted
self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists())
->>>>>>> origin/main
From dca2f635a287fb845ea1becfdf9c263f0d2908e4 Mon Sep 17 00:00:00 2001
From: CocoByte
Date: Mon, 3 Feb 2025 13:07:36 -0700
Subject: [PATCH 20/98] Shrink max-width of tables in widescreen. Constrict
Action column to 80px. Add Action column updates to Members table.
---
src/docker-compose.yml | 2 ++
.../src/js/getgov/table-domain-requests.js | 4 +--
.../assets/src/js/getgov/table-domains.js | 2 +-
.../assets/src/js/getgov/table-members.js | 30 +++++++------------
.../assets/src/sass/_theme/_base.scss | 6 +++-
.../templates/includes/members_table.html | 2 +-
6 files changed, 21 insertions(+), 25 deletions(-)
diff --git a/src/docker-compose.yml b/src/docker-compose.yml
index 5ad6d0ce6..09bf8243e 100644
--- a/src/docker-compose.yml
+++ b/src/docker-compose.yml
@@ -79,6 +79,8 @@ services:
- POSTGRES_DB=app
- POSTGRES_USER=user
- POSTGRES_PASSWORD=feedabee
+ ports:
+ - "5432:5432"
node:
build:
diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js
index f667a96b5..9a78a4551 100644
--- a/src/registrar/assets/src/js/getgov/table-domain-requests.js
+++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js
@@ -116,8 +116,8 @@ export class DomainRequestsTable extends BaseTable {