From 8f84f33756012e117eab9d3fe7b567d5df9c8adb Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 31 Jul 2024 20:34:34 -0400 Subject: [PATCH 1/6] portfolio invitation model --- src/registrar/admin.py | 74 ++++++++++++++- .../migrations/0114_portfolioinvitation.py | 84 ++++++++++++++++ src/registrar/models/__init__.py | 5 +- src/registrar/models/portfolio_invitation.py | 95 +++++++++++++++++++ src/registrar/models/user.py | 65 ++++++------- .../models/utility/portfolio_helper.py | 33 +++++++ .../templates/admin/model_descriptions.html | 2 + .../portfolio_invitation_description.html | 11 +++ src/registrar/tests/test_admin.py | 73 ++++++++++++++ src/registrar/tests/test_models.py | 65 ++++++++++++- 10 files changed, 465 insertions(+), 42 deletions(-) create mode 100644 src/registrar/migrations/0114_portfolioinvitation.py create mode 100644 src/registrar/models/portfolio_invitation.py create mode 100644 src/registrar/models/utility/portfolio_helper.py create mode 100644 src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 46f6cc68c..5dd0b1852 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -11,6 +11,7 @@ from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models.domain_group import DomainGroup from registrar.models.suborganization import Suborganization +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm): "groups": NoAutocompleteFilteredSelectMultiple("groups", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "portfolio_roles": FilteredSelectMultipleArrayWidget( - "portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices ), "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "portfolio_additional_permissions", is_stacked=False, - choices=User.UserPortfolioPermissionChoices.choices, + choices=UserPortfolioPermissionChoices.choices, ), } @@ -169,6 +170,24 @@ class MyUserAdminForm(UserChangeForm): ) +class PortfolioInvitationAdminForm(UserChangeForm): + """This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + widgets = { + "portfolio_roles": FilteredSelectMultipleArrayWidget( + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + ), + "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( + "portfolio_additional_permissions", + is_stacked=False, + choices=UserPortfolioPermissionChoices.choices, + ), + } + + class DomainInformationAdminForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" @@ -1299,6 +1318,56 @@ class DomainInvitationAdmin(ListHeaderAdmin): return super().changelist_view(request, extra_context=extra_context) +class PortfolioInvitationAdmin(ListHeaderAdmin): + """Custom portfolio invitation admin class.""" + + form = PortfolioInvitationAdminForm + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "email", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", + "status", + ] + + # Search + search_fields = [ + "email", + "portfolio__name", + ] + + # Filters + list_filter = ("status",) + + search_help_text = "Search by email or portfolio." + + # Mark the FSM field 'status' as readonly + # to allow admin users to create Domain Invitations + # without triggering the FSM Transition Not Allowed + # error. + readonly_fields = ["status"] + + autocomplete_fields = ["portfolio"] + + change_form_template = "django/admin/email_clipboard_change_form.html" + + # Select portfolio invitations to change -> Portfolio invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Portfolio invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + + class DomainInformationResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) +admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin) admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) diff --git a/src/registrar/migrations/0114_portfolioinvitation.py b/src/registrar/migrations/0114_portfolioinvitation.py new file mode 100644 index 000000000..afd1dd714 --- /dev/null +++ b/src/registrar/migrations/0114_portfolioinvitation.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.10 on 2024-07-31 22:49 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0113_user_portfolio_user_portfolio_additional_permissions_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PortfolioInvitation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("email", models.EmailField(max_length=254)), + ( + "portfolio_roles", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("organization_admin", "Admin"), + ("organization_admin_read_only", "Admin read only"), + ("organization_member", "Member"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ( + "portfolio_additional_permissions", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("edit_domains", "User is a manager on a domain"), + ("view_member", "View members"), + ("edit_member", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ( + "status", + django_fsm.FSMField( + choices=[("invited", "Invited"), ("retrieved", "Retrieved")], + default="invited", + max_length=50, + protected=True, + ), + ), + ( + "portfolio", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="portfolios", to="registrar.portfolio" + ), + ), + ], + options={ + "indexes": [models.Index(fields=["status"], name="registrar_p_status_aa4218_idx")], + }, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index a68633aff..1e0aad0b1 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -1,4 +1,4 @@ -from auditlog.registry import auditlog # type: ignore +from auditlog.registry import auditlog from .contact import Contact from .domain_request import DomainRequest from .domain_information import DomainInformation @@ -16,6 +16,7 @@ from .website import Website from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .waffle_flag import WaffleFlag +from .portfolio_invitation import PortfolioInvitation from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization @@ -40,6 +41,7 @@ __all__ = [ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "PortfolioInvitation", "Portfolio", "DomainGroup", "Suborganization", @@ -63,6 +65,7 @@ auditlog.register(Website) auditlog.register(TransitionDomain) auditlog.register(VerifiedByStaff) auditlog.register(WaffleFlag) +auditlog.register(PortfolioInvitation) auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py new file mode 100644 index 000000000..2ad780429 --- /dev/null +++ b/src/registrar/models/portfolio_invitation.py @@ -0,0 +1,95 @@ +"""People are invited by email to administer domains.""" + +import logging + +from django.contrib.auth import get_user_model +from django.db import models + +from django_fsm import FSMField, transition +from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore + +from .utility.time_stamped_model import TimeStampedModel +from django.contrib.postgres.fields import ArrayField + + +logger = logging.getLogger(__name__) + + +class PortfolioInvitation(TimeStampedModel): + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["status"]), + ] + + # Constants for status field + class PortfolioInvitationStatus(models.TextChoices): + INVITED = "invited", "Invited" + RETRIEVED = "retrieved", "Retrieved" + + email = models.EmailField( + null=False, + blank=False, + ) + + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.CASCADE, # delete portfolio, then get rid of invitations + null=False, + related_name="portfolios", + ) + + portfolio_roles = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioRoleChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more roles.", + ) + + portfolio_additional_permissions = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioPermissionChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more additional permissions.", + ) + + status = FSMField( + choices=PortfolioInvitationStatus.choices, + default=PortfolioInvitationStatus.INVITED, + protected=True, # can't alter state except through transition methods! + ) + + def __str__(self): + return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") + + # and create a role for that user on this portfolio + user.portfolio = self.portfolio + if self.portfolio_roles and len(self.portfolio_roles) > 0: + user.portfolio_roles = self.portfolio_roles + if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: + user.portfolio_additional_permissions = self.portfolio_additional_permissions + user.save() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index b135e30c7..9c39c4d85 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -5,8 +5,10 @@ from django.db import models from django.db.models import Q from registrar.models.user_domain_role import UserDomainRole +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation +from .portfolio_invitation import PortfolioInvitation from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .domain import Domain @@ -62,36 +64,6 @@ class User(AbstractUser): # after they login. FIXTURE_USER = "fixture_user", "Created by fixtures" - class UserPortfolioRoleChoices(models.TextChoices): - """ - Roles make it easier for admins to look at - """ - - ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" - ORGANIZATION_MEMBER = "organization_member", "Member" - - class UserPortfolioPermissionChoices(models.TextChoices): - """ """ - - VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" - VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" - # EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission - # so we have one way to test for portfolio and domain edit permissions - # Do we need to check for portfolio domains specifically? - # NOTE: A user on an org can currently invite a user outside the org - EDIT_DOMAINS = "edit_domains", "User is a manager on a domain" - - VIEW_MEMBER = "view_member", "View members" - EDIT_MEMBER = "edit_member", "Create and edit members" - - VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" - EDIT_REQUESTS = "edit_requests", "Create and edit requests" - - VIEW_PORTFOLIO = "view_portfolio", "View organization" - EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" - PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, @@ -270,7 +242,7 @@ class User(AbstractUser): # EDIT_DOMAINS === user is a manager on a domain (has UserDomainRole) # NOTE: Should we check whether the domain is in the portfolio? - if portfolio_permission == self.UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists(): + if portfolio_permission == UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists(): return True if not self.portfolio: @@ -283,22 +255,22 @@ class User(AbstractUser): # the methods below are checks for individual portfolio permissions. they are defined here # to make them easier to call elsewhere throughout the application def has_base_portfolio_permission(self): - return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO) def has_domains_portfolio_permission(self): return ( - self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) - or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) # or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS) ) def has_edit_domains_portfolio_permission(self): - return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS) + return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_DOMAINS) def has_domain_requests_portfolio_permission(self): return ( - self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) - or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) # or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_REQUESTS) ) @@ -409,6 +381,24 @@ class User(AbstractUser): new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain) new_domain_invitation.save() + def check_portfolio_invitations_on_login(self): + """When a user first arrives on the site, we need to retrieve any portfolio + invitations that match their email address.""" + for invitation in PortfolioInvitation.objects.filter( + email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED + ): + if self.portfolio is None: + try: + invitation.retrieve() + invitation.save() + except RuntimeError: + # retrieving should not fail because of a missing user, but + # if it does fail, log the error so a new user can continue + # logging in + logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True) + else: + logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True) + def on_each_login(self): """Callback each time the user is authenticated. @@ -420,6 +410,7 @@ class User(AbstractUser): """ self.check_domain_invitations_on_login() + self.check_portfolio_invitations_on_login() def is_org_user(self, request): has_organization_feature_flag = flag_is_active(request, "organization_feature") diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py new file mode 100644 index 000000000..cadf12135 --- /dev/null +++ b/src/registrar/models/utility/portfolio_helper.py @@ -0,0 +1,33 @@ +from django.db import models + + +class UserPortfolioRoleChoices(models.TextChoices): + """ + Roles make it easier for admins to look at + """ + + ORGANIZATION_ADMIN = "organization_admin", "Admin" + ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" + ORGANIZATION_MEMBER = "organization_member", "Member" + + +class UserPortfolioPermissionChoices(models.TextChoices): + """ """ + + VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" + VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" + # EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission + # so we have one way to test for portfolio and domain edit permissions + # Do we need to check for portfolio domains specifically? + # NOTE: A user on an org can currently invite a user outside the org + EDIT_DOMAINS = "edit_domains", "User is a manager on a domain" + + VIEW_MEMBER = "view_member", "View members" + EDIT_MEMBER = "edit_member", "Create and edit members" + + VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" + VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" + EDIT_REQUESTS = "edit_requests", "Create and edit requests" + + VIEW_PORTFOLIO = "view_portfolio", "View organization" + EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" diff --git a/src/registrar/templates/admin/model_descriptions.html b/src/registrar/templates/admin/model_descriptions.html index c075e03a5..4b61e21bd 100644 --- a/src/registrar/templates/admin/model_descriptions.html +++ b/src/registrar/templates/admin/model_descriptions.html @@ -30,6 +30,8 @@ {% include "django/admin/includes/descriptions/verified_by_staff_description.html" %} {% elif opts.model_name == 'website' %} {% include "django/admin/includes/descriptions/website_description.html" %} + {% elif opts.model_name == 'portfolioinvitation' %} + {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} {% else %}

This table does not have a description yet.

{% endif %} diff --git a/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html new file mode 100644 index 000000000..51515bcb2 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html @@ -0,0 +1,11 @@ +

+Portfolio invitations contain all individuals who have been invited to become members of an organization. +Invitations are sent via email, and the recipient must log in to the registrar to officially +accept and become a member. +

+ +

+An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent +or that the recipient has logged in but is already a member of an organization. +A “received” status indicates that the recipient has logged in. +

diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index c145e1f98..aa139b74e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -13,6 +13,7 @@ from registrar.admin import ( ContactAdmin, DomainInformationAdmin, MyHostAdmin, + PortfolioInvitationAdmin, UserDomainRoleAdmin, VerifiedByStaffAdmin, FsmModelResource, @@ -38,6 +39,7 @@ from registrar.models import ( UserGroup, TransitionDomain, ) +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.senior_official import SeniorOfficial from registrar.models.user_domain_role import UserDomainRole from registrar.models.verified_by_staff import VerifiedByStaff @@ -177,6 +179,77 @@ class TestDomainInvitationAdmin(TestCase): self.assertContains(response, retrieved_html, count=1) +class TestPortfolioInvitationAdmin(TestCase): + """Tests for the PortfolioInvitationAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client, and admin + """ + + @classmethod + def setUpClass(cls): + cls.factory = RequestFactory() + cls.admin = ListHeaderAdmin(model=PortfolioInvitationAdmin, admin_site=AdminSite()) + cls.superuser = create_superuser() + + def setUp(self): + """Create a client object""" + self.client = Client(HTTP_HOST="localhost:8080") + + def tearDown(self): + """Delete all DomainInvitation objects""" + PortfolioInvitation.objects.all().delete() + Contact.objects.all().delete() + + @classmethod + def tearDownClass(self): + User.objects.all().delete() + + @less_console_noise_decorator + def test_has_model_description(self): + """Tests if this model has a model description on the table view""" + self.client.force_login(self.superuser) + response = self.client.get( + "/admin/registrar/portfolioinvitation/", + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "Portfolio invitations contain all individuals who have been invited to become members of an organization.", + ) + self.assertContains(response, "Show more") + + def test_get_filters(self): + """Ensures that our filters are displaying correctly""" + with less_console_noise(): + self.client.force_login(self.superuser) + + response = self.client.get( + "/admin/registrar/portfolioinvitation/", + {}, + follow=True, + ) + + # Assert that the filters are added + self.assertContains(response, "invited", count=4) + self.assertContains(response, "Invited", count=2) + self.assertContains(response, "retrieved", count=2) + self.assertContains(response, "Retrieved", count=2) + + # Check for the HTML context specificially + invited_html = 'Invited' + retrieved_html = 'Retrieved' + + self.assertContains(response, invited_html, count=1) + self.assertContains(response, retrieved_html, count=1) + + class TestHostAdmin(TestCase): """Tests for the HostAdmin class as super user diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 741ec5361..994f45480 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -20,7 +20,9 @@ from registrar.models import ( import boto3_mocking from registrar.models.portfolio import Portfolio +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.transition_domain import TransitionDomain +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from registrar.utility.constants import BranchChoices @@ -1071,8 +1073,8 @@ class TestDomainInformation(TestCase): return {k: v for k, v in dict_obj.items() if k not in bad_fields} -class TestInvitations(TestCase): - """Test the retrieval of invitations.""" +class TestDomainInvitations(TestCase): + """Test the retrieval of domain invitations.""" @less_console_noise_decorator def setUp(self): @@ -1116,6 +1118,65 @@ class TestInvitations(TestCase): self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) +class TestPortfolioInvitations(TestCase): + """Test the retrieval of portfolio invitations.""" + + @less_console_noise_decorator + def setUp(self): + self.email = "mayor@igorville.gov" + self.email2 = "creator@igorville.gov" + self.user, _ = User.objects.get_or_create(email=self.email) + self.user2, _ = User.objects.get_or_create(email=self.email2, username="creator") + self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California") + self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER + self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS + self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS + self.invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.email, + portfolio=self.portfolio, + portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], + portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], + ) + + def tearDown(self): + super().tearDown() + Portfolio.objects.all().delete() + PortfolioInvitation.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + def test_retrieval(self): + self.assertFalse(self.user.portfolio) + self.invitation.retrieve() + self.user.refresh_from_db() + self.assertEqual(self.user.portfolio.organization_name, "Hotel California") + self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin]) + self.assertEqual( + self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2] + ) + self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + + @less_console_noise_decorator + def test_retrieve_missing_user_error(self): + # get rid of matching users + User.objects.filter(email=self.email).delete() + with self.assertRaises(RuntimeError): + self.invitation.retrieve() + + @less_console_noise_decorator + def test_retrieve_user_already_member_error(self): + self.assertFalse(self.user.portfolio) + portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel") + self.user.portfolio = portfolio2 + self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") + self.user.save() + self.user.check_portfolio_invitations_on_login() + self.user.refresh_from_db() + self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") + self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + + class TestUser(TestCase): """Test actions that occur on user login, test class method that controls how users get validated.""" From f22316ccc2b326ed079fd0488897a603064221cc Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 1 Aug 2024 08:14:26 -0400 Subject: [PATCH 2/6] fix unit tests --- src/registrar/tests/test_models.py | 3 ++- src/registrar/tests/test_views_portfolio.py | 27 +++++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 994f45480..c1059012b 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1196,6 +1196,7 @@ class TestUser(TestCase): DomainRequest.objects.all().delete() DraftDomain.objects.all().delete() TransitionDomain.objects.all().delete() + Portfolio.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() @@ -1359,7 +1360,7 @@ class TestUser(TestCase): """ portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] self.user.save() self.user.refresh_from_db() diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 3596bf567..f1db5b29e 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -10,6 +10,7 @@ from registrar.models import ( UserDomainRole, User, ) +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices from .common import create_test_user from waffle.testutils import override_flag @@ -55,7 +56,7 @@ class TestPortfolio(WebTest): def test_middleware_does_not_redirect_if_no_portfolio(self): """Test that user with no assigned portfolio is not redirected when attempting to access home""" self.app.set_user(self.user.username) - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() with override_flag("organization_feature", active=True): @@ -70,7 +71,7 @@ class TestPortfolio(WebTest): """Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page""" self.app.set_user(self.user.username) self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() with override_flag("organization_feature", active=True): @@ -87,8 +88,8 @@ class TestPortfolio(WebTest): self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, ] self.user.save() self.user.refresh_from_db() @@ -155,9 +156,9 @@ class TestPortfolio(WebTest): self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] self.user.save() self.user.refresh_from_db() @@ -203,8 +204,8 @@ class TestPortfolioOrganization(TestPortfolio): self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] self.user.save() self.user.refresh_from_db() @@ -220,8 +221,8 @@ class TestPortfolioOrganization(TestPortfolio): self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] self.user.save() self.user.refresh_from_db() @@ -238,8 +239,8 @@ class TestPortfolioOrganization(TestPortfolio): self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] self.user.save() self.user.refresh_from_db() From 108adce05e30b3b620408ac091446b542d4a0998 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 1 Aug 2024 08:28:36 -0400 Subject: [PATCH 3/6] fix migrations --- ...14_portfolioinvitation.py => 0115_portfolioinvitation.py} | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename src/registrar/migrations/{0114_portfolioinvitation.py => 0115_portfolioinvitation.py} (93%) diff --git a/src/registrar/migrations/0114_portfolioinvitation.py b/src/registrar/migrations/0115_portfolioinvitation.py similarity index 93% rename from src/registrar/migrations/0114_portfolioinvitation.py rename to src/registrar/migrations/0115_portfolioinvitation.py index afd1dd714..82a171f10 100644 --- a/src/registrar/migrations/0114_portfolioinvitation.py +++ b/src/registrar/migrations/0115_portfolioinvitation.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-07-31 22:49 +# Generated by Django 4.2.10 on 2024-08-01 12:28 import django.contrib.postgres.fields from django.db import migrations, models @@ -9,7 +9,7 @@ import django_fsm class Migration(migrations.Migration): dependencies = [ - ("registrar", "0113_user_portfolio_user_portfolio_additional_permissions_and_more"), + ("registrar", "0114_alter_user_portfolio_additional_permissions"), ] operations = [ @@ -44,7 +44,6 @@ class Migration(migrations.Migration): choices=[ ("view_all_domains", "View all domains and domain reports"), ("view_managed_domains", "View managed domains"), - ("edit_domains", "User is a manager on a domain"), ("view_member", "View members"), ("edit_member", "Create and edit members"), ("view_all_requests", "View all requests"), From ca24078f50f39fccbccb6a9cd9a9efe91e20c8c6 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 1 Aug 2024 08:37:21 -0400 Subject: [PATCH 4/6] fix unit tests --- src/registrar/tests/test_views_portfolio.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index f1db5b29e..29515d18f 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -179,7 +179,7 @@ class TestPortfolio(WebTest): # reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains # and domain requests from nav - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() @@ -195,9 +195,7 @@ class TestPortfolio(WebTest): portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}) ) - -class TestPortfolioOrganization(TestPortfolio): - + @less_console_noise_decorator def test_portfolio_org_name(self): """Can load portfolio's org name page.""" with override_flag("organization_feature", active=True): @@ -215,6 +213,7 @@ class TestPortfolioOrganization(TestPortfolio): page, "The name of your federal agency will be publicly listed as the domain registrant." ) + @less_console_noise_decorator def test_domain_org_name_address_content(self): """Org name and address information appears on the page.""" with override_flag("organization_feature", active=True): @@ -233,6 +232,7 @@ class TestPortfolioOrganization(TestPortfolio): # Once in the sidenav, once in the main nav, once in the form self.assertContains(page, "Hotel California", count=3) + @less_console_noise_decorator def test_domain_org_name_address_form(self): """Submitting changes works on the org name address page.""" with override_flag("organization_feature", active=True): From 7b46e39a89ad50df6627c5680ac94ea7d14018f4 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 1 Aug 2024 08:42:57 -0400 Subject: [PATCH 5/6] fix unit tests --- src/registrar/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 5fffa878f..b50525e27 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1379,7 +1379,7 @@ class TestUser(TestCase): self.assertTrue(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) - self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] self.user.save() self.user.refresh_from_db() From 70d30f9cee2f9495ae4a76643542f9ea7f0a1ee2 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 1 Aug 2024 08:52:02 -0400 Subject: [PATCH 6/6] fix unit tests --- src/registrar/models/user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index d81c17123..33d8fa1ac 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -254,13 +254,13 @@ class User(AbstractUser): def has_domains_portfolio_permission(self): return self._has_portfolio_permission( - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS - ) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) def has_domain_requests_portfolio_permission(self): return self._has_portfolio_permission( - User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS - ) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) @classmethod def needs_identity_verification(cls, email, uuid):