diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index cdef3dba7..8185922a4 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -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, diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 927af3621..2d2b90a5f 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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) diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js index 92bba4a1f..b82a5574f 100644 --- a/src/registrar/assets/js/get-gov-reports.js +++ b/src/registrar/assets/js/get-gov-reports.js @@ -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') }, ], }; diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 4f75fd2fb..322e94bf0 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -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; diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 904c0fb29..9ca1355c3 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -46,7 +46,6 @@ body { background-color: color('gray-1'); } - .section-outlined { background-color: color('white'); border: 1px solid color('base-lighter'); diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index 4bc8f6715..d753d0ce8 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -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, diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 66011a3c4..2f3d282ea 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -15,13 +15,28 @@ {% else %} {% endif %} {% endblock %} - diff --git a/src/registrar/templates/django/admin/domain_invitation_change_form.html b/src/registrar/templates/django/admin/domain_invitation_change_form.html index 6ce6ed0d1..699760fa8 100644 --- a/src/registrar/templates/django/admin/domain_invitation_change_form.html +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -11,4 +11,4 @@ {{ block.super }} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/includes/member_domains_edit_table.html b/src/registrar/templates/includes/member_domains_edit_table.html index 0b8ff005a..fd63e5aa7 100644 --- a/src/registrar/templates/includes/member_domains_edit_table.html +++ b/src/registrar/templates/includes/member_domains_edit_table.html @@ -1,5 +1,3 @@ -{% load static %} - {% if member %} - + {% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %} + {% include "includes/search.html" %} + {% endwith %} @@ -85,7 +45,7 @@ member domains - Assigned domains + Assigned domains Domains diff --git a/src/registrar/templates/includes/member_domains_table.html b/src/registrar/templates/includes/member_domains_table.html index d7839e485..4e63fdbc3 100644 --- a/src/registrar/templates/includes/member_domains_table.html +++ b/src/registrar/templates/includes/member_domains_table.html @@ -1,5 +1,3 @@ -{% load static %} - {% if member %} -