diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 8bdebc9fb..ec5c10d51 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -204,38 +204,177 @@ class MyUserAdminForm(UserChangeForm): ) -class UserPortfolioPermissionsForm(forms.ModelForm): - class Meta: - model = models.UserPortfolioPermission - fields = "__all__" - widgets = { - "roles": FilteredSelectMultipleArrayWidget( - "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices - ), - "additional_permissions": FilteredSelectMultipleArrayWidget( - "additional_permissions", - is_stacked=False, - choices=UserPortfolioPermissionChoices.choices, - ), - } +class PortfolioPermissionsForm(forms.ModelForm): + """ + Form for managing portfolio permissions in Django admin. This form class is used + for both UserPortfolioPermission and PortfolioInvitation models. + + Allows selecting a portfolio, assigning a role, and managing specific permissions + related to requests, domains, and members. + """ + + # Define available permissions for requests, domains, and members + REQUEST_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ] + + DOMAIN_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ] + + MEMBER_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] + + # Dropdown to select a portfolio + portfolio = forms.ModelChoiceField( + queryset=models.Portfolio.objects.all(), + label="Portfolio", + widget=AutocompleteSelectWithPlaceholder( + models.PortfolioInvitation._meta.get_field("portfolio"), + admin.site, + attrs={"data-placeholder": "---------"}, # Customize placeholder + ), + ) + + # Dropdown for selecting the user role (e.g., Admin or Basic) + role = forms.ChoiceField( + choices=[("", "---------")] + UserPortfolioRoleChoices.choices, + required=True, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Member access", + help_text="Only admins can manage member permissions and organization metadata.", + ) + + # Dropdown for selecting request permissions, with a default "No access" option + request_permissions = forms.ChoiceField( + choices=[(None, "No access")] + [(perm.value, perm.label) for perm in REQUEST_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Domain requests", + ) + + # Dropdown for selecting domain permissions + domain_permissions = forms.ChoiceField( + choices=[(perm.value, perm.label) for perm in DOMAIN_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Domains", + ) + + # Dropdown for selecting member permissions, with a default "No access" option + member_permissions = forms.ChoiceField( + choices=[(None, "No access")] + [(perm.value, perm.label) for perm in MEMBER_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Members", + ) + + def __init__(self, *args, **kwargs): + """ + Initialize the form and set default values based on the existing instance. + """ + super().__init__(*args, **kwargs) + + # If an instance exists, populate the form fields with existing data + if self.instance and self.instance.pk: + # Set the initial value for the role field + if self.instance.roles: + self.fields["role"].initial = self.instance.roles[0] # Assuming a single role per user + + # Set the initial values for permissions based on the instance data + if self.instance.additional_permissions: + for perm in self.instance.additional_permissions: + if perm in self.REQUEST_PERMISSIONS: + self.fields["request_permissions"].initial = perm + elif perm in self.DOMAIN_PERMISSIONS: + self.fields["domain_permissions"].initial = perm + elif perm in self.MEMBER_PERMISSIONS: + self.fields["member_permissions"].initial = perm + + def clean(self): + """ + Custom validation and processing of form data before saving. + """ + cleaned_data = super().clean() + + # Store the selected role as a list (assuming single role assignment) + self.instance.roles = [cleaned_data.get("role")] if cleaned_data.get("role") else [] + cleaned_data["roles"] = self.instance.roles + + # If the selected role is "organization_member," store additional permissions + if self.instance.roles == [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]: + self.instance.additional_permissions = list( + filter( + None, + [ + cleaned_data.get("request_permissions"), + cleaned_data.get("domain_permissions"), + cleaned_data.get("member_permissions"), + ], + ) + ) + else: + # If the user is an admin, clear any additional permissions + self.instance.additional_permissions = [] + cleaned_data["additional_permissions"] = self.instance.additional_permissions + + return cleaned_data -class PortfolioInvitationAdminForm(UserChangeForm): - """This form utilizes the custom widget for its class's ManyToMany UIs.""" +class UserPortfolioPermissionsForm(PortfolioPermissionsForm): + """ + Form for managing user portfolio permissions in Django admin. + + Extends PortfolioPermissionsForm to include a user field, allowing administrators + to assign roles and permissions to specific users within a portfolio. + """ + + # Dropdown to select a user from the database + user = forms.ModelChoiceField( + queryset=models.User.objects.all(), + label="User", + widget=AutocompleteSelectWithPlaceholder( + models.UserPortfolioPermission._meta.get_field("user"), + admin.site, + attrs={"data-placeholder": "---------"}, # Customize placeholder + ), + ) class Meta: - model = models.PortfolioInvitation - fields = "__all__" - widgets = { - "roles": FilteredSelectMultipleArrayWidget( - "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices - ), - "additional_permissions": FilteredSelectMultipleArrayWidget( - "additional_permissions", - is_stacked=False, - choices=UserPortfolioPermissionChoices.choices, - ), - } + """ + Meta class defining the model and fields to be used in the form. + """ + + model = models.UserPortfolioPermission # Uses the UserPortfolioPermission model + fields = ["user", "portfolio", "role", "domain_permissions", "request_permissions", "member_permissions"] + + +class PortfolioInvitationForm(PortfolioPermissionsForm): + """ + Form for sending portfolio invitations in Django admin. + + Extends PortfolioPermissionsForm to include an email field for inviting users, + allowing them to be assigned a role and permissions within a portfolio before they join. + """ + + class Meta: + """ + Meta class defining the model and fields to be used in the form. + """ + + model = models.PortfolioInvitation # Uses the PortfolioInvitation model + fields = [ + "email", + "portfolio", + "role", + "domain_permissions", + "request_permissions", + "member_permissions", + "status", + ] class DomainInformationAdminForm(forms.ModelForm): @@ -1345,12 +1484,13 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): change_form_template = "django/admin/user_portfolio_permission_change_form.html" delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html" + delete_selected_confirmation_template = "django/admin/user_portfolio_permission_delete_selected_confirmation.html" def get_roles(self, obj): readable_roles = obj.get_readable_roles() return ", ".join(readable_roles) - get_roles.short_description = "Roles" # type: ignore + get_roles.short_description = "Member access" # type: ignore def delete_queryset(self, request, queryset): """We override the delete method in the model. @@ -1643,7 +1783,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin): class PortfolioInvitationAdmin(BaseInvitationAdmin): """Custom portfolio invitation admin class.""" - form = PortfolioInvitationAdminForm + form = PortfolioInvitationForm class Meta: model = models.PortfolioInvitation @@ -1655,8 +1795,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): list_display = [ "email", "portfolio", - "roles", - "additional_permissions", + "get_roles", "status", ] @@ -1681,6 +1820,13 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): change_form_template = "django/admin/portfolio_invitation_change_form.html" delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html" + delete_selected_confirmation_template = "django/admin/portfolio_invitation_delete_selected_confirmation.html" + + def get_roles(self, obj): + readable_roles = obj.get_readable_roles() + return ", ".join(readable_roles) + + get_roles.short_description = "Member access" # type: ignore def save_model(self, request, obj, form, change): """ @@ -4225,21 +4371,21 @@ class PortfolioAdmin(ListHeaderAdmin): if admin_count > 0: url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count - return format_html(f'{admin_count} administrators') - return "No administrators found." + return format_html(f'{admin_count} admins') + return "No admins found." - display_admins.short_description = "Administrators" # type: ignore + display_admins.short_description = "Admins" # type: ignore def display_members(self, obj): - """Returns the number of members for this portfolio""" + """Returns the number of basic members for this portfolio""" member_count = len(self.get_user_portfolio_permission_non_admins(obj)) if member_count > 0: url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count - return format_html(f'{member_count} members') - return "No additional members found." + return format_html(f'{member_count} basic members') + return "No basic members found." - display_members.short_description = "Members" # type: ignore + display_members.short_description = "Basic members" # type: ignore # Creates select2 fields (with search bars) autocomplete_fields = [ diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 112740dd9..97ce33b93 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -14,6 +14,7 @@ import { initFilterFocusListeners } from './domain-request-form.js'; import { initDomainFormTargetBlankButtons } from './domain-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js'; +import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js' import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainFields } from './domain-form.js'; import { initAnalyticsDashboard } from './analytics.js'; @@ -44,6 +45,9 @@ initDynamicDomainFields(); // Portfolio initDynamicPortfolioFields(); +// Portfolio permissions +initDynamicPortfolioPermissionFields(); + // Domain information initDynamicDomainInformationFields(); diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js new file mode 100644 index 000000000..3e331ea46 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js @@ -0,0 +1,67 @@ +import { hideElement, showElement } from './helpers-admin.js'; + +/** + * A function for dynamically changing fields on the UserPortfolioPermissions + * and PortfolioInvitation admin forms + */ +function handlePortfolioPermissionFields(){ + + const roleDropdown = document.getElementById("id_role"); + const domainPermissionsField = document.querySelector(".field-domain_permissions"); + const domainRequestPermissionsField = document.querySelector(".field-request_permissions"); + const memberPermissionsField = document.querySelector(".field-member_permissions"); + + /** + * Updates the visibility of portfolio permissions fields based on the selected role. + * + * This function checks the value of the role dropdown (`roleDropdown`): + * - If the selected role is "organization_member": + * - Shows the domain permissions field (`domainPermissionsField`). + * - Shows the domain request permissions field (`domainRequestPermissionsField`). + * - Shows the member permissions field (`memberPermissionsField`). + * - Otherwise: + * - Hides all the above fields. + * + * The function ensures that the appropriate fields are dynamically displayed + * or hidden depending on the role selection in the form. + */ + function updatePortfolioPermissionsFormVisibility() { + if (roleDropdown && domainPermissionsField && domainRequestPermissionsField && memberPermissionsField) { + if (roleDropdown.value === "organization_member") { + showElement(domainPermissionsField); + showElement(domainRequestPermissionsField); + showElement(memberPermissionsField); + } else { + hideElement(domainPermissionsField); + hideElement(domainRequestPermissionsField); + hideElement(memberPermissionsField); + } + } + } + + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + if (roleDropdown) { + roleDropdown.addEventListener("change", function() { + updatePortfolioPermissionsFormVisibility(); + }) + } + } + + // Run initial setup functions + updatePortfolioPermissionsFormVisibility(); + setEventListeners(); +} + +export function initDynamicPortfolioPermissionFields() { + document.addEventListener('DOMContentLoaded', function() { + let isPortfolioPermissionPage = document.getElementById("userportfoliopermission_form"); + let isPortfolioInvitationPage = document.getElementById("portfolioinvitation_form") + if (isPortfolioPermissionPage || isPortfolioInvitationPage) { + handlePortfolioPermissionFields(); + } + }); +} diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py index 6eee6438f..15a69365c 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -319,31 +319,23 @@ class DomainRequestFixture: """Creates DomainRequests given a list of users.""" total_domain_requests_to_make = len(users) # 100000 - # Check if the database is already populated with the desired - # number of entries. - # (Prevents re-adding more entries to an already populated database, - # which happens when restarting Docker src) - domain_requests_already_made = DomainRequest.objects.count() - domain_requests_to_create = [] - if domain_requests_already_made < total_domain_requests_to_make: - for user in users: - for request_data in cls.DOMAINREQUESTS: - # Prepare DomainRequest objects - try: - domain_request = DomainRequest( - creator=user, - organization_name=request_data["organization_name"], - ) - cls._set_non_foreign_key_fields(domain_request, request_data) - cls._set_foreign_key_fields(domain_request, request_data, user) - domain_requests_to_create.append(domain_request) - except Exception as e: - logger.warning(e) - num_additional_requests_to_make = ( - total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) - ) + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + creator=user, + organization_name=request_data["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(e) + + num_additional_requests_to_make = total_domain_requests_to_make - len(domain_requests_to_create) if num_additional_requests_to_make > 0: for _ in range(num_additional_requests_to_make): random_user = random.choice(users) # nosec diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5aef09389..b83e718cb 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -445,3 +445,28 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): class Meta: model = PortfolioInvitation fields = ["portfolio", "email", "roles", "additional_permissions"] + + def _post_clean(self): + """ + Override _post_clean to customize model validation errors. + This runs after form clean is complete, but before the errors are displayed. + """ + try: + super()._post_clean() + self.instance.clean() + except forms.ValidationError as e: + override_error = False + if hasattr(e, "code"): + field = "email" if "email" in self.fields else None + if e.code == "has_existing_permissions": + self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + override_error = True + elif e.code == "has_existing_invitations": + self.add_error( + field, f"{self.instance.email} has already been invited to another .gov organization." + ) + override_error = True + + # Errors denoted as "__all__" are special error types reserved for the model level clean function + if override_error and "__all__" in self._errors: + del self._errors["__all__"] diff --git a/src/registrar/management/commands/populate_domain_updated_federal_agency.py b/src/registrar/management/commands/populate_domain_updated_federal_agency.py index 3fbf2792c..1c7d1e980 100644 --- a/src/registrar/management/commands/populate_domain_updated_federal_agency.py +++ b/src/registrar/management/commands/populate_domain_updated_federal_agency.py @@ -1,4 +1,4 @@ -""" " +""" Data migration: Renaming deprecated Federal Agencies to their new updated names ie (U.S. Peace Corps to Peace Corps) within Domain Information and Domain Requests diff --git a/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py b/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py new file mode 100644 index 000000000..c100f04b2 --- /dev/null +++ b/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2.17 on 2025-02-28 17:11 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "Viewer"), + ("view_managed_domains", "Viewer, limited (domains they manage)"), + ("view_members", "Viewer"), + ("edit_members", "Manager"), + ("view_all_requests", "Viewer"), + ("edit_requests", "Creator"), + ("view_portfolio", "Viewer"), + ("edit_portfolio", "Manager"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="portfolioinvitation", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "Viewer"), + ("view_managed_domains", "Viewer, limited (domains they manage)"), + ("view_members", "Viewer"), + ("edit_members", "Manager"), + ("view_all_requests", "Viewer"), + ("edit_requests", "Creator"), + ("view_portfolio", "Viewer"), + ("edit_portfolio", "Manager"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index fafa99856..1da0fcdd1 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -15,6 +15,7 @@ from .utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_readable_roles, get_role_display, validate_portfolio_invitation, ) # type: ignore @@ -78,6 +79,10 @@ class PortfolioInvitation(TimeStampedModel): def __str__(self): return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + def get_readable_roles(self): + """Returns a readable list of self.roles""" + return get_readable_roles(self.roles) + def get_managed_domains_count(self): """Return the count of domain invitations managed by the invited user for this portfolio.""" # Filter the UserDomainRole model to get domains where the user has a manager role diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 0a758ff6a..38a87722d 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -12,6 +12,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_description_display, get_members_display, get_members_description_display, + get_readable_roles, get_role_display, validate_user_portfolio_permission, ) @@ -94,12 +95,7 @@ class UserPortfolioPermission(TimeStampedModel): def get_readable_roles(self): """Returns a readable list of self.roles""" - readable_roles = [] - if self.roles: - readable_roles = sorted( - [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles] - ) - return readable_roles + return get_readable_roles(self.roles) def get_managed_domains_count(self): """Return the count of domains managed by the user for this portfolio.""" @@ -275,7 +271,12 @@ class UserPortfolioPermission(TimeStampedModel): def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() - validate_user_portfolio_permission(self) + # Ensure user exists before running further validation + # In django admin, this clean method is called before form validation checks + # for required fields. Since validation below requires user, skip if user does + # not exist + if self.user_id: + validate_user_portfolio_permission(self) def delete(self, *args, **kwargs): diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index e94733fb6..009ea3c26 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -16,7 +16,7 @@ class UserPortfolioRoleChoices(models.TextChoices): """ ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_MEMBER = "organization_member", "Member" + ORGANIZATION_MEMBER = "organization_member", "Basic" @classmethod def get_user_portfolio_role_label(cls, user_portfolio_role): @@ -30,17 +30,17 @@ class UserPortfolioRoleChoices(models.TextChoices): 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_ALL_DOMAINS = "view_all_domains", "Viewer" + VIEW_MANAGED_DOMAINS = "view_managed_domains", "Viewer, limited (domains they manage)" - VIEW_MEMBERS = "view_members", "View members" - EDIT_MEMBERS = "edit_members", "Create and edit members" + VIEW_MEMBERS = "view_members", "Viewer" + EDIT_MEMBERS = "edit_members", "Manager" - VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - EDIT_REQUESTS = "edit_requests", "Create and edit requests" + VIEW_ALL_REQUESTS = "view_all_requests", "Viewer" + EDIT_REQUESTS = "edit_requests", "Creator" - VIEW_PORTFOLIO = "view_portfolio", "View organization" - EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" + VIEW_PORTFOLIO = "view_portfolio", "Viewer" + EDIT_PORTFOLIO = "edit_portfolio", "Manager" @classmethod def get_user_portfolio_permission_label(cls, user_portfolio_permission): @@ -79,6 +79,13 @@ class MemberPermissionDisplay(StrEnum): NONE = "None" +def get_readable_roles(roles): + readable_roles = [] + if roles: + readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in roles]) + return readable_roles + + def get_role_display(roles): """ Returns a user-friendly display name for a given list of user roles. @@ -285,7 +292,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_permissions", ) existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( @@ -295,7 +303,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_invitations", ) @@ -343,6 +352,7 @@ def validate_portfolio_invitation(portfolio_invitation): # == Validate the multiple_porfolios flag. == # user = User.objects.filter(email=portfolio_invitation.email).first() + # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): @@ -355,13 +365,15 @@ def validate_portfolio_invitation(portfolio_invitation): if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_permissions", ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_invitations", ) diff --git a/src/registrar/templates/admin/model_descriptions.html b/src/registrar/templates/admin/model_descriptions.html index 9f13245fe..9b163c407 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 == 'userportfoliopermission' %} + {% include "django/admin/includes/descriptions/user_portfolio_permission_description.html" %} {% elif opts.model_name == 'portfolioinvitation' %} {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} {% elif opts.model_name == 'allowedemail' %} 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 699760fa8..886cfc834 100644 --- a/src/registrar/templates/django/admin/domain_invitation_change_form.html +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the User domain roles permissions table instead. + If you invite someone to a domain here, it will trigger email notifications. If you don't want to trigger emails, use the + + User Domain Roles + + table instead.

diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html index 215bf5ada..7315c9ddc 100644 --- a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html +++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html @@ -5,10 +5,12 @@ diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html index 2e15347c1..8c1614f5d 100644 --- a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html +++ b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html @@ -5,10 +5,12 @@ diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html index ff277a444..6b2cae5ce 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html @@ -1,16 +1,14 @@

-Domain invitations contain all individuals who have been invited to manage a .gov domain. -Invitations are sent via email, and the recipient must log in to the registrar to officially -accept and become a domain manager. + This table contains all individuals who have been invited to manage a .gov domain. + These individuals must log in to the registrar to officially accept and become a domain manager.

-An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. Deleting an invitation with an "invited" status will prevent the user from signing in. -A “received” status indicates that the recipient has logged in. Deleting an invitation with a "received" status will not revoke that user's access from the domain. To remove a user who has already signed in, go to User domain roles and delete the role for the correct domain/manager combination. + An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. + A “received” status indicates that the recipient has logged in.

-If an invitation is created in this table, an email will not be sent. -To have an email sent, go to the domain in Domains, -click the “Manage domain” button, and add a domain manager. + If you invite someone to a domain by using this table, they’ll receive an email notification. + The existing managers of the domain will also be notified. However, canceling an invitation here won’t trigger any emails.

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 index 51515bcb2..9a486f944 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html @@ -1,11 +1,15 @@

-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. + This table contains all individuals who have been invited to become members of a portfolio. + These individuals 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. + 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 another portfolio. A “received” + status indicates that the recipient has logged in. +

+ +

+ If you invite someone to a portfolio by using this table, they’ll receive an email notification. + If you assign them "admin" access, the existing portfolio admins will also be notified. However, canceling an invitation here won’t trigger any emails.

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html index 7066fcb93..f0e7f5a5f 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html @@ -1,10 +1,13 @@

-This table represents the managers who are assigned to each domain in the registrar. -There are separate records for each domain/manager combination. -Managers can update information related to a domain, such as DNS data and security contact. + This table represents the managers who are assigned to each domain in the registrar. There are separate records for each domain/manager combination. + Managers can update information related to a domain, such as DNS data and security contact.

-The creator of an approved domain request automatically becomes a manager for that domain. -Anyone who retrieves a domain invitation is also assigned the manager role. + The creator of an approved domain request automatically becomes a manager for that domain. + Anyone who retrieves a domain invitation will also appear in this table as a manager. +

+ +

+ If you add or remove someone to a domain by using this table, those actions won’t trigger notification emails.

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html new file mode 100644 index 000000000..fd8919b8f --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html @@ -0,0 +1,11 @@ +

+ This table represents the members of each portfolio in the registrar. There are separate records for each member/portfolio combination. +

+ +

+ Each member is assigned one of two access levels: admin or basic. Only admins can manage member permissions and organization metadata. +

+ +

+ If you add or remove someone to a portfolio by using this table, those actions won’t trigger notification emails. +

\ No newline at end of file diff --git a/src/registrar/templates/django/admin/portfolio_invitation_change_form.html b/src/registrar/templates/django/admin/portfolio_invitation_change_form.html index 959e8f8bf..c679b36cd 100644 --- a/src/registrar/templates/django/admin/portfolio_invitation_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_invitation_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the User portfolio permissions table instead. + If you invite someone to a portfolio here, it will trigger email notifications. If you don't want to trigger emails, use the + + User Portfolio Permissions + + table instead.

diff --git a/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html index 932822766..187b5d2c7 100644 --- a/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html +++ b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html @@ -4,12 +4,12 @@

- If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's - portfolio access if they already logged in. Go to the + If you cancel the portfolio invitation here, it won't trigger any email notifications. + It also won't remove the user's portfolio access if they already logged in. Go to the User Portfolio Permissions - table if you want to remove the user from a portfolio. + table if you want to remove their portfolio access.

diff --git a/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html new file mode 100644 index 000000000..2d8bed70b --- /dev/null +++ b/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html @@ -0,0 +1,17 @@ +{% extends "admin/delete_selected_confirmation.html" %} + +{% block content_subtitle %} +
+
+

+ If you cancel the portfolio invitation here, it won't trigger any email notifications. + It also won't remove the user's portfolio access if they already logged in. Go to the + + User Portfolio Permissions + + table if you want to remove their portfolio access. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/user_domain_role_change_form.html b/src/registrar/templates/django/admin/user_domain_role_change_form.html index d8c298bc1..5c589952f 100644 --- a/src/registrar/templates/django/admin/user_domain_role_change_form.html +++ b/src/registrar/templates/django/admin/user_domain_role_change_form.html @@ -6,7 +6,10 @@

- If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the User Domain Role invitations table instead. + If you add someone to a domain here, it won't trigger any email notifications. To trigger emails, use the + + Domain Invitations + table instead.

diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html index 171f4c3ea..68ae1765d 100644 --- a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html +++ b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html @@ -5,7 +5,7 @@ diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html index 392d1aebc..c06a5493e 100644 --- a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html +++ b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html @@ -5,7 +5,7 @@ diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html index 489d67bc5..76e37d568 100644 --- a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html +++ b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the Portfolio invitations table instead. + If you add someone to a portfolio here, it won't trigger any email notifications. To trigger emails, use the + + Portfolio Invitations + + table instead.

diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html index 71c789a63..eb2dd078a 100644 --- a/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html +++ b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html @@ -4,7 +4,7 @@

- If you remove someone from a portfolio here, it will not send any emails when you click "Save". + If you remove someone from a portfolio here, it won't trigger any email notifications.

diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html new file mode 100644 index 000000000..d0d586f07 --- /dev/null +++ b/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html @@ -0,0 +1,12 @@ +{% extends "admin/delete_selected_confirmation.html" %} + +{% block content_subtitle %} +
+
+

+ If you remove someone from a portfolio here, it won't trigger any email notifications. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 0cb99c5c6..1de6b1be3 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2,6 +2,7 @@ from datetime import datetime from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite +from registrar import models from registrar.utility.email import EmailSendingError from registrar.utility.errors import MissingEmailError from waffle.testutils import override_flag @@ -19,6 +20,7 @@ from registrar.admin import ( MyHostAdmin, PortfolioInvitationAdmin, UserDomainRoleAdmin, + UserPortfolioPermissionsForm, VerifiedByStaffAdmin, FsmModelResource, WebsiteAdmin, @@ -175,7 +177,7 @@ class TestDomainInvitationAdmin(WebTest): # Test for a description snippet self.assertContains( - response, "Domain invitations contain all individuals who have been invited to manage a .gov domain." + response, "This table contains all individuals who have been invited to manage a .gov domain." ) self.assertContains(response, "Show more") @@ -199,7 +201,7 @@ class TestDomainInvitationAdmin(WebTest): # Test for a description snippet self.assertContains( response, - "If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain", + "If you invite someone to a domain here, it will trigger email notifications.", ) @less_console_noise_decorator @@ -215,7 +217,7 @@ class TestDomainInvitationAdmin(WebTest): ) # Assert that the filters are added - self.assertContains(response, "invited", count=6) + self.assertContains(response, "invited", count=5) self.assertContains(response, "Invited", count=2) self.assertContains(response, "retrieved", count=3) self.assertContains(response, "Retrieved", count=2) @@ -1166,7 +1168,7 @@ class TestUserPortfolioPermissionAdmin(TestCase): # Test for a description snippet self.assertContains( response, - "If you add someone to a portfolio here, it will not trigger an invitation email.", + "If you add someone to a portfolio here, it won't trigger any email notifications.", ) @less_console_noise_decorator @@ -1181,7 +1183,7 @@ class TestUserPortfolioPermissionAdmin(TestCase): response = self.client.get(delete_url) # Check if the response contains the expected static message - expected_message = "If you remove someone from a portfolio here, it will not send any emails" + expected_message = "If you remove someone from a portfolio here, it won't trigger any email notifications." self.assertIn(expected_message, response.content.decode("utf-8")) @@ -1230,7 +1232,7 @@ class TestPortfolioInvitationAdmin(TestCase): # Test for a description snippet self.assertContains( response, - "Portfolio invitations contain all individuals who have been invited to become members of an organization.", + "This table contains all individuals who have been invited to become members of a portfolio.", ) self.assertContains(response, "Show more") @@ -1254,7 +1256,7 @@ class TestPortfolioInvitationAdmin(TestCase): # Test for a description snippet self.assertContains( response, - "If you add someone to a portfolio here, it will trigger an invitation email when you click", + "If you invite someone to a portfolio here, it will trigger email notifications.", ) @less_console_noise_decorator @@ -1638,6 +1640,143 @@ class TestPortfolioInvitationAdmin(TestCase): self.assertIn(expected_message, response.content.decode("utf-8")) +class PortfolioPermissionsFormTest(TestCase): + + def setUp(self): + # Create a mock portfolio for testing + self.user = create_test_user() + self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.user) + + def tearDown(self): + UserPortfolioPermission.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + + def test_form_valid_with_required_fields(self): + """Test that the form is valid when required fields are filled correctly.""" + # Mock the instance or use a test instance + test_instance = models.UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + ], + ) + form_data = { + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "request_permissions": "view_all_requests", + "domain_permissions": "view_all_domains", + "member_permissions": "view_members", + "user": self.user.id, + } + form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance) + self.assertTrue(form.is_valid()) + + def test_form_invalid_without_role(self): + """Test that the form is invalid if role is missing.""" + # Mock the instance or use a test instance + test_instance = models.UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + ], + ) + form_data = { + "portfolio": self.portfolio.id, + "role": "", # Missing role + } + form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance) + self.assertFalse(form.is_valid()) + self.assertIn("role", form.errors) + + def test_member_role_preserves_permissions(self): + """Ensure that selecting 'organization_member' keeps the additional permissions.""" + # Mock the instance or use a test instance + test_instance = models.UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + ], + ) + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + "member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS, + "portfolio": self.portfolio.id, + "user": self.user.id, + } + form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance) + + # Check if form is valid + self.assertTrue(form.is_valid()) + + # Test if permissions are correctly preserved + cleaned_data = form.cleaned_data + self.assertIn(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, cleaned_data["request_permissions"]) + self.assertIn(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, cleaned_data["domain_permissions"]) + + def test_admin_role_clears_permissions(self): + """Ensure that selecting 'organization_admin' clears additional permissions.""" + # Mock the instance or use a test instance + test_instance = models.UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + ], + ) + form_data = { + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "request_permissions": "view_all_requests", + "domain_permissions": "view_all_domains", + "member_permissions": "view_members", + "user": self.user.id, + } + form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance) + self.assertTrue(form.is_valid()) + + # Simulate form save to check cleaned data behavior + cleaned_data = form.clean() + self.assertEqual(cleaned_data["role"], UserPortfolioRoleChoices.ORGANIZATION_ADMIN) + self.assertNotIn("request_permissions", cleaned_data["additional_permissions"]) # Permissions should be removed + self.assertNotIn("domain_permissions", cleaned_data["additional_permissions"]) + self.assertNotIn("member_permissions", cleaned_data["additional_permissions"]) + + def test_invalid_permission_choice(self): + """Ensure invalid permissions are not accepted.""" + # Mock the instance or use a test instance + test_instance = models.UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + ], + ) + form_data = { + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "request_permissions": "invalid_permission", # Invalid choice + } + form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance) + self.assertFalse(form.is_valid()) + self.assertIn("request_permissions", form.errors) + + class TestHostAdmin(TestCase): """Tests for the HostAdmin class as super user @@ -2186,7 +2325,7 @@ class TestUserDomainRoleAdmin(WebTest): # Test for a description snippet self.assertContains( response, - "If you add someone to a domain here, it will not trigger any emails.", + "If you add someone to a domain here, it won't trigger any email notifications.", ) def test_domain_sortable(self): @@ -3560,10 +3699,10 @@ class TestPortfolioAdmin(TestCase): display_admins = self.admin.display_admins(self.portfolio) url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}" - self.assertIn(f'2 administrators', display_admins) + self.assertIn(f'2 admins', display_admins) display_members = self.admin.display_members(self.portfolio) - self.assertIn(f'2 members', display_members) + self.assertIn(f'2 basic members', display_members) @less_console_noise_decorator def test_senior_official_readonly_for_federal_org(self): diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 64bcb50a8..92e053576 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3873,11 +3873,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): # verify messages self.assertContains( response, - ( - "This user is already assigned to a portfolio invitation. " - "Based on current waffle flag settings, users cannot be assigned " - "to multiple portfolios." - ), + f"{self.invited_member_email} has already been invited to another .gov organization.", ) # Validate Database has not changed @@ -3915,11 +3911,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): # Verify messages self.assertContains( response, - ( - "This user is already assigned to a portfolio. " - "Based on current waffle flag settings, users cannot be " - "assigned to multiple portfolios." - ), + f"{self.user.email} is already a member of another .gov organization.", ) # Validate Database has not changed