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_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 Meta: "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 @@ def _override_base_help_texts(self): ) +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 @@ def changelist_view(self, request, extra_context=None): 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 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): 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/0115_portfolioinvitation.py b/src/registrar/migrations/0115_portfolioinvitation.py new file mode 100644 index 000000000..82a171f10 --- /dev/null +++ b/src/registrar/migrations/0115_portfolioinvitation.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.10 on 2024-08-01 12:28 + +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", "0114_alter_user_portfolio_additional_permissions"), + ] + + 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"), + ("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 .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 @@ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "PortfolioInvitation", "Portfolio", "DomainGroup", "Suborganization", @@ -63,6 +65,7 @@ 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 b1c9473db..33d8fa1ac 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -5,8 +5,10 @@ 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,31 +64,6 @@ class VerificationTypeChoices(models.TextChoices): # 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" - - 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, @@ -273,17 +250,17 @@ def _has_portfolio_permission(self, portfolio_permission): # 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) + 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): @@ -392,6 +369,24 @@ def create_domain_and_invite(self, transition_domain: TransitionDomain): 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. @@ -403,6 +398,7 @@ def on_each_login(self): """ 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..70977f312 --- /dev/null +++ b/src/registrar/models/utility/portfolio_helper.py @@ -0,0 +1,28 @@ +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" + + 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 9eeaf1577..4ec3336ba 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -13,6 +13,7 @@ ContactAdmin, DomainInformationAdmin, MyHostAdmin, + PortfolioInvitationAdmin, UserDomainRoleAdmin, VerifiedByStaffAdmin, FsmModelResource, @@ -38,6 +39,7 @@ 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 @@ def test_get_filters(self): 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 5982d89b9..b50525e27 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -20,7 +20,9 @@ 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 @@ def clean_dict(self, dict_obj): 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 @@ def test_retrieve_on_each_login(self): 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.""" @@ -1135,6 +1196,7 @@ def tearDown(self): DomainRequest.objects.all().delete() DraftDomain.objects.all().delete() TransitionDomain.objects.all().delete() + Portfolio.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() @@ -1297,7 +1359,7 @@ def test_has_portfolio_permission(self): """ 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() @@ -1317,7 +1379,7 @@ def test_has_portfolio_permission(self): 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() diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 09f34efcf..f6c684868 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -10,6 +10,7 @@ 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 @@ def test_middleware_does_not_redirect_if_no_permission(self): 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 @@ def test_middleware_redirects_to_portfolio_organization_page(self): """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 @@ def test_middleware_redirects_to_portfolio_domains_page(self): 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() @@ -149,9 +150,9 @@ def test_navigation_links_hidden_when_user_not_have_permission(self): 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() @@ -168,7 +169,7 @@ def test_navigation_links_hidden_when_user_not_have_permission(self): # 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() @@ -180,17 +181,15 @@ def test_navigation_links_hidden_when_user_not_have_permission(self): self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domain-requests")) - -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): 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() @@ -200,14 +199,15 @@ def test_portfolio_org_name(self): 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): 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() @@ -218,14 +218,15 @@ def test_domain_org_name_address_content(self): # 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): 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()