Merge branch 'main' into ad/3256-new-changes-on-name-servers-page

This commit is contained in:
Rachid Mrad 2025-03-06 21:57:38 -05:00 committed by GitHub
commit 9d0ef3ddff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 675 additions and 140 deletions

View file

@ -204,38 +204,177 @@ class MyUserAdminForm(UserChangeForm):
) )
class UserPortfolioPermissionsForm(forms.ModelForm): class PortfolioPermissionsForm(forms.ModelForm):
class Meta: """
model = models.UserPortfolioPermission Form for managing portfolio permissions in Django admin. This form class is used
fields = "__all__" for both UserPortfolioPermission and PortfolioInvitation models.
widgets = {
"roles": FilteredSelectMultipleArrayWidget( Allows selecting a portfolio, assigning a role, and managing specific permissions
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices related to requests, domains, and members.
), """
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions", # Define available permissions for requests, domains, and members
is_stacked=False, REQUEST_PERMISSIONS = [
choices=UserPortfolioPermissionChoices.choices, 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): class UserPortfolioPermissionsForm(PortfolioPermissionsForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs.""" """
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: class Meta:
model = models.PortfolioInvitation """
fields = "__all__" Meta class defining the model and fields to be used in the form.
widgets = { """
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices model = models.UserPortfolioPermission # Uses the UserPortfolioPermission model
), fields = ["user", "portfolio", "role", "domain_permissions", "request_permissions", "member_permissions"]
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False, class PortfolioInvitationForm(PortfolioPermissionsForm):
choices=UserPortfolioPermissionChoices.choices, """
), 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): class DomainInformationAdminForm(forms.ModelForm):
@ -1345,12 +1484,13 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
change_form_template = "django/admin/user_portfolio_permission_change_form.html" change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.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): def get_roles(self, obj):
readable_roles = obj.get_readable_roles() readable_roles = obj.get_readable_roles()
return ", ".join(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): def delete_queryset(self, request, queryset):
"""We override the delete method in the model. """We override the delete method in the model.
@ -1643,7 +1783,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
class PortfolioInvitationAdmin(BaseInvitationAdmin): class PortfolioInvitationAdmin(BaseInvitationAdmin):
"""Custom portfolio invitation admin class.""" """Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm form = PortfolioInvitationForm
class Meta: class Meta:
model = models.PortfolioInvitation model = models.PortfolioInvitation
@ -1655,8 +1795,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
list_display = [ list_display = [
"email", "email",
"portfolio", "portfolio",
"roles", "get_roles",
"additional_permissions",
"status", "status",
] ]
@ -1681,6 +1820,13 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
change_form_template = "django/admin/portfolio_invitation_change_form.html" change_form_template = "django/admin/portfolio_invitation_change_form.html"
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.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): def save_model(self, request, obj, form, change):
""" """
@ -4225,21 +4371,21 @@ class PortfolioAdmin(ListHeaderAdmin):
if admin_count > 0: if admin_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count # Create a clickable link with the count
return format_html(f'<a href="{url}">{admin_count} administrators</a>') return format_html(f'<a href="{url}">{admin_count} admins</a>')
return "No administrators found." return "No admins found."
display_admins.short_description = "Administrators" # type: ignore display_admins.short_description = "Admins" # type: ignore
def display_members(self, obj): 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)) member_count = len(self.get_user_portfolio_permission_non_admins(obj))
if member_count > 0: if member_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count # Create a clickable link with the count
return format_html(f'<a href="{url}">{member_count} members</a>') return format_html(f'<a href="{url}">{member_count} basic members</a>')
return "No additional members found." 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) # Creates select2 fields (with search bars)
autocomplete_fields = [ autocomplete_fields = [

View file

@ -14,6 +14,7 @@ import {
initFilterFocusListeners } from './domain-request-form.js'; initFilterFocusListeners } from './domain-request-form.js';
import { initDomainFormTargetBlankButtons } from './domain-form.js'; import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js'
import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js'; import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js'; import { initAnalyticsDashboard } from './analytics.js';
@ -44,6 +45,9 @@ initDynamicDomainFields();
// Portfolio // Portfolio
initDynamicPortfolioFields(); initDynamicPortfolioFields();
// Portfolio permissions
initDynamicPortfolioPermissionFields();
// Domain information // Domain information
initDynamicDomainInformationFields(); initDynamicDomainInformationFields();

View file

@ -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();
}
});
}

View file

@ -319,31 +319,23 @@ class DomainRequestFixture:
"""Creates DomainRequests given a list of users.""" """Creates DomainRequests given a list of users."""
total_domain_requests_to_make = len(users) # 100000 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 = [] 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 = ( for user in users:
total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) 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: if num_additional_requests_to_make > 0:
for _ in range(num_additional_requests_to_make): for _ in range(num_additional_requests_to_make):
random_user = random.choice(users) # nosec random_user = random.choice(users) # nosec

View file

@ -445,3 +445,28 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm):
class Meta: class Meta:
model = PortfolioInvitation model = PortfolioInvitation
fields = ["portfolio", "email", "roles", "additional_permissions"] 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__"]

View file

@ -1,4 +1,4 @@
""" " """
Data migration: Renaming deprecated Federal Agencies to Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps) their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests within Domain Information and Domain Requests

View file

@ -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,
),
),
]

View file

@ -15,6 +15,7 @@ from .utility.portfolio_helper import (
get_domains_display, get_domains_display,
get_members_description_display, get_members_description_display,
get_members_display, get_members_display,
get_readable_roles,
get_role_display, get_role_display,
validate_portfolio_invitation, validate_portfolio_invitation,
) # type: ignore ) # type: ignore
@ -78,6 +79,10 @@ class PortfolioInvitation(TimeStampedModel):
def __str__(self): def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}" 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): def get_managed_domains_count(self):
"""Return the count of domain invitations managed by the invited user for this portfolio.""" """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 # Filter the UserDomainRole model to get domains where the user has a manager role

View file

@ -12,6 +12,7 @@ from registrar.models.utility.portfolio_helper import (
get_domains_description_display, get_domains_description_display,
get_members_display, get_members_display,
get_members_description_display, get_members_description_display,
get_readable_roles,
get_role_display, get_role_display,
validate_user_portfolio_permission, validate_user_portfolio_permission,
) )
@ -94,12 +95,7 @@ class UserPortfolioPermission(TimeStampedModel):
def get_readable_roles(self): def get_readable_roles(self):
"""Returns a readable list of self.roles""" """Returns a readable list of self.roles"""
readable_roles = [] return get_readable_roles(self.roles)
if self.roles:
readable_roles = sorted(
[UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
)
return readable_roles
def get_managed_domains_count(self): def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio.""" """Return the count of domains managed by the user for this portfolio."""
@ -275,7 +271,12 @@ class UserPortfolioPermission(TimeStampedModel):
def clean(self): def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() 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): def delete(self, *args, **kwargs):

View file

@ -16,7 +16,7 @@ class UserPortfolioRoleChoices(models.TextChoices):
""" """
ORGANIZATION_ADMIN = "organization_admin", "Admin" ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_MEMBER = "organization_member", "Member" ORGANIZATION_MEMBER = "organization_member", "Basic"
@classmethod @classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role): def get_user_portfolio_role_label(cls, user_portfolio_role):
@ -30,17 +30,17 @@ class UserPortfolioRoleChoices(models.TextChoices):
class UserPortfolioPermissionChoices(models.TextChoices): class UserPortfolioPermissionChoices(models.TextChoices):
""" """ """ """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" VIEW_ALL_DOMAINS = "view_all_domains", "Viewer"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" VIEW_MANAGED_DOMAINS = "view_managed_domains", "Viewer, limited (domains they manage)"
VIEW_MEMBERS = "view_members", "View members" VIEW_MEMBERS = "view_members", "Viewer"
EDIT_MEMBERS = "edit_members", "Create and edit members" EDIT_MEMBERS = "edit_members", "Manager"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" VIEW_ALL_REQUESTS = "view_all_requests", "Viewer"
EDIT_REQUESTS = "edit_requests", "Create and edit requests" EDIT_REQUESTS = "edit_requests", "Creator"
VIEW_PORTFOLIO = "view_portfolio", "View organization" VIEW_PORTFOLIO = "view_portfolio", "Viewer"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" EDIT_PORTFOLIO = "edit_portfolio", "Manager"
@classmethod @classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission): def get_user_portfolio_permission_label(cls, user_portfolio_permission):
@ -79,6 +79,13 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None" 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): def get_role_display(roles):
""" """
Returns a user-friendly display name for a given list of user 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(): if existing_permissions.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio. " "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( 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(): if existing_invitations.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio invitation. " "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. == # # == Validate the multiple_porfolios flag. == #
user = User.objects.filter(email=portfolio_invitation.email).first() user = User.objects.filter(email=portfolio_invitation.email).first()
# If user returns None, then we check for global assignment of multiple_portfolios. # If user returns None, then we check for global assignment of multiple_portfolios.
# Otherwise we just check on the user. # Otherwise we just check on the user.
if not flag_is_active_for_user(user, "multiple_portfolios"): if not flag_is_active_for_user(user, "multiple_portfolios"):
@ -355,13 +365,15 @@ def validate_portfolio_invitation(portfolio_invitation):
if existing_permissions.exists(): if existing_permissions.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio. " "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(): if existing_invitations.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio invitation. " "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",
) )

View file

@ -30,6 +30,8 @@
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %} {% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
{% elif opts.model_name == 'website' %} {% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %} {% 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' %} {% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% elif opts.model_name == 'allowedemail' %} {% elif opts.model_name == 'allowedemail' %}

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
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 <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles permissions table</a> instead. If you invite someone to a domain here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table instead.
</p> </p>
</div> </div>
</div> </div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert"> <div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove If you cancel the domain invitation here, it won't trigger any email notifications.
their domain management privileges if they already have that role assigned. Go to the It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a> <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
if you want to remove the user from a domain. User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p> </p>
</div> </div>
</div> </div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert"> <div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove If you cancel the domain invitation here, it won't trigger any email notifications.
their domain management privileges if they already have that role assigned. Go to the It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a> <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
if you want to remove the user from a domain. User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,16 +1,14 @@
<p> <p>
Domain invitations contain all individuals who have been invited to manage a .gov domain. This table contains 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 These individuals must log in to the registrar to officially accept and become a domain manager.
accept and become a domain manager.
</p> </p>
<p> <p>
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. 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. 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 <a class="text-underline" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles</a> and delete the role for the correct domain/manager combination. A “received” status indicates that the recipient has logged in.
</p> </p>
<p> <p>
If an invitation is created in this table, an email will not be sent. If you invite someone to a domain by using this table, theyll receive an email notification.
To have an email sent, go to the domain in <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>, The existing managers of the domain will also be notified. However, canceling an invitation here wont trigger any emails.
click the “Manage domain” button, and add a domain manager.
</p> </p>

View file

@ -1,11 +1,15 @@
<p> <p>
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.
Invitations are sent via email, and the recipient must log in to the registrar to officially These individuals must log in to the registrar to officially accept and become a member.
accept and become a member.
</p> </p>
<p> <p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent An “invited” status indicates that the recipient has not logged in to the registrar since the invitation
or that the recipient has logged in but is already a member of an organization. was sent or that the recipient has logged in but is already a member of another portfolio. A “received”
A “received” status indicates that the recipient has logged in. status indicates that the recipient has logged in.
</p>
<p>
If you invite someone to a portfolio by using this table, theyll receive an email notification.
If you assign them "admin" access, the existing portfolio admins will also be notified. However, canceling an invitation here wont trigger any emails.
</p> </p>

View file

@ -1,10 +1,13 @@
<p> <p>
This table represents the managers who are assigned to each domain in the registrar. This table represents the managers who are assigned to each domain in the registrar. There are separate records for each domain/manager combination.
There are separate records for each domain/manager combination. Managers can update information related to a domain, such as DNS data and security contact.
Managers can update information related to a domain, such as DNS data and security contact.
</p> </p>
<p> <p>
The creator of an approved domain request automatically becomes a manager for that domain. 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. Anyone who retrieves a domain invitation will also appear in this table as a manager.
</p>
<p>
If you add or remove someone to a domain by using this table, those actions wont trigger notification emails.
</p> </p>

View file

@ -0,0 +1,11 @@
<p>
This table represents the members of each portfolio in the registrar. There are separate records for each member/portfolio combination.
</p>
<p>
Each member is assigned one of two access levels: admin or basic. Only admins can manage member permissions and organization metadata.
</p>
<p>
If you add or remove someone to a portfolio by using this table, those actions wont trigger notification emails.
</p>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
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 <a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">User portfolio permissions table</a> instead. If you invite someone to a portfolio here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table instead.
</p> </p>
</div> </div>
</div> </div>

View file

@ -4,12 +4,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's If you cancel the portfolio invitation here, it won't trigger any email notifications.
portfolio access if they already logged in. Go to the It also won't remove the user's portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}"> <a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions User Portfolio Permissions
</a> </a>
table if you want to remove the user from a portfolio. table if you want to remove their portfolio access.
</p> </p>
</div> </div>
</div> </div>

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
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
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove their portfolio access.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -6,7 +6,10 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the <a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">User Domain Role invitations table</a> instead. If you add someone to a domain here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">
Domain Invitations
</a> table instead.
</p> </p>
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert"> <div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save." If you remove someone from a domain here, it won't trigger any email notifications.
</p> </p>
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert"> <div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save." If you remove someone from a domain here, it won't trigger any email notifications.
</p> </p>
</div> </div>
</div> </div>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">Portfolio invitations table</a> instead. If you add someone to a portfolio here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">
Portfolio Invitations
</a>
table instead.
</p> </p>
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none"> <div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
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.
</p> </p>
</div> </div>
</div> </div>

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it won't trigger any email notifications.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -2,6 +2,7 @@ from datetime import datetime
from django.utils import timezone from django.utils import timezone
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from registrar import models
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag from waffle.testutils import override_flag
@ -19,6 +20,7 @@ from registrar.admin import (
MyHostAdmin, MyHostAdmin,
PortfolioInvitationAdmin, PortfolioInvitationAdmin,
UserDomainRoleAdmin, UserDomainRoleAdmin,
UserPortfolioPermissionsForm,
VerifiedByStaffAdmin, VerifiedByStaffAdmin,
FsmModelResource, FsmModelResource,
WebsiteAdmin, WebsiteAdmin,
@ -175,7 +177,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet # Test for a description snippet
self.assertContains( 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") self.assertContains(response, "Show more")
@ -199,7 +201,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, 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 @less_console_noise_decorator
@ -215,7 +217,7 @@ class TestDomainInvitationAdmin(WebTest):
) )
# Assert that the filters are added # 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, "Invited", count=2)
self.assertContains(response, "retrieved", count=3) self.assertContains(response, "retrieved", count=3)
self.assertContains(response, "Retrieved", count=2) self.assertContains(response, "Retrieved", count=2)
@ -1166,7 +1168,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, 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 @less_console_noise_decorator
@ -1181,7 +1183,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
response = self.client.get(delete_url) response = self.client.get(delete_url)
# Check if the response contains the expected static message # 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")) self.assertIn(expected_message, response.content.decode("utf-8"))
@ -1230,7 +1232,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, 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") self.assertContains(response, "Show more")
@ -1254,7 +1256,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, 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 @less_console_noise_decorator
@ -1638,6 +1640,143 @@ class TestPortfolioInvitationAdmin(TestCase):
self.assertIn(expected_message, response.content.decode("utf-8")) 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): class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user """Tests for the HostAdmin class as super user
@ -2186,7 +2325,7 @@ class TestUserDomainRoleAdmin(WebTest):
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, 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): def test_domain_sortable(self):
@ -3560,10 +3699,10 @@ class TestPortfolioAdmin(TestCase):
display_admins = self.admin.display_admins(self.portfolio) display_admins = self.admin.display_admins(self.portfolio)
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}" url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
self.assertIn(f'<a href="{url}">2 administrators</a>', display_admins) self.assertIn(f'<a href="{url}">2 admins</a>', display_admins)
display_members = self.admin.display_members(self.portfolio) display_members = self.admin.display_members(self.portfolio)
self.assertIn(f'<a href="{url}">2 members</a>', display_members) self.assertIn(f'<a href="{url}">2 basic members</a>', display_members)
@less_console_noise_decorator @less_console_noise_decorator
def test_senior_official_readonly_for_federal_org(self): def test_senior_official_readonly_for_federal_org(self):

View file

@ -3873,11 +3873,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
# verify messages # verify messages
self.assertContains( self.assertContains(
response, response,
( f"{self.invited_member_email} has already been invited to another .gov organization.",
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned "
"to multiple portfolios."
),
) )
# Validate Database has not changed # Validate Database has not changed
@ -3915,11 +3911,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
# Verify messages # Verify messages
self.assertContains( self.assertContains(
response, response,
( f"{self.user.email} is already a member of another .gov organization.",
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be "
"assigned to multiple portfolios."
),
) )
# Validate Database has not changed # Validate Database has not changed