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/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/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/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js
index f667a96b5..8556b714f 100644
--- a/src/registrar/assets/src/js/getgov/table-domain-requests.js
+++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js
@@ -116,10 +116,10 @@ export class DomainRequestsTable extends BaseTable {
diff --git a/src/registrar/templates/portfolio_base.html b/src/registrar/templates/portfolio_base.html
index 9f43c7251..ec7c0c22b 100644
--- a/src/registrar/templates/portfolio_base.html
+++ b/src/registrar/templates/portfolio_base.html
@@ -9,7 +9,7 @@
{# the entire logged in page goes here #}
-
+
{% block portfolio_content %}{% endblock %}
diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py
index f6807294c..9489c2e0f 100644
--- a/src/registrar/tests/test_admin_domain.py
+++ b/src/registrar/tests/test_admin_domain.py
@@ -12,6 +12,7 @@ from registrar.models import (
Domain,
DomainRequest,
DomainInformation,
+ DomainInvitation,
User,
Host,
Portfolio,
@@ -495,6 +496,107 @@ class TestDomainInformationInline(MockEppLib):
self.assertIn("poopy@gov.gov", domain_managers)
+class TestDomainInvitationAdmin(TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.staffuser = create_user(email="staffdomainmanager@meoward.com", is_staff=True)
+ cls.site = AdminSite()
+ cls.admin = DomainAdmin(model=Domain, admin_site=cls.site)
+ cls.factory = RequestFactory()
+
+ def setUp(self):
+ self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.staffuser)
+ super().setUp()
+
+ def test_successful_cancel_invitation_flow_in_admin(self):
+ """Testing canceling a domain invitation in Django Admin."""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="cancelinvitationflowviaadmin.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain
+ invitation = DomainInvitation.objects.create(
+ email="inviteddomainmanager@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.INVITED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you can see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Cancel invitation")
+
+ # 5. Click the cancel invitation button
+ response = self.client.post(domain_invitation_change_url, {"cancel_invitation": "true"}, follow=True)
+
+ # 6. Make sure we're redirect back to the change view page in /admin
+ self.assertRedirects(response, domain_invitation_change_url)
+
+ # 7. Confirm cancellation confirmation message appears
+ expected_message = f"Invitation for {invitation.email} on {domain.name} is canceled"
+ self.assertContains(response, expected_message)
+
+ def test_no_cancel_invitation_button_in_retrieved_state(self):
+ """Shouldn't be able to see the "Cancel invitation" button if invitation is RETRIEVED state"""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="retrieved.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain and NOT in invited state
+ invitation = DomainInvitation.objects.create(
+ email="retrievedinvitation@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you CANNOT see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Cancel invitation")
+
+ def test_no_cancel_invitation_button_in_canceled_state(self):
+ """Shouldn't be able to see the "Cancel invitation" button if invitation is CANCELED state"""
+
+ # 1. Create a domain and assign staff user role + domain manager
+ domain = Domain.objects.create(name="canceled.gov")
+ UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
+
+ # 2. Invite a domain manager to the above domain and NOT in invited state
+ invitation = DomainInvitation.objects.create(
+ email="canceledinvitation@meoward.com",
+ domain=domain,
+ status=DomainInvitation.DomainInvitationStatus.CANCELED,
+ )
+
+ # 3. Go to the Domain Invitations list in /admin
+ domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
+ response = self.client.get(domain_invitation_list_url)
+ self.assertEqual(response.status_code, 200)
+
+ # 4. Go to the change view of that invitation and make sure you CANNOT see the button
+ domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
+ response = self.client.get(domain_invitation_change_url)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Cancel invitation")
+
+
class TestDomainAdminWithClient(TestCase):
"""Test DomainAdmin class as super user.
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index 17e4736c4..110feea85 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -7,6 +7,7 @@ from registrar.models.domain_group import DomainGroup
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.constants import BranchChoices
from django.utils import timezone
from django.utils.module_loading import import_string
@@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
self.executive_so_2 = SeniorOfficial.objects.create(
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
)
+
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
self.domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
@@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
)
self.domain_request.approve()
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
+ self.domain = Domain.objects.get(name="city.gov")
self.domain_request_2 = completed_domain_request(
name="icecreamforigorville.gov",
@@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
FederalAgency.objects.all().delete()
User.objects.all().delete()
- @less_console_noise_decorator
def run_create_federal_portfolio(self, **kwargs):
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
@@ -1820,12 +1822,12 @@ class TestCreateFederalPortfolio(TestCase):
# We expect a error to be thrown when we dont pass parse requests or domains
with self.assertRaisesRegex(
- CommandError, "You must specify at least one of --parse_requests or --parse_domains."
+ CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
):
self.run_create_federal_portfolio(branch="executive")
with self.assertRaisesRegex(
- CommandError, "You must specify at least one of --parse_requests or --parse_domains."
+ CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
):
self.run_create_federal_portfolio(agency_name="test")
@@ -1865,6 +1867,142 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.creator, self.user)
@less_console_noise_decorator
+ def test_add_managers_from_domains(self):
+ """Test that all domain managers are added as portfolio managers."""
+
+ # Create users and assign them as domain managers
+ manager1 = User.objects.create(username="manager1", email="manager1@example.com")
+ manager2 = User.objects.create(username="manager2", email="manager2@example.com")
+ UserDomainRole.objects.create(user=manager1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+ UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Run the management command
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
+
+ # Check that the portfolio was created
+ self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
+
+ # Check that the users have been added as portfolio managers
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
+
+ # Check that the users have been added as portfolio managers
+ self.assertEqual(permissions.count(), 2)
+ for perm in permissions:
+ self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
+
+ @less_console_noise_decorator
+ def test_add_invited_managers(self):
+ """Test that invited domain managers receive portfolio invitations."""
+
+ # create a domain invitation for the manager
+ _ = DomainInvitation.objects.create(
+ domain=self.domain, email="manager1@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
+ )
+
+ # Run the management command
+ self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
+
+ # Check that the portfolio was created
+ self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
+
+ # Check that a PortfolioInvitation has been created for the invited email
+ invitation = PortfolioInvitation.objects.get(email="manager1@example.com", portfolio=self.portfolio)
+
+ # Verify the status of the invitation remains INVITED
+ self.assertEqual(
+ invitation.status,
+ PortfolioInvitation.PortfolioInvitationStatus.INVITED,
+ "PortfolioInvitation status should remain INVITED for non-existent users.",
+ )
+
+ # Verify that no duplicate invitations are created
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
+ )
+ invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
+ self.assertEqual(
+ invitations.count(),
+ 1,
+ "Duplicate PortfolioInvitation should not be created for the same email and portfolio.",
+ )
+
+ @less_console_noise_decorator
+ def test_no_duplicate_managers_added(self):
+ """Test that duplicate managers are not added multiple times."""
+ # Create a manager
+ manager = User.objects.create(username="manager", email="manager@example.com")
+ UserDomainRole.objects.create(user=manager, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Create a pre-existing portfolio
+ self.portfolio = Portfolio.objects.create(
+ organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
+ )
+
+ # Manually add the manager to the portfolio
+ UserPortfolioPermission.objects.create(
+ portfolio=self.portfolio, user=manager, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
+ )
+
+ # Run the management command
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
+ )
+
+ # Ensure that the manager is not duplicated
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user=manager)
+ self.assertEqual(permissions.count(), 1)
+
+ @less_console_noise_decorator
+ def test_add_managers_skip_existing_portfolios(self):
+ """Test that managers are skipped when the portfolio already exists."""
+
+ # Create a pre-existing portfolio
+ self.portfolio = Portfolio.objects.create(
+ organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
+ )
+
+ domain_request_1 = completed_domain_request(
+ name="domain1.gov",
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ federal_agency=self.federal_agency,
+ user=self.user,
+ portfolio=self.portfolio,
+ )
+ domain_request_1.approve()
+ domain1 = Domain.objects.get(name="domain1.gov")
+
+ domain_request_2 = completed_domain_request(
+ name="domain2.gov",
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ generic_org_type=DomainRequest.OrganizationChoices.CITY,
+ federal_agency=self.federal_agency,
+ user=self.user,
+ portfolio=self.portfolio,
+ )
+ domain_request_2.approve()
+ domain2 = Domain.objects.get(name="domain2.gov")
+
+ # Create users and assign them as domain managers
+ manager1 = User.objects.create(username="manager1", email="manager1@example.com")
+ manager2 = User.objects.create(username="manager2", email="manager2@example.com")
+ UserDomainRole.objects.create(user=manager1, domain=domain1, role=UserDomainRole.Roles.MANAGER)
+ UserDomainRole.objects.create(user=manager2, domain=domain2, role=UserDomainRole.Roles.MANAGER)
+
+ # Run the management command
+ self.run_create_federal_portfolio(
+ agency_name=self.federal_agency.agency,
+ parse_requests=True,
+ add_managers=True,
+ skip_existing_portfolios=True,
+ )
+
+ # Check that managers were added to the portfolio
+ permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
+ self.assertEqual(permissions.count(), 2)
+ for perm in permissions:
+ self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
+
def test_skip_existing_portfolios(self):
"""Tests the skip_existing_portfolios to ensure that it doesn't add
suborgs, domain requests, and domain info."""