mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-01 23:42:17 +02:00
Merge pull request #3252 from cisagov/bob/3019-portfolio-invitation-email
#3019: Portfolio and domain invitation emails
This commit is contained in:
commit
1745f6a2d2
22 changed files with 1516 additions and 621 deletions
|
@ -21,10 +21,14 @@ from registrar.utility.admin_helpers import (
|
|||
get_field_links_as_list,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.contrib.messages import get_messages
|
||||
from django.contrib.admin.helpers import AdminForm
|
||||
from django.shortcuts import redirect
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.email_invitations import send_portfolio_invitation_email
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
@ -37,7 +41,7 @@ from waffle.admin import FlagAdmin
|
|||
from waffle.models import Sample, Switch
|
||||
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from registrar.views.utility.mixins import OrderableFieldsMixin
|
||||
from django.contrib.admin.views.main import ORDER_VAR
|
||||
|
@ -1312,6 +1316,8 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
|
|||
search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
|
||||
search_help_text = "Search by first name, last name, email, or portfolio."
|
||||
|
||||
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
|
||||
|
||||
def get_roles(self, obj):
|
||||
readable_roles = obj.get_readable_roles()
|
||||
return ", ".join(readable_roles)
|
||||
|
@ -1468,7 +1474,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
|
||||
autocomplete_fields = ["portfolio"]
|
||||
|
||||
change_form_template = "django/admin/email_clipboard_change_form.html"
|
||||
change_form_template = "django/admin/portfolio_invitation_change_form.html"
|
||||
|
||||
# Select portfolio invitations to change -> Portfolio invitations
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
|
@ -1478,6 +1484,118 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Override the save_model method.
|
||||
|
||||
Only send email on creation of the PortfolioInvitation object. Not on updates.
|
||||
Emails sent to requested user / email.
|
||||
When exceptions are raised, return without saving model.
|
||||
"""
|
||||
if not change: # Only send email if this is a new PortfolioInvitation (creation)
|
||||
portfolio = obj.portfolio
|
||||
requested_email = obj.email
|
||||
requestor = request.user
|
||||
|
||||
permission_exists = UserPortfolioPermission.objects.filter(
|
||||
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
|
||||
).exists()
|
||||
try:
|
||||
if not permission_exists:
|
||||
# if permission does not exist for a user with requested_email, send email
|
||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
|
||||
messages.success(request, f"{requested_email} has been invited.")
|
||||
else:
|
||||
messages.warning(request, "User is already a member of this portfolio.")
|
||||
except Exception as e:
|
||||
# when exception is raised, handle and do not save the model
|
||||
self._handle_exceptions(e, request, obj)
|
||||
return
|
||||
# Call the parent save method to save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def _handle_exceptions(self, exception, request, obj):
|
||||
"""Handle exceptions raised during the process.
|
||||
|
||||
Log warnings / errors, and message errors to the user.
|
||||
"""
|
||||
if isinstance(exception, EmailSendingError):
|
||||
logger.warning(
|
||||
"Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
|
||||
obj.email,
|
||||
obj.portfolio,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
|
||||
elif isinstance(exception, MissingEmailError):
|
||||
messages.error(request, str(exception))
|
||||
logger.error(
|
||||
f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. "
|
||||
f"No email exists for the requestor.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
|
||||
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
|
||||
|
||||
def response_add(self, request, obj, post_url_continue=None):
|
||||
"""
|
||||
Override response_add to handle rendering when exceptions are raised during add model.
|
||||
|
||||
Normal flow on successful save_model on add is to redirect to changelist_view.
|
||||
If there are errors, flow is modified to instead render change form.
|
||||
"""
|
||||
# Check if there are any error or warning messages in the `messages` framework
|
||||
storage = get_messages(request)
|
||||
has_errors = any(message.level_tag in ["error", "warning"] for message in storage)
|
||||
|
||||
if has_errors:
|
||||
# Re-render the change form if there are errors or warnings
|
||||
# Prepare context for rendering the change form
|
||||
|
||||
# Get the model form
|
||||
ModelForm = self.get_form(request, obj=obj)
|
||||
form = ModelForm(instance=obj)
|
||||
|
||||
# Create an AdminForm instance
|
||||
admin_form = AdminForm(
|
||||
form,
|
||||
list(self.get_fieldsets(request, obj)),
|
||||
self.get_prepopulated_fields(request, obj),
|
||||
self.get_readonly_fields(request, obj),
|
||||
model_admin=self,
|
||||
)
|
||||
media = self.media + form.media
|
||||
|
||||
opts = obj._meta
|
||||
change_form_context = {
|
||||
**self.admin_site.each_context(request), # Add admin context
|
||||
"title": f"Add {opts.verbose_name}",
|
||||
"opts": opts,
|
||||
"original": obj,
|
||||
"save_as": self.save_as,
|
||||
"has_change_permission": self.has_change_permission(request, obj),
|
||||
"add": True, # Indicate this is an "Add" form
|
||||
"change": False, # Indicate this is not a "Change" form
|
||||
"is_popup": False,
|
||||
"inline_admin_formsets": [],
|
||||
"save_on_top": self.save_on_top,
|
||||
"show_delete": self.has_delete_permission(request, obj),
|
||||
"obj": obj,
|
||||
"adminform": admin_form, # Pass the AdminForm instance
|
||||
"media": media,
|
||||
"errors": None,
|
||||
}
|
||||
return self.render_change_form(
|
||||
request,
|
||||
context=change_form_context,
|
||||
add=True,
|
||||
change=False,
|
||||
obj=obj,
|
||||
)
|
||||
return super().response_add(request, obj, post_url_continue)
|
||||
|
||||
|
||||
class DomainInformationResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
|
|
@ -150,14 +150,14 @@ export function initAddNewMemberPageListeners() {
|
|||
document.getElementById('modalEmail').textContent = emailValue;
|
||||
|
||||
// Get selected radio button for access level
|
||||
let selectedAccess = document.querySelector('input[name="member_access_level"]:checked');
|
||||
let selectedAccess = document.querySelector('input[name="role"]:checked');
|
||||
// Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button)
|
||||
// This value does not have the first letter capitalized so let's capitalize it
|
||||
let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected";
|
||||
document.getElementById('modalAccessLevel').textContent = accessText;
|
||||
|
||||
// Populate permission details based on access level
|
||||
if (selectedAccess && selectedAccess.value === 'admin') {
|
||||
if (selectedAccess && selectedAccess.value === 'organization_admin') {
|
||||
populatePermissionDetails('new-member-admin-permissions');
|
||||
} else {
|
||||
populatePermissionDetails('new-member-basic-permissions');
|
||||
|
@ -187,10 +187,10 @@ export function initPortfolioMemberPageRadio() {
|
|||
);
|
||||
}else if (newMemberForm){
|
||||
hookupRadioTogglerListener(
|
||||
'member_access_level',
|
||||
'role',
|
||||
{
|
||||
'admin': 'new-member-admin-permissions',
|
||||
'basic': 'new-member-basic-permissions'
|
||||
'organization_admin': 'new-member-admin-permissions',
|
||||
'organization_member': 'new-member-basic-permissions'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,3 +47,8 @@
|
|||
background-color: color('base-darkest');
|
||||
}
|
||||
}
|
||||
|
||||
// Override the specificity of USWDS css to enable no max width on admin alerts
|
||||
.usa-alert__body.maxw-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
|
|
@ -39,7 +39,8 @@ body {
|
|||
padding-top: units(5)!important;
|
||||
}
|
||||
|
||||
#wrapper.dashboard--grey-1 {
|
||||
#wrapper.dashboard--grey-1,
|
||||
.bg-gray-1 {
|
||||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
|
@ -265,4 +266,4 @@ abbr[title] {
|
|||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,3 +78,7 @@ legend.float-left-tablet + button.float-right-tablet {
|
|||
.read-only-value {
|
||||
margin-top: units(0);
|
||||
}
|
||||
|
||||
.bg-gray-1 .usa-radio {
|
||||
background: color('gray-1');
|
||||
}
|
||||
|
|
|
@ -146,7 +146,7 @@ urlpatterns = [
|
|||
# ),
|
||||
path(
|
||||
"members/new-member/",
|
||||
views.NewMemberView.as_view(),
|
||||
views.PortfolioAddMemberView.as_view(),
|
||||
name="new-member",
|
||||
),
|
||||
path(
|
||||
|
|
|
@ -12,7 +12,6 @@ from registrar.models import (
|
|||
DomainInformation,
|
||||
Portfolio,
|
||||
SeniorOfficial,
|
||||
User,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
|
@ -111,170 +110,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
return cleaned_data
|
||||
|
||||
|
||||
class PortfolioMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class NewMemberForm(forms.ModelForm):
|
||||
member_access_level = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("admin", "Admin Access"), ("basic", "Basic Access")],
|
||||
widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}),
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Member access level is required",
|
||||
},
|
||||
)
|
||||
admin_org_domain_request_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Admin domain request permission is required",
|
||||
},
|
||||
)
|
||||
admin_org_members_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Admin member permission is required",
|
||||
},
|
||||
)
|
||||
basic_org_domain_request_permissions = forms.ChoiceField(
|
||||
label="Select permission",
|
||||
choices=[
|
||||
("view_only", "View all requests"),
|
||||
("view_and_create", "View all requests plus create requests"),
|
||||
("no_access", "No access"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Basic member permission is required",
|
||||
},
|
||||
)
|
||||
|
||||
email = forms.EmailField(
|
||||
label="Enter the email of the member you'd like to invite",
|
||||
max_length=None,
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
required=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["email"]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Lowercase the value of the 'email' field
|
||||
email_value = cleaned_data.get("email")
|
||||
if email_value:
|
||||
cleaned_data["email"] = email_value.lower()
|
||||
|
||||
##########################################
|
||||
# TODO: future ticket
|
||||
# (invite new member)
|
||||
##########################################
|
||||
# Check for an existing user (if there isn't any, send an invite)
|
||||
# if email_value:
|
||||
# try:
|
||||
# existingUser = User.objects.get(email=email_value)
|
||||
# except User.DoesNotExist:
|
||||
# raise forms.ValidationError("User with this email does not exist.")
|
||||
|
||||
member_access_level = cleaned_data.get("member_access_level")
|
||||
|
||||
# Intercept the error messages so that we don't validate hidden inputs
|
||||
if not member_access_level:
|
||||
# If no member access level has been selected, delete error messages
|
||||
# for all hidden inputs (which is everything except the e-mail input
|
||||
# and member access selection)
|
||||
for field in self.fields:
|
||||
if field in self.errors and field != "email" and field != "member_access_level":
|
||||
del self.errors[field]
|
||||
return cleaned_data
|
||||
|
||||
basic_dom_req_error = "basic_org_domain_request_permissions"
|
||||
admin_dom_req_error = "admin_org_domain_request_permissions"
|
||||
admin_member_error = "admin_org_members_permissions"
|
||||
|
||||
if member_access_level == "admin" and basic_dom_req_error in self.errors:
|
||||
# remove the error messages pertaining to basic permission inputs
|
||||
del self.errors[basic_dom_req_error]
|
||||
elif member_access_level == "basic":
|
||||
# remove the error messages pertaining to admin permission inputs
|
||||
if admin_dom_req_error in self.errors:
|
||||
del self.errors[admin_dom_req_error]
|
||||
if admin_member_error in self.errors:
|
||||
del self.errors[admin_member_error]
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BasePortfolioMemberForm(forms.Form):
|
||||
class BasePortfolioMemberForm(forms.ModelForm):
|
||||
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
|
||||
|
||||
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
|
||||
|
@ -345,13 +181,18 @@ class BasePortfolioMemberForm(forms.Form):
|
|||
],
|
||||
}
|
||||
|
||||
def __init__(self, *args, instance=None, **kwargs):
|
||||
"""Initialize self.instance, self.initial, and descriptions under each radio button.
|
||||
Uses map_instance_to_initial to set the initial dictionary."""
|
||||
class Meta:
|
||||
model = None
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Override the form's initialization.
|
||||
|
||||
Map existing model values to custom form fields.
|
||||
Update field descriptions.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
if instance:
|
||||
self.instance = instance
|
||||
self.initial = self.map_instance_to_initial(self.instance)
|
||||
# Adds a <p> description beneath each role option
|
||||
self.fields["role"].descriptions = {
|
||||
"organization_admin": UserPortfolioRoleChoices.get_role_description(
|
||||
|
@ -361,17 +202,15 @@ class BasePortfolioMemberForm(forms.Form):
|
|||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
|
||||
),
|
||||
}
|
||||
|
||||
def save(self):
|
||||
"""Saves self.instance by grabbing data from self.cleaned_data.
|
||||
Uses map_cleaned_data_to_instance.
|
||||
"""
|
||||
self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance)
|
||||
self.instance.save()
|
||||
return self.instance
|
||||
# Map model instance values to custom form fields
|
||||
if self.instance:
|
||||
self.map_instance_to_initial()
|
||||
|
||||
def clean(self):
|
||||
"""Validates form data based on selected role and its required fields."""
|
||||
"""Validates form data based on selected role and its required fields.
|
||||
Updates roles and additional_permissions in cleaned_data so they can be properly
|
||||
mapped to the model.
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
role = cleaned_data.get("role")
|
||||
|
||||
|
@ -389,20 +228,30 @@ class BasePortfolioMemberForm(forms.Form):
|
|||
if cleaned_data.get("domain_request_permission_member") == "no_access":
|
||||
cleaned_data["domain_request_permission_member"] = None
|
||||
|
||||
# Handle roles
|
||||
cleaned_data["roles"] = [role]
|
||||
|
||||
# Handle additional_permissions
|
||||
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
|
||||
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
|
||||
|
||||
# Handle EDIT permissions (should be accompanied with a view permission)
|
||||
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
|
||||
|
||||
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||
|
||||
# Only set unique permissions not already defined in the base role
|
||||
role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False)
|
||||
cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
# Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work:
|
||||
# map_instance_to_initial => called on init to set self.initial.
|
||||
# Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission)
|
||||
# into a dictionary representation for the form to use automatically.
|
||||
|
||||
# map_cleaned_data_to_instance => called on save() to save the instance to the db.
|
||||
# Takes the self.cleaned_data dict, and converts this dict back to the object.
|
||||
|
||||
def map_instance_to_initial(self, instance):
|
||||
def map_instance_to_initial(self):
|
||||
"""
|
||||
Maps self.instance to self.initial, handling roles and permissions.
|
||||
Returns form data dictionary with appropriate permission levels based on user role:
|
||||
Updates self.initial dictionary with appropriate permission levels based on user role:
|
||||
{
|
||||
"role": "organization_admin" or "organization_member",
|
||||
"member_permission_admin": permission level if admin,
|
||||
|
@ -410,12 +259,12 @@ class BasePortfolioMemberForm(forms.Form):
|
|||
"domain_request_permission_member": permission level if member
|
||||
}
|
||||
"""
|
||||
if self.initial is None:
|
||||
self.initial = {}
|
||||
# Function variables
|
||||
form_data = {}
|
||||
perms = UserPortfolioPermission.get_portfolio_permissions(
|
||||
instance.roles, instance.additional_permissions, get_list=False
|
||||
self.instance.roles, self.instance.additional_permissions, get_list=False
|
||||
)
|
||||
|
||||
# Get the available options for roles, domains, and member.
|
||||
roles = [
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
|
@ -433,49 +282,62 @@ class BasePortfolioMemberForm(forms.Form):
|
|||
# Build form data based on role (which options are available).
|
||||
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
|
||||
# and ADMIN takes precedence over MEMBER.
|
||||
roles = instance.roles or []
|
||||
roles = self.instance.roles or []
|
||||
selected_role = next((role for role in roles if role in roles), None)
|
||||
form_data = {"role": selected_role}
|
||||
self.initial["role"] = selected_role
|
||||
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN
|
||||
if is_admin:
|
||||
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None)
|
||||
selected_member_permission = next((perm for perm in member_perms if perm in perms), None)
|
||||
form_data["domain_request_permission_admin"] = selected_domain_permission
|
||||
form_data["member_permission_admin"] = selected_member_permission
|
||||
self.initial["domain_request_permission_admin"] = selected_domain_permission
|
||||
self.initial["member_permission_admin"] = selected_member_permission
|
||||
else:
|
||||
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
|
||||
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
|
||||
form_data["domain_request_permission_member"] = selected_domain_permission
|
||||
self.initial["domain_request_permission_member"] = selected_domain_permission
|
||||
|
||||
return form_data
|
||||
|
||||
def map_cleaned_data_to_instance(self, cleaned_data, instance):
|
||||
"""
|
||||
Maps self.cleaned_data to self.instance, setting roles and permissions.
|
||||
Args:
|
||||
cleaned_data (dict): Cleaned data containing role and permission choices
|
||||
instance: Instance to update
|
||||
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
Returns:
|
||||
instance: Updated instance
|
||||
"""
|
||||
role = cleaned_data.get("role")
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
# Handle roles
|
||||
instance.roles = [role]
|
||||
|
||||
# Handle additional_permissions
|
||||
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
|
||||
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
|
||||
class PortfolioInvitedMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
# Handle EDIT permissions (should be accompanied with a view permission)
|
||||
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = ["roles", "additional_permissions"]
|
||||
|
||||
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
|
||||
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||
|
||||
# Only set unique permissions not already defined in the base role
|
||||
role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False)
|
||||
instance.additional_permissions = list(additional_permissions - role_permissions)
|
||||
return instance
|
||||
class PortfolioNewMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for adding a portfolio invited member.
|
||||
"""
|
||||
|
||||
email = forms.EmailField(
|
||||
label="Enter the email of the member you'd like to invite",
|
||||
max_length=None,
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
required=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = ["portfolio", "email", "roles", "additional_permissions"]
|
||||
|
|
|
@ -171,8 +171,10 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
# The solution to this is to only grab what is only COMMONLY "forbidden".
|
||||
# This will scale if we add more roles in the future.
|
||||
# This is thes same as applying the `&` operator across all sets for each role.
|
||||
common_forbidden_perms = set.intersection(
|
||||
*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles]
|
||||
common_forbidden_perms = (
|
||||
set.intersection(*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles])
|
||||
if roles
|
||||
else set()
|
||||
)
|
||||
|
||||
# Check if the users current permissions overlap with any forbidden permissions
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% 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 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
|
@ -2,15 +2,13 @@
|
|||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block field_sets %}
|
||||
{% for fieldset in adminform %}
|
||||
{% comment %}
|
||||
This is a placeholder for now.
|
||||
|
||||
Disclaimer:
|
||||
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
|
||||
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
||||
{% endcomment %}
|
||||
{% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% 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 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
|
34
src/registrar/templates/emails/portfolio_invitation.txt
Normal file
34
src/registrar/templates/emails/portfolio_invitation.txt
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi.
|
||||
|
||||
{{ requestor_email }} has invited you to {{ portfolio.organization_name }}.
|
||||
|
||||
You can view this organization on the .gov registrar <https://manage.get.gov>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
YOU NEED A LOGIN.GOV ACCOUNT
|
||||
You’ll need a Login.gov account to access this .gov organization. That account
|
||||
needs to be associated with the following email address: {{ email }}
|
||||
|
||||
Login.gov provides a simple and secure process for signing in to many government
|
||||
services with one account. If you don’t already have one, follow these steps to
|
||||
create your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
|
||||
|
||||
|
||||
SOMETHING WRONG?
|
||||
If you’re not affiliated with {{ portfolio.organization_name }} or think you received this
|
||||
message in error, reply to this email.
|
||||
|
||||
|
||||
THANK YOU
|
||||
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
The .gov team
|
||||
Contact us: <https://get.gov/contact/>
|
||||
Learn about .gov <https://get.gov>
|
||||
|
||||
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
|
||||
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
|||
You’ve been invited to a .gov organization
|
|
@ -12,7 +12,7 @@
|
|||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
|
@ -91,7 +91,7 @@
|
|||
<h3 class="summary-item__title
|
||||
text-primary-dark
|
||||
margin-bottom-0">Organization domain requests</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.domain_request_permission_admin %}
|
||||
{% endwith %}
|
||||
|
||||
|
@ -99,7 +99,7 @@
|
|||
text-primary-dark
|
||||
margin-bottom-0
|
||||
margin-top-3">Organization members</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.member_permission_admin %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
@ -110,7 +110,7 @@
|
|||
<p>Member permissions available for basic-level acccess.</p>
|
||||
|
||||
<h3 class="margin-bottom-0 summary-item__title text-primary-dark">Organization domain requests</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.domain_request_permission_member %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
{% endblock messages%}
|
||||
|
||||
<!-- Navigation breadcrumbs -->
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
|
@ -56,22 +56,8 @@
|
|||
|
||||
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
<div class="usa-radio">
|
||||
{% for radio in form.member_access_level %}
|
||||
{{ radio.tag }}
|
||||
<label class="usa-radio__label usa-legend" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
<p class="margin-0 margin-top-2">
|
||||
{% if radio.choice_label == "Admin Access" %}
|
||||
Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately.
|
||||
{% else %}
|
||||
Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members can’t view all members of an organization or manage them. Domain management can be assigned separately.
|
||||
{% endif %}
|
||||
</p>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors form.role %}
|
||||
{% endwith %}
|
||||
|
||||
</fieldset>
|
||||
|
@ -84,16 +70,16 @@
|
|||
<h3 class="summary-item__title
|
||||
text-primary-dark
|
||||
margin-bottom-0">Organization domain requests</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% input_with_errors form.admin_org_domain_request_permissions %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.domain_request_permission_admin %}
|
||||
{% endwith %}
|
||||
|
||||
<h3 class="summary-item__title
|
||||
text-primary-dark
|
||||
margin-bottom-0
|
||||
margin-top-3">Organization members</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% input_with_errors form.admin_org_members_permissions %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.member_permission_admin %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
|
@ -103,8 +89,8 @@
|
|||
<p>Member permissions available for basic-level acccess.</p>
|
||||
|
||||
<h3 class="margin-bottom-0">Organization domain requests</h3>
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
|
||||
{% input_with_errors form.basic_org_domain_request_permissions %}
|
||||
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
|
||||
{% input_with_errors form.domain_request_permission_member %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ 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.utility.email import EmailSendingError
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from waffle.testutils import override_flag
|
||||
from django_webtest import WebTest # type: ignore
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
|
@ -277,6 +279,29 @@ class TestUserPortfolioPermissionAdmin(TestCase):
|
|||
# Should return the forbidden permissions for member role
|
||||
self.assertEqual(member_only_permissions, set(member_forbidden))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_change_form_description(self):
|
||||
"""Tests if this model has a model description on the change form view"""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response,
|
||||
"If you add someone to a portfolio here, it will not trigger an invitation email.",
|
||||
)
|
||||
|
||||
|
||||
class TestPortfolioInvitationAdmin(TestCase):
|
||||
"""Tests for the PortfolioInvitationAdmin class as super user
|
||||
|
@ -432,6 +457,30 @@ class TestPortfolioInvitationAdmin(TestCase):
|
|||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_change_form_description(self):
|
||||
"""Tests if this model has a model description on the change form view"""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_filters(self):
|
||||
"""Ensures that our filters are displaying correctly"""
|
||||
with less_console_noise():
|
||||
|
@ -456,6 +505,176 @@ class TestPortfolioInvitationAdmin(TestCase):
|
|||
self.assertContains(response, invited_html, count=1)
|
||||
self.assertContains(response, retrieved_html, count=1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||
@patch("django.contrib.messages.success") # Mock the `messages.warning` call
|
||||
def test_save_sends_email(self, mock_messages_warning, mock_send_email):
|
||||
"""On save_model, an email is NOT sent if an invitation already exists."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Create an instance of the admin class
|
||||
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||
|
||||
# Create a PortfolioInvitation instance
|
||||
portfolio_invitation = PortfolioInvitation(
|
||||
email="james.gordon@gotham.gov",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# Create a request object
|
||||
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
|
||||
request.user = self.superuser
|
||||
|
||||
# Call the save_model method
|
||||
admin_instance.save_model(request, portfolio_invitation, None, None)
|
||||
|
||||
# Assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_called()
|
||||
|
||||
# Get the arguments passed to send_portfolio_invitation_email
|
||||
_, called_kwargs = mock_send_email.call_args
|
||||
|
||||
# Assert the email content
|
||||
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
|
||||
self.assertEqual(called_kwargs["requestor"], self.superuser)
|
||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||
|
||||
# Assert that a warning message was triggered
|
||||
mock_messages_warning.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||
@patch("django.contrib.messages.warning") # Mock the `messages.warning` call
|
||||
def test_save_does_not_send_email_if_requested_user_exists(self, mock_messages_warning, mock_send_email):
|
||||
"""On save_model, an email is NOT sent if an the requested email belongs to an existing user.
|
||||
It also throws a warning."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Create an instance of the admin class
|
||||
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||
|
||||
# Mock the UserPortfolioPermission query to simulate the invitation already existing
|
||||
existing_user = create_user()
|
||||
UserPortfolioPermission.objects.create(user=existing_user, portfolio=self.portfolio)
|
||||
|
||||
# Create a PortfolioInvitation instance
|
||||
portfolio_invitation = PortfolioInvitation(
|
||||
email=existing_user.email,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# Create a request object
|
||||
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
|
||||
request.user = self.superuser
|
||||
|
||||
# Call the save_model method
|
||||
admin_instance.save_model(request, portfolio_invitation, None, None)
|
||||
|
||||
# Assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
# Assert that a warning message was triggered
|
||||
mock_messages_warning.assert_called_once_with(request, "User is already a member of this portfolio.")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||
@patch("django.contrib.messages.error") # Mock the `messages.error` call
|
||||
def test_save_exception_email_sending_error(self, mock_messages_error, mock_send_email):
|
||||
"""Handle EmailSendingError correctly when sending the portfolio invitation fails."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Mock the email sending function to raise EmailSendingError
|
||||
mock_send_email.side_effect = EmailSendingError("Email service unavailable")
|
||||
|
||||
# Create an instance of the admin class
|
||||
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||
|
||||
# Create a PortfolioInvitation instance
|
||||
portfolio_invitation = PortfolioInvitation(
|
||||
email="james.gordon@gotham.gov",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# Create a request object
|
||||
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
|
||||
request.user = self.superuser
|
||||
|
||||
# Call the save_model method
|
||||
admin_instance.save_model(request, portfolio_invitation, None, None)
|
||||
|
||||
# Assert that messages.error was called with the correct message
|
||||
mock_messages_error.assert_called_once_with(
|
||||
request, "Could not send email invitation. Portfolio invitation not saved."
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||
@patch("django.contrib.messages.error") # Mock the `messages.error` call
|
||||
def test_save_exception_missing_email_error(self, mock_messages_error, mock_send_email):
|
||||
"""Handle MissingEmailError correctly when no email exists for the requestor."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Mock the email sending function to raise MissingEmailError
|
||||
mock_send_email.side_effect = MissingEmailError()
|
||||
|
||||
# Create an instance of the admin class
|
||||
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||
|
||||
# Create a PortfolioInvitation instance
|
||||
portfolio_invitation = PortfolioInvitation(
|
||||
email="james.gordon@gotham.gov",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# Create a request object
|
||||
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
|
||||
request.user = self.superuser
|
||||
|
||||
# Call the save_model method
|
||||
admin_instance.save_model(request, portfolio_invitation, None, None)
|
||||
|
||||
# Assert that messages.error was called with the correct message
|
||||
mock_messages_error.assert_called_once_with(
|
||||
request,
|
||||
"Can't send invitation email. No email is associated with your user account.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||
@patch("django.contrib.messages.error") # Mock the `messages.error` call
|
||||
def test_save_exception_generic_error(self, mock_messages_error, mock_send_email):
|
||||
"""Handle generic exceptions correctly during portfolio invitation."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Mock the email sending function to raise a generic exception
|
||||
mock_send_email.side_effect = Exception("Unexpected error")
|
||||
|
||||
# Create an instance of the admin class
|
||||
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||
|
||||
# Create a PortfolioInvitation instance
|
||||
portfolio_invitation = PortfolioInvitation(
|
||||
email="james.gordon@gotham.gov",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# Create a request object
|
||||
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
|
||||
request.user = self.superuser
|
||||
|
||||
# Call the save_model method
|
||||
admin_instance.save_model(request, portfolio_invitation, None, None)
|
||||
|
||||
# Assert that messages.error was called with the correct message
|
||||
mock_messages_error.assert_called_once_with(
|
||||
request, "Could not send email invitation. Portfolio invitation not saved."
|
||||
)
|
||||
|
||||
|
||||
class TestHostAdmin(TestCase):
|
||||
"""Tests for the HostAdmin class as super user
|
||||
|
|
|
@ -18,7 +18,17 @@ from registrar.forms.domain_request_wizard import (
|
|||
AboutYourOrganizationForm,
|
||||
)
|
||||
from registrar.forms.domain import ContactForm
|
||||
from registrar.tests.common import MockEppLib
|
||||
from registrar.forms.portfolio import (
|
||||
PortfolioInvitedMemberForm,
|
||||
PortfolioMemberForm,
|
||||
PortfolioNewMemberForm,
|
||||
)
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.tests.common import MockEppLib, create_user
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
|
@ -408,3 +418,196 @@ class TestContactForm(TestCase):
|
|||
def test_contact_form_email_invalid2(self):
|
||||
form = ContactForm(data={"email": "@"})
|
||||
self.assertEqual(form.errors["email"], ["Enter a valid email address."])
|
||||
|
||||
|
||||
class TestBasePortfolioMemberForms(TestCase):
|
||||
"""We test on the child forms instead of BasePortfolioMemberForm because the base form
|
||||
is a model form with no model bound."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = create_user()
|
||||
self.portfolio, _ = Portfolio.objects.get_or_create(
|
||||
creator_id=self.user.id, organization_name="Hotel California"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
Portfolio.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
def _assert_form_is_valid(self, form_class, data, instance=None):
|
||||
if instance is not None:
|
||||
form = form_class(data=data, instance=instance)
|
||||
else:
|
||||
print("no instance")
|
||||
form = form_class(data=data)
|
||||
self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}")
|
||||
return form
|
||||
|
||||
def _assert_form_has_error(self, form_class, data, field_name):
|
||||
form = form_class(data=data)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn(field_name, form.errors)
|
||||
|
||||
def _assert_initial_data(self, form_class, instance, expected_initial_data):
|
||||
"""Helper to check if the instance data is correctly mapped to the initial form values."""
|
||||
form = form_class(instance=instance)
|
||||
for field, expected_value in expected_initial_data.items():
|
||||
self.assertEqual(form.initial[field], expected_value)
|
||||
|
||||
def _assert_permission_mapping(self, form_class, data, expected_permissions):
|
||||
"""Helper to check if permissions are correctly handled and mapped."""
|
||||
form = self._assert_form_is_valid(form_class, data)
|
||||
cleaned_data = form.cleaned_data
|
||||
for permission in expected_permissions:
|
||||
self.assertIn(permission, cleaned_data["additional_permissions"])
|
||||
|
||||
def test_required_field_for_admin(self):
|
||||
"""Test that required fields are validated for an admin role."""
|
||||
data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
|
||||
"domain_request_permission_admin": "", # Simulate missing field
|
||||
"member_permission_admin": "", # Simulate missing field
|
||||
}
|
||||
|
||||
# Check required fields for all forms
|
||||
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
|
||||
self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin")
|
||||
|
||||
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin")
|
||||
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
|
||||
|
||||
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin")
|
||||
self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin")
|
||||
|
||||
def test_required_field_for_member(self):
|
||||
"""Test that required fields are validated for a member role."""
|
||||
data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": "", # Simulate missing field
|
||||
}
|
||||
|
||||
# Check required fields for all forms
|
||||
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member")
|
||||
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member")
|
||||
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member")
|
||||
|
||||
def test_clean_validates_required_fields_for_role(self):
|
||||
"""Test that the `clean` method validates the correct fields for each role.
|
||||
|
||||
For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
|
||||
For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
|
||||
|
||||
These things are handled in the views."""
|
||||
|
||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=self.user
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
|
||||
|
||||
data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
|
||||
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
|
||||
}
|
||||
|
||||
# Check form validity for all forms
|
||||
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
|
||||
cleaned_data = form.cleaned_data
|
||||
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
|
||||
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
|
||||
|
||||
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
|
||||
cleaned_data = form.cleaned_data
|
||||
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
|
||||
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
|
||||
|
||||
data = {
|
||||
"email": "hi@ho.com",
|
||||
"portfolio": self.portfolio.id,
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
|
||||
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
|
||||
}
|
||||
|
||||
form = self._assert_form_is_valid(PortfolioNewMemberForm, data)
|
||||
cleaned_data = form.cleaned_data
|
||||
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
|
||||
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
|
||||
|
||||
def test_clean_member_permission_edgecase(self):
|
||||
"""Test that the clean method correctly handles the special "no_access" value for members.
|
||||
We'll need to add a portfolio, which in the app is handled by the view post."""
|
||||
|
||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=self.user
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
|
||||
|
||||
data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": "no_access", # Simulate no access permission
|
||||
}
|
||||
|
||||
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
|
||||
cleaned_data = form.cleaned_data
|
||||
self.assertEqual(cleaned_data["domain_request_permission_member"], None)
|
||||
|
||||
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
|
||||
cleaned_data = form.cleaned_data
|
||||
self.assertEqual(cleaned_data["domain_request_permission_member"], None)
|
||||
|
||||
def test_map_instance_to_initial_admin_role(self):
|
||||
"""Test that instance data is correctly mapped to the initial form values for an admin role."""
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
portfolio=self.portfolio,
|
||||
email="hi@ho",
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
|
||||
)
|
||||
|
||||
expected_initial_data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
"member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
}
|
||||
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
|
||||
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
|
||||
|
||||
def test_map_instance_to_initial_member_role(self):
|
||||
"""Test that instance data is correctly mapped to the initial form values for a member role."""
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
portfolio=self.portfolio,
|
||||
email="hi@ho",
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
|
||||
)
|
||||
expected_initial_data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
}
|
||||
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
|
||||
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
|
||||
|
||||
def test_invalid_data_for_admin(self):
|
||||
"""Test invalid form submission for an admin role with missing permissions."""
|
||||
data = {
|
||||
"email": "hi@ho.com",
|
||||
"portfolio": self.portfolio.id,
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
|
||||
"domain_request_permission_admin": "", # Missing field
|
||||
"member_permission_admin": "", # Missing field
|
||||
}
|
||||
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
|
||||
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
|
||||
|
|
|
@ -4,6 +4,8 @@ from unittest.mock import MagicMock, ANY, patch
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from waffle.testutils import override_flag
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -548,6 +550,7 @@ class TestDomainManagers(TestDomainOverview):
|
|||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
|
||||
# Add the portfolio to the domain_information object
|
||||
self.domain_information.portfolio = self.portfolio
|
||||
self.domain_information.save()
|
||||
# Add portfolio perms to the user object
|
||||
self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
|
@ -560,6 +563,7 @@ class TestDomainManagers(TestDomainOverview):
|
|||
|
||||
def tearDown(self):
|
||||
"""Ensure that the user has its original permissions"""
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -592,7 +596,7 @@ class TestDomainManagers(TestDomainOverview):
|
|||
@less_console_noise_decorator
|
||||
def test_domain_user_add_form(self):
|
||||
"""Adding an existing user works."""
|
||||
other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
|
||||
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
|
@ -615,6 +619,148 @@ class TestDomainManagers(TestDomainOverview):
|
|||
success_page = success_result.follow()
|
||||
self.assertContains(success_page, "mayor@igorville.gov")
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_flag("organization_feature", active=True)
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.views.domain.send_portfolio_invitation_email")
|
||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||
def test_domain_user_add_form_sends_portfolio_invitation(self, mock_send_domain_email, mock_send_portfolio_email):
|
||||
"""Adding an existing user works and sends portfolio invitation when
|
||||
user is not member of portfolio."""
|
||||
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
add_page.form["email"] = "mayor@igorville.gov"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
success_result = add_page.form.submit()
|
||||
|
||||
self.assertEqual(success_result.status_code, 302)
|
||||
self.assertEqual(
|
||||
success_result["Location"],
|
||||
reverse("domain-users", kwargs={"pk": self.domain.id}),
|
||||
)
|
||||
|
||||
# Verify that the invitation emails were sent
|
||||
mock_send_portfolio_email.assert_called_once_with(
|
||||
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
|
||||
)
|
||||
mock_send_domain_email.assert_called_once()
|
||||
call_args = mock_send_domain_email.call_args.kwargs
|
||||
self.assertEqual(call_args["email"], "mayor@igorville.gov")
|
||||
self.assertEqual(call_args["requestor"], self.user)
|
||||
self.assertEqual(call_args["domain"], self.domain)
|
||||
self.assertIsNone(call_args.get("is_member_of_different_org"))
|
||||
|
||||
# Assert that the PortfolioInvitation is created
|
||||
portfolio_invitation = PortfolioInvitation.objects.filter(
|
||||
email="mayor@igorville.gov", portfolio=self.portfolio
|
||||
).first()
|
||||
self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.")
|
||||
self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov")
|
||||
self.assertEqual(portfolio_invitation.portfolio, self.portfolio)
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_page = success_result.follow()
|
||||
self.assertContains(success_page, "mayor@igorville.gov")
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_flag("organization_feature", active=True)
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.views.domain.send_portfolio_invitation_email")
|
||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||
def test_domain_user_add_form_doesnt_send_portfolio_invitation_if_already_member(
|
||||
self, mock_send_domain_email, mock_send_portfolio_email
|
||||
):
|
||||
"""Adding an existing user works and sends portfolio invitation when
|
||||
user is not member of portfolio."""
|
||||
other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
add_page.form["email"] = "mayor@igorville.gov"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
success_result = add_page.form.submit()
|
||||
|
||||
self.assertEqual(success_result.status_code, 302)
|
||||
self.assertEqual(
|
||||
success_result["Location"],
|
||||
reverse("domain-users", kwargs={"pk": self.domain.id}),
|
||||
)
|
||||
|
||||
# Verify that the invitation emails were sent
|
||||
mock_send_portfolio_email.assert_not_called()
|
||||
mock_send_domain_email.assert_called_once()
|
||||
call_args = mock_send_domain_email.call_args.kwargs
|
||||
self.assertEqual(call_args["email"], "mayor@igorville.gov")
|
||||
self.assertEqual(call_args["requestor"], self.user)
|
||||
self.assertEqual(call_args["domain"], self.domain)
|
||||
self.assertIsNone(call_args.get("is_member_of_different_org"))
|
||||
|
||||
# Assert that no PortfolioInvitation is created
|
||||
portfolio_invitation_exists = PortfolioInvitation.objects.filter(
|
||||
email="mayor@igorville.gov", portfolio=self.portfolio
|
||||
).exists()
|
||||
self.assertFalse(
|
||||
portfolio_invitation_exists, "Portfolio invitation should not be created when the user is already a member."
|
||||
)
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_page = success_result.follow()
|
||||
self.assertContains(success_page, "mayor@igorville.gov")
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_flag("organization_feature", active=True)
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.views.domain.send_portfolio_invitation_email")
|
||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||
def test_domain_user_add_form_sends_portfolio_invitation_raises_email_sending_error(
|
||||
self, mock_send_domain_email, mock_send_portfolio_email
|
||||
):
|
||||
"""Adding an existing user works and attempts to send portfolio invitation when
|
||||
user is not member of portfolio and send raises an error."""
|
||||
mock_send_portfolio_email.side_effect = EmailSendingError("Failed to send email.")
|
||||
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
add_page.form["email"] = "mayor@igorville.gov"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
success_result = add_page.form.submit()
|
||||
|
||||
self.assertEqual(success_result.status_code, 302)
|
||||
self.assertEqual(
|
||||
success_result["Location"],
|
||||
reverse("domain-users", kwargs={"pk": self.domain.id}),
|
||||
)
|
||||
|
||||
# Verify that the invitation emails were sent
|
||||
mock_send_portfolio_email.assert_called_once_with(
|
||||
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
|
||||
)
|
||||
mock_send_domain_email.assert_not_called()
|
||||
|
||||
# Assert that no PortfolioInvitation is created
|
||||
portfolio_invitation_exists = PortfolioInvitation.objects.filter(
|
||||
email="mayor@igorville.gov", portfolio=self.portfolio
|
||||
).exists()
|
||||
self.assertFalse(
|
||||
portfolio_invitation_exists, "Portfolio invitation should not be created when email fails to send."
|
||||
)
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_page = success_result.follow()
|
||||
self.assertContains(success_page, "Could not send email invitation.")
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_created(self):
|
||||
|
@ -827,39 +973,20 @@ class TestDomainManagers(TestDomainOverview):
|
|||
self.assertNotIn("Last", email_content)
|
||||
self.assertNotIn("First Last", email_content)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_email_displays_error_non_existent(self):
|
||||
"""Inviting a non existent user sends them an email, with email as the name."""
|
||||
# make sure there is no user with this email
|
||||
email_address = "mayor@igorville.gov"
|
||||
User.objects.filter(email=email_address).delete()
|
||||
|
||||
# Give the user who is sending the email an invalid email address
|
||||
self.user.email = ""
|
||||
self.user.save()
|
||||
|
||||
def test_domain_invitation_email_validation_blocks_bad_email(self):
|
||||
"""Inviting a bad email blocks at validation."""
|
||||
email_address = "mayor"
|
||||
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_error_message = MagicMock()
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||
with patch("django.contrib.messages.error") as mock_error_message:
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
add_page.form["email"] = email_address
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
add_page.form.submit().follow()
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
add_page.form["email"] = email_address
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
response = add_page.form.submit()
|
||||
|
||||
expected_message_content = "Can't send invitation email. No email is associated with your account."
|
||||
self.assertContains(response, "Enter an email address in the required format, like name@example.com.")
|
||||
|
||||
# Grab the message content
|
||||
returned_error_message = mock_error_message.call_args[0][1]
|
||||
|
||||
# Check that the message content is what we expect
|
||||
self.assertEqual(expected_message_content, returned_error_message)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_email_displays_error(self):
|
||||
"""When the requesting user has no email, an error is displayed"""
|
||||
|
@ -870,28 +997,25 @@ class TestDomainManagers(TestDomainOverview):
|
|||
|
||||
# Give the user who is sending the email an invalid email address
|
||||
self.user.email = ""
|
||||
self.user.is_staff = False
|
||||
self.user.save()
|
||||
|
||||
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
|
||||
|
||||
mock_client = MagicMock()
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
add_page.form["email"] = email_address
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
add_page.form.submit()
|
||||
|
||||
mock_error_message = MagicMock()
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||
with patch("django.contrib.messages.error") as mock_error_message:
|
||||
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
add_page.form["email"] = email_address
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
add_page.form.submit().follow()
|
||||
expected_message_content = "Can't send invitation email. No email is associated with your user account."
|
||||
|
||||
expected_message_content = "Can't send invitation email. No email is associated with your account."
|
||||
|
||||
# Grab the message content
|
||||
returned_error_message = mock_error_message.call_args[0][1]
|
||||
|
||||
# Check that the message content is what we expect
|
||||
self.assertEqual(expected_message_content, returned_error_message)
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_error.assert_called_once_with(
|
||||
ANY,
|
||||
expected_message_content,
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_cancel(self):
|
||||
|
|
|
@ -20,6 +20,8 @@ from registrar.models.user_group import UserGroup
|
|||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.tests.test_views import TestWithUser
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
|
||||
from waffle.testutils import override_flag
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
|
@ -2837,7 +2839,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
],
|
||||
)
|
||||
|
||||
cls.new_member_email = "new_user@example.com"
|
||||
cls.new_member_email = "davekenn4242@gmail.com"
|
||||
|
||||
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
|
||||
|
||||
# Assign permissions to the user making requests
|
||||
UserPortfolioPermission.objects.create(
|
||||
|
@ -2856,8 +2860,10 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
AllowedEmail.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
|
@ -2869,30 +2875,240 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
# Simulate submission of member invite for new user
|
||||
final_response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"member_access_level": "basic",
|
||||
"basic_org_domain_request_permissions": "view_only",
|
||||
"email": self.new_member_email,
|
||||
},
|
||||
)
|
||||
mock_client_class = MagicMock()
|
||||
mock_client = mock_client_class.return_value
|
||||
|
||||
# Ensure the final submission is successful
|
||||
self.assertEqual(final_response.status_code, 302) # redirects after success
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
|
||||
# Simulate submission of member invite for new user
|
||||
final_response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.new_member_email,
|
||||
},
|
||||
)
|
||||
|
||||
# Validate Database Changes
|
||||
portfolio_invite = PortfolioInvitation.objects.filter(
|
||||
email=self.new_member_email, portfolio=self.portfolio
|
||||
).first()
|
||||
self.assertIsNotNone(portfolio_invite)
|
||||
self.assertEqual(portfolio_invite.email, self.new_member_email)
|
||||
# Ensure the final submission is successful
|
||||
self.assertEqual(final_response.status_code, 302) # Redirects
|
||||
|
||||
# Validate Database Changes
|
||||
portfolio_invite = PortfolioInvitation.objects.filter(
|
||||
email=self.new_member_email, portfolio=self.portfolio
|
||||
).first()
|
||||
self.assertIsNotNone(portfolio_invite)
|
||||
self.assertEqual(portfolio_invite.email, self.new_member_email)
|
||||
|
||||
# Check that an email was sent
|
||||
self.assertTrue(mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_invite_for_new_users_initial_ajax_call_passes(self):
|
||||
"""Tests the member invitation flow for new users."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
mock_client = mock_client_class.return_value
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
|
||||
# Simulate submission of member invite for new user
|
||||
final_response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.new_member_email,
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
|
||||
# Ensure the prep ajax submission is successful
|
||||
self.assertEqual(final_response.status_code, 200)
|
||||
|
||||
# Check that the response is a JSON response with is_valid
|
||||
json_response = final_response.json()
|
||||
self.assertIn("is_valid", json_response)
|
||||
self.assertTrue(json_response["is_valid"])
|
||||
|
||||
# assert that portfolio invitation is not created
|
||||
self.assertFalse(
|
||||
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
|
||||
"Portfolio invitation should not be created when an Exception occurs.",
|
||||
)
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(mock_client.send_email.called)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_invite_for_previously_invited_member(self):
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_member_invite_for_previously_invited_member_initial_ajax_call_fails(self, mock_send_email):
|
||||
"""Tests the initial ajax call in the member invitation flow for existing portfolio member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
invite_count_before = PortfolioInvitation.objects.count()
|
||||
|
||||
# Simulate submission of member invite for user who has already been invited
|
||||
response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.invited_member_email,
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the response is a JSON response with is_valid == False
|
||||
json_response = response.json()
|
||||
self.assertIn("is_valid", json_response)
|
||||
self.assertFalse(json_response["is_valid"])
|
||||
|
||||
# Validate Database has not changed
|
||||
invite_count_after = PortfolioInvitation.objects.count()
|
||||
self.assertEqual(invite_count_after, invite_count_before)
|
||||
|
||||
# assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_submit_new_member_raises_email_sending_error(self, mock_send_email):
|
||||
"""Test when adding a new member and email_send method raises EmailSendingError."""
|
||||
mock_send_email.side_effect = EmailSendingError("Failed to send email.")
|
||||
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
form_data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.new_member_email,
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
response = self.client.post(reverse("new-member"), data=form_data)
|
||||
|
||||
# Assert
|
||||
# assert that the send_portfolio_invitation_email called
|
||||
mock_send_email.assert_called_once_with(
|
||||
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
|
||||
)
|
||||
# assert that response is a redirect to reverse("members")
|
||||
self.assertRedirects(response, reverse("members"))
|
||||
# assert that messages contains message, "Could not send email invitation"
|
||||
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
|
||||
# assert that portfolio invitation is not created
|
||||
self.assertFalse(
|
||||
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
|
||||
"Portfolio invitation should not be created when an EmailSendingError occurs.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_submit_new_member_raises_missing_email_error(self, mock_send_email):
|
||||
"""Test when adding a new member and email_send method raises MissingEmailError."""
|
||||
mock_send_email.side_effect = MissingEmailError()
|
||||
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
form_data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.new_member_email,
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
response = self.client.post(reverse("new-member"), data=form_data)
|
||||
|
||||
# Assert
|
||||
# assert that the send_portfolio_invitation_email called
|
||||
mock_send_email.assert_called_once_with(
|
||||
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
|
||||
)
|
||||
# assert that response is a redirect to reverse("members")
|
||||
self.assertRedirects(response, reverse("members"))
|
||||
# assert that messages contains message, "Could not send email invitation"
|
||||
mock_error.assert_called_once_with(
|
||||
response.wsgi_request,
|
||||
"Can't send invitation email. No email is associated with your user account.",
|
||||
)
|
||||
# assert that portfolio invitation is not created
|
||||
self.assertFalse(
|
||||
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
|
||||
"Portfolio invitation should not be created when a MissingEmailError occurs.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_submit_new_member_raises_exception(self, mock_send_email):
|
||||
"""Test when adding a new member and email_send method raises Exception."""
|
||||
mock_send_email.side_effect = Exception("Generic exception")
|
||||
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
form_data = {
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.new_member_email,
|
||||
}
|
||||
|
||||
# Act
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
response = self.client.post(reverse("new-member"), data=form_data)
|
||||
|
||||
# Assert
|
||||
# assert that the send_portfolio_invitation_email called
|
||||
mock_send_email.assert_called_once_with(
|
||||
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
|
||||
)
|
||||
# assert that response is a redirect to reverse("members")
|
||||
self.assertRedirects(response, reverse("members"))
|
||||
# assert that messages contains message, "Could not send email invitation"
|
||||
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
|
||||
# assert that portfolio invitation is not created
|
||||
self.assertFalse(
|
||||
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
|
||||
"Portfolio invitation should not be created when an Exception occurs.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_member_invite_for_previously_invited_member(self, mock_send_email):
|
||||
"""Tests the member invitation flow for existing portfolio member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -2906,23 +3122,35 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"member_access_level": "basic",
|
||||
"basic_org_domain_request_permissions": "view_only",
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.invited_member_email,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302) # Redirects
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# TODO: verify messages
|
||||
# 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."
|
||||
),
|
||||
)
|
||||
|
||||
# Validate Database has not changed
|
||||
invite_count_after = PortfolioInvitation.objects.count()
|
||||
self.assertEqual(invite_count_after, invite_count_before)
|
||||
|
||||
# assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_invite_for_existing_member(self):
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_member_invite_for_existing_member(self, mock_send_email):
|
||||
"""Tests the member invitation flow for existing portfolio member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -2936,19 +3164,30 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"member_access_level": "basic",
|
||||
"basic_org_domain_request_permissions": "view_only",
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"email": self.user.email,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302) # Redirects
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# TODO: verify messages
|
||||
# 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."
|
||||
),
|
||||
)
|
||||
|
||||
# Validate Database has not changed
|
||||
invite_count_after = PortfolioInvitation.objects.count()
|
||||
self.assertEqual(invite_count_after, invite_count_before)
|
||||
|
||||
# assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
|
||||
class TestEditPortfolioMemberView(WebTest):
|
||||
"""Tests for the edit member page on portfolios"""
|
||||
|
@ -3089,7 +3328,13 @@ class TestEditPortfolioMemberView(WebTest):
|
|||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_admin_removing_own_admin_role(self):
|
||||
"""Tests an admin removing their own admin role redirects to home."""
|
||||
"""Tests an admin removing their own admin role redirects to home.
|
||||
|
||||
Removing the admin role will remove both view and edit members permissions.
|
||||
Note: The user can remove the edit members permissions but as long as they
|
||||
stay in admin role, they will at least still have view members permissions.
|
||||
"""
|
||||
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Get the user's admin permission
|
||||
|
|
114
src/registrar/utility/email_invitations.py
Normal file
114
src/registrar/utility/email_invitations.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
from django.conf import settings
|
||||
from registrar.models import DomainInvitation
|
||||
from registrar.utility.errors import (
|
||||
AlreadyDomainInvitedError,
|
||||
AlreadyDomainManagerError,
|
||||
MissingEmailError,
|
||||
OutsideOrgMemberError,
|
||||
)
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from registrar.utility.email import send_templated_email
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_domain_invitation_email(email: str, requestor, domain, is_member_of_different_org):
|
||||
"""
|
||||
Sends a domain invitation email to the specified address.
|
||||
|
||||
Raises exceptions for validation or email-sending issues.
|
||||
|
||||
Args:
|
||||
email (str): Email address of the recipient.
|
||||
requestor (User): The user initiating the invitation.
|
||||
domain (Domain): The domain object for which the invitation is being sent.
|
||||
is_member_of_different_org (bool): if an email belongs to a different org
|
||||
|
||||
Raises:
|
||||
MissingEmailError: If the requestor has no email associated with their account.
|
||||
AlreadyDomainManagerError: If the email corresponds to an existing domain manager.
|
||||
AlreadyDomainInvitedError: If an invitation has already been sent.
|
||||
OutsideOrgMemberError: If the requested_user is part of a different organization.
|
||||
EmailSendingError: If there is an error while sending the email.
|
||||
"""
|
||||
# Default email address for staff
|
||||
requestor_email = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# Check if the requestor is staff and has an email
|
||||
if not requestor.is_staff:
|
||||
if not requestor.email or requestor.email.strip() == "":
|
||||
raise MissingEmailError
|
||||
else:
|
||||
requestor_email = requestor.email
|
||||
|
||||
# Check if the recipient is part of a different organization
|
||||
# COMMENT: this does not account for multiple_portfolios flag being active
|
||||
if (
|
||||
flag_is_active_for_user(requestor, "organization_feature")
|
||||
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
||||
and is_member_of_different_org
|
||||
):
|
||||
raise OutsideOrgMemberError
|
||||
|
||||
# Check for an existing invitation
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=domain)
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
raise AlreadyDomainManagerError(email)
|
||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||
invite.update_cancellation_status()
|
||||
invite.save()
|
||||
else:
|
||||
raise AlreadyDomainInvitedError(email)
|
||||
except DomainInvitation.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Send the email
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email,
|
||||
context={
|
||||
"domain": domain,
|
||||
"requestor_email": requestor_email,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_portfolio_invitation_email(email: str, requestor, portfolio):
|
||||
"""
|
||||
Sends a portfolio member invitation email to the specified address.
|
||||
|
||||
Raises exceptions for validation or email-sending issues.
|
||||
|
||||
Args:
|
||||
email (str): Email address of the recipient
|
||||
requestor (User): The user initiating the invitation.
|
||||
portfolio (Portfolio): The portfolio object for which the invitation is being sent.
|
||||
|
||||
Raises:
|
||||
MissingEmailError: If the requestor has no email associated with their account.
|
||||
EmailSendingError: If there is an error while sending the email.
|
||||
"""
|
||||
|
||||
# Default email address for staff
|
||||
requestor_email = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# Check if the requestor is staff and has an email
|
||||
if not requestor.is_staff:
|
||||
if not requestor.email or requestor.email.strip() == "":
|
||||
raise MissingEmailError
|
||||
else:
|
||||
requestor_email = requestor.email
|
||||
|
||||
send_templated_email(
|
||||
"emails/portfolio_invitation.txt",
|
||||
"emails/portfolio_invitation_subject.txt",
|
||||
to_address=email,
|
||||
context={
|
||||
"portfolio": portfolio,
|
||||
"requestor_email": requestor_email,
|
||||
"email": email,
|
||||
},
|
||||
)
|
|
@ -23,6 +23,33 @@ class InvalidDomainError(ValueError):
|
|||
pass
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
"""Base exception for invitation-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AlreadyDomainManagerError(InvitationError):
|
||||
"""Raised when the user is already a manager for the domain."""
|
||||
|
||||
def __init__(self, email):
|
||||
super().__init__(f"{email} is already a manager for this domain.")
|
||||
|
||||
|
||||
class AlreadyDomainInvitedError(InvitationError):
|
||||
"""Raised when the user has already been invited to the domain."""
|
||||
|
||||
def __init__(self, email):
|
||||
super().__init__(f"{email} has already been invited to this domain.")
|
||||
|
||||
|
||||
class MissingEmailError(InvitationError):
|
||||
"""Raised when the requestor has no email associated with their account."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("Can't send invitation email. No email is associated with your user account.")
|
||||
|
||||
|
||||
class OutsideOrgMemberError(ValueError):
|
||||
"""
|
||||
Error raised when an org member tries adding a user from a different .gov org.
|
||||
|
|
|
@ -25,14 +25,17 @@ from registrar.models import (
|
|||
PortfolioInvitation,
|
||||
User,
|
||||
UserDomainRole,
|
||||
UserPortfolioPermission,
|
||||
PublicContact,
|
||||
)
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.enums import DefaultEmail
|
||||
from registrar.utility.errors import (
|
||||
AlreadyDomainInvitedError,
|
||||
AlreadyDomainManagerError,
|
||||
GenericError,
|
||||
GenericErrorCodes,
|
||||
MissingEmailError,
|
||||
NameserverError,
|
||||
NameserverErrorCodes as nsErrorCodes,
|
||||
DsDataError,
|
||||
|
@ -63,6 +66,7 @@ from epplibwrapper import (
|
|||
)
|
||||
|
||||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
||||
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
|
||||
from django import forms
|
||||
|
||||
|
@ -1100,7 +1104,10 @@ class DomainUsersView(DomainBaseView):
|
|||
|
||||
# If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True
|
||||
for portfolio_invitation in portfolio_invitations:
|
||||
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
|
||||
if (
|
||||
portfolio_invitation.roles
|
||||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles
|
||||
):
|
||||
has_admin_flag = True
|
||||
break # Once we find one match, no need to check further
|
||||
|
||||
|
@ -1142,170 +1149,172 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
def get_success_url(self):
|
||||
return reverse("domain-users", kwargs={"pk": self.object.pk})
|
||||
|
||||
def _domain_abs_url(self):
|
||||
"""Get an absolute URL for this domain."""
|
||||
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
|
||||
def _get_org_membership(self, requestor_org, requested_email, requested_user):
|
||||
"""
|
||||
Verifies if an email belongs to a different organization as a member or invited member.
|
||||
Verifies if an email belongs to this organization as a member or invited member.
|
||||
User does not belong to any org can be deduced from the tuple returned.
|
||||
|
||||
def _is_member_of_different_org(self, email, requestor, requested_user):
|
||||
"""Verifies if an email belongs to a different organization as a member or invited member."""
|
||||
# Check if user is a already member of a different organization than the requestor's org
|
||||
requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio
|
||||
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
|
||||
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
|
||||
|
||||
return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or (
|
||||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
||||
)
|
||||
|
||||
def _check_invite_status(self, invite, email):
|
||||
"""Check if invitation status is canceled or retrieved, and gives the appropiate response"""
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email} is already a manager for this domain.",
|
||||
)
|
||||
return False
|
||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||
invite.update_cancellation_status()
|
||||
invite.save()
|
||||
return True
|
||||
else:
|
||||
# else if it has been sent but not accepted
|
||||
messages.warning(self.request, f"{email} has already been invited to this domain")
|
||||
return False
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||
"""Performs the sending of the domain invitation email,
|
||||
does not make a domain information object
|
||||
email: string- email to send to
|
||||
add_success: bool- default True indicates:
|
||||
adding a success message to the view if the email sending succeeds
|
||||
|
||||
raises EmailSendingError
|
||||
Returns a tuple (member_of_a_different_org, member_of_this_org).
|
||||
"""
|
||||
|
||||
# Set a default email address to send to for staff
|
||||
requestor_email = settings.DEFAULT_FROM_EMAIL
|
||||
# COMMENT: this code does not take into account multiple portfolios flag
|
||||
|
||||
# Check if the email requestor has a valid email address
|
||||
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
|
||||
requestor_email = requestor.email
|
||||
elif not requestor.is_staff:
|
||||
messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
|
||||
logger.error(
|
||||
f"Can't send email to '{email}' on domain '{self.object}'."
|
||||
f"No email exists for the requestor '{requestor.username}'.",
|
||||
exc_info=True,
|
||||
)
|
||||
# COMMENT: shouldn't this code be based on the organization of the domain, not the org
|
||||
# of the requestor? requestor could have multiple portfolios
|
||||
|
||||
# Check for existing permissions or invitations for the requested user
|
||||
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
|
||||
existing_org_invitation = PortfolioInvitation.objects.filter(email=requested_email).first()
|
||||
|
||||
# Determine membership in a different organization
|
||||
member_of_a_different_org = (
|
||||
existing_org_permission and existing_org_permission.portfolio != requestor_org
|
||||
) or (existing_org_invitation and existing_org_invitation.portfolio != requestor_org)
|
||||
|
||||
# Determine membership in the same organization
|
||||
member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == requestor_org) or (
|
||||
existing_org_invitation and existing_org_invitation.portfolio == requestor_org
|
||||
)
|
||||
|
||||
return member_of_a_different_org, member_of_this_org
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Add the specified user to this domain."""
|
||||
requested_email = form.cleaned_data["email"]
|
||||
requestor = self.request.user
|
||||
|
||||
# Look up a user with that email
|
||||
requested_user = self._get_requested_user(requested_email)
|
||||
# NOTE: This does not account for multiple portfolios flag being set to True
|
||||
domain_org = self.object.domain_info.portfolio
|
||||
|
||||
# requestor can only send portfolio invitations if they are staff or if they are a member
|
||||
# of the domain's portfolio
|
||||
requestor_can_update_portfolio = (
|
||||
UserPortfolioPermission.objects.filter(user=requestor, portfolio=domain_org).first() is not None
|
||||
or requestor.is_staff
|
||||
)
|
||||
|
||||
member_of_a_different_org, member_of_this_org = self._get_org_membership(
|
||||
domain_org, requested_email, requested_user
|
||||
)
|
||||
|
||||
# determine portfolio of the domain (code currently is looking at requestor's portfolio)
|
||||
# if requested_email/user is not member or invited member of this portfolio
|
||||
# COMMENT: this code does not take into account multiple portfolios flag
|
||||
# send portfolio invitation email
|
||||
# create portfolio invitation
|
||||
# create message to view
|
||||
if (
|
||||
flag_is_active_for_user(requestor, "organization_feature")
|
||||
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
||||
and domain_org is not None
|
||||
and requestor_can_update_portfolio
|
||||
and not member_of_this_org
|
||||
):
|
||||
try:
|
||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
|
||||
PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=domain_org)
|
||||
messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}")
|
||||
except Exception as e:
|
||||
self._handle_portfolio_exceptions(e, requested_email, domain_org)
|
||||
# If that first invite does not succeed take an early exit
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
try:
|
||||
if requested_user is None:
|
||||
self._handle_new_user_invitation(requested_email, requestor, member_of_a_different_org)
|
||||
else:
|
||||
self._handle_existing_user(requested_email, requestor, requested_user, member_of_a_different_org)
|
||||
except Exception as e:
|
||||
self._handle_exceptions(e, requested_email)
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def _get_requested_user(self, email):
|
||||
"""Retrieve a user by email or return None if the user doesn't exist."""
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
# Check is user is a member or invited member of a different org from this domain's org
|
||||
if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org(
|
||||
email, requestor, requested_user
|
||||
):
|
||||
add_success = False
|
||||
raise OutsideOrgMemberError
|
||||
def _handle_new_user_invitation(self, email, requestor, member_of_different_org):
|
||||
"""Handle invitation for a new user who does not exist in the system."""
|
||||
send_domain_invitation_email(
|
||||
email=email,
|
||||
requestor=requestor,
|
||||
domain=self.object,
|
||||
is_member_of_different_org=member_of_different_org,
|
||||
)
|
||||
DomainInvitation.objects.get_or_create(email=email, domain=self.object)
|
||||
messages.success(self.request, f"{email} has been invited to the domain: {self.object}")
|
||||
|
||||
# Check to see if an invite has already been sent
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||
# check if the invite has already been accepted or has a canceled invite
|
||||
add_success = self._check_invite_status(invite, email)
|
||||
except Exception:
|
||||
logger.error("An error occured")
|
||||
def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org):
|
||||
"""Handle adding an existing user to the domain."""
|
||||
send_domain_invitation_email(
|
||||
email=email,
|
||||
requestor=requestor,
|
||||
domain=self.object,
|
||||
is_member_of_different_org=member_of_different_org,
|
||||
)
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
messages.success(self.request, f"Added user {email}.")
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email,
|
||||
context={
|
||||
"domain_url": self._domain_abs_url(),
|
||||
"domain": self.object,
|
||||
"requestor_email": requestor_email,
|
||||
},
|
||||
)
|
||||
except EmailSendingError as exc:
|
||||
logger.warn(
|
||||
"Could not sent email invitation to %s for domain %s",
|
||||
def _handle_exceptions(self, exception, email):
|
||||
"""Handle exceptions raised during the process."""
|
||||
if isinstance(exception, EmailSendingError):
|
||||
logger.warning(
|
||||
"Could not send email invitation to %s for domain %s (EmailSendingError)",
|
||||
email,
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(exc)
|
||||
raise EmailSendingError("Could not send email invitation.") from exc
|
||||
else:
|
||||
if add_success:
|
||||
messages.success(self.request, f"{email} has been invited to this domain.")
|
||||
|
||||
def _make_invitation(self, email_address: str, requestor: User):
|
||||
"""Make a Domain invitation for this email and redirect with a message."""
|
||||
try:
|
||||
self._send_domain_invitation_email(email=email_address, requestor=requestor)
|
||||
except EmailSendingError:
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
elif isinstance(exception, OutsideOrgMemberError):
|
||||
logger.warning(
|
||||
"Could not send email. Can not invite member of a .gov organization to a different organization.",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.error(
|
||||
self.request,
|
||||
f"{email} is already a member of another .gov organization.",
|
||||
)
|
||||
elif isinstance(exception, AlreadyDomainManagerError):
|
||||
messages.warning(self.request, str(exception))
|
||||
elif isinstance(exception, AlreadyDomainInvitedError):
|
||||
messages.warning(self.request, str(exception))
|
||||
elif isinstance(exception, MissingEmailError):
|
||||
messages.error(self.request, str(exception))
|
||||
logger.error(
|
||||
f"Can't send email to '{email}' on domain '{self.object}'. No email exists for the requestor.",
|
||||
exc_info=True,
|
||||
)
|
||||
elif isinstance(exception, IntegrityError):
|
||||
messages.warning(self.request, f"{email} is already a manager for this domain")
|
||||
else:
|
||||
# (NOTE: only create a domainInvitation if the e-mail sends correctly)
|
||||
DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
|
||||
return redirect(self.get_success_url())
|
||||
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Add the specified user on this domain.
|
||||
Throws EmailSendingError."""
|
||||
requested_email = form.cleaned_data["email"]
|
||||
requestor = self.request.user
|
||||
email_success = False
|
||||
# look up a user with that email
|
||||
try:
|
||||
requested_user = User.objects.get(email=requested_email)
|
||||
except User.DoesNotExist:
|
||||
# no matching user, go make an invitation
|
||||
email_success = True
|
||||
return self._make_invitation(requested_email, requestor)
|
||||
def _handle_portfolio_exceptions(self, exception, email, portfolio):
|
||||
"""Handle exceptions raised during the process."""
|
||||
if isinstance(exception, EmailSendingError):
|
||||
logger.warning("Could not send email invitation (EmailSendingError)", exc_info=True)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
elif isinstance(exception, MissingEmailError):
|
||||
messages.error(self.request, str(exception))
|
||||
logger.error(
|
||||
f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
# if user already exists then just send an email
|
||||
try:
|
||||
self._send_domain_invitation_email(
|
||||
requested_email, requestor, requested_user=requested_user, add_success=False
|
||||
)
|
||||
email_success = True
|
||||
except EmailSendingError:
|
||||
logger.warn(
|
||||
"Could not send email invitation (EmailSendingError)",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
email_success = True
|
||||
except OutsideOrgMemberError:
|
||||
logger.warn(
|
||||
"Could not send email. Can not invite member of a .gov organization to a different organization.",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.error(
|
||||
self.request,
|
||||
f"{requested_email} is already a member of another .gov organization.",
|
||||
)
|
||||
except Exception:
|
||||
logger.warn(
|
||||
"Could not send email invitation (Other Exception)",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
if email_success:
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
messages.success(self.request, f"Added user {requested_email}.")
|
||||
except IntegrityError:
|
||||
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
|
||||
|
||||
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import json
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
@ -15,6 +14,8 @@ from registrar.models.user_domain_role import UserDomainRole
|
|||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.email_invitations import send_portfolio_invitation_email
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from registrar.utility.enums import DefaultUserValues
|
||||
from registrar.views.utility.mixins import PortfolioMemberPermission
|
||||
from registrar.views.utility.permission_views import (
|
||||
|
@ -150,7 +151,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
|
|||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = portfolioForms.BasePortfolioMemberForm
|
||||
form_class = portfolioForms.PortfolioMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
|
@ -169,13 +170,14 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
|||
|
||||
def post(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
user_initially_is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
|
||||
user = portfolio_permission.user
|
||||
form = self.form_class(request.POST, instance=portfolio_permission)
|
||||
if form.is_valid():
|
||||
# Check if user is removing their own admin or edit role
|
||||
removing_admin_role_on_self = (
|
||||
request.user == user
|
||||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
|
||||
and user_initially_is_admin
|
||||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
|
||||
)
|
||||
form.save()
|
||||
|
@ -369,7 +371,7 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
|
|||
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = portfolioForms.BasePortfolioMemberForm
|
||||
form_class = portfolioForms.PortfolioInvitedMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
|
@ -694,34 +696,27 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
|
|||
return render(request, "portfolio_members.html")
|
||||
|
||||
|
||||
class NewMemberView(PortfolioMembersPermissionView, FormMixin):
|
||||
class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
|
||||
|
||||
template_name = "portfolio_members_add_new.html"
|
||||
form_class = portfolioForms.NewMemberForm
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""Get the portfolio object based on the session."""
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if portfolio is None:
|
||||
raise Http404("No organization found for this user")
|
||||
return portfolio
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Include the instance in the form kwargs."""
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["instance"] = self.get_object()
|
||||
return kwargs
|
||||
form_class = portfolioForms.PortfolioNewMemberForm
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Handle GET requests to display the form."""
|
||||
self.object = self.get_object()
|
||||
self.object = None # No existing PortfolioInvitation instance
|
||||
form = self.get_form()
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Handle POST requests to process form submission."""
|
||||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
self.object = None # For a new invitation, there's no existing model instance
|
||||
|
||||
# portfolio not submitted with form, so override the value
|
||||
data = request.POST.copy()
|
||||
if not data.get("portfolio"):
|
||||
data["portfolio"] = self.request.session.get("portfolio").id
|
||||
# Pass the modified data to the form
|
||||
form = portfolioForms.PortfolioNewMemberForm(data)
|
||||
|
||||
if form.is_valid():
|
||||
return self.form_valid(form)
|
||||
|
@ -738,7 +733,7 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin):
|
|||
return super().form_invalid(form) # Handle non-AJAX requests normally
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
super().form_valid(form)
|
||||
if self.is_ajax():
|
||||
return JsonResponse({"is_valid": True}) # Return a JSON response
|
||||
else:
|
||||
|
@ -748,108 +743,42 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin):
|
|||
"""Redirect to members table."""
|
||||
return reverse("members")
|
||||
|
||||
def _send_portfolio_invitation_email(self, email: str, requestor: User, add_success=True):
|
||||
"""Performs the sending of the member invitation email
|
||||
email: string- email to send to
|
||||
add_success: bool- default True indicates:
|
||||
adding a success message to the view if the email sending succeeds
|
||||
|
||||
raises EmailSendingError
|
||||
"""
|
||||
|
||||
# Set a default email address to send to for staff
|
||||
requestor_email = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# Check if the email requestor has a valid email address
|
||||
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
|
||||
requestor_email = requestor.email
|
||||
elif not requestor.is_staff:
|
||||
messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
|
||||
logger.error(
|
||||
f"Can't send email to '{email}' on domain '{self.object}'."
|
||||
f"No email exists for the requestor '{requestor.username}'.",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
# Check to see if an invite has already been sent
|
||||
try:
|
||||
invite = PortfolioInvitation.objects.get(email=email, portfolio=self.object)
|
||||
if invite: # We have an existin invite
|
||||
# check if the invite has already been accepted
|
||||
if invite.status == PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED:
|
||||
add_success = False
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email} is already a manager for this portfolio.",
|
||||
)
|
||||
else:
|
||||
add_success = False
|
||||
# it has been sent but not accepted
|
||||
messages.warning(self.request, f"{email} has already been invited to this portfolio")
|
||||
return
|
||||
except Exception as err:
|
||||
logger.error(f"_send_portfolio_invitation_email() => An error occured: {err}")
|
||||
|
||||
try:
|
||||
logger.debug("requestor email: " + requestor_email)
|
||||
|
||||
# send_templated_email(
|
||||
# "emails/portfolio_invitation.txt",
|
||||
# "emails/portfolio_invitation_subject.txt",
|
||||
# to_address=email,
|
||||
# context={
|
||||
# "portfolio": self.object,
|
||||
# "requestor_email": requestor_email,
|
||||
# },
|
||||
# )
|
||||
except EmailSendingError as exc:
|
||||
logger.warn(
|
||||
"Could not sent email invitation to %s for domain %s",
|
||||
email,
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
raise EmailSendingError("Could not send email invitation.") from exc
|
||||
else:
|
||||
if add_success:
|
||||
messages.success(self.request, f"{email} has been invited.")
|
||||
|
||||
def _make_invitation(self, email_address: str, requestor: User, add_success=True):
|
||||
"""Make a Member invitation for this email and redirect with a message."""
|
||||
try:
|
||||
self._send_portfolio_invitation_email(email=email_address, requestor=requestor, add_success=add_success)
|
||||
except EmailSendingError:
|
||||
logger.warn(
|
||||
"Could not send email invitation (EmailSendingError)",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
except Exception:
|
||||
logger.warn(
|
||||
"Could not send email invitation (Other Exception)",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
else:
|
||||
# (NOTE: only create a MemberInvitation if the e-mail sends correctly)
|
||||
PortfolioInvitation.objects.get_or_create(email=email_address, portfolio=self.object)
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def submit_new_member(self, form):
|
||||
"""Add the specified user as a member
|
||||
for this portfolio.
|
||||
Throws EmailSendingError."""
|
||||
"""Add the specified user as a member for this portfolio."""
|
||||
requested_email = form.cleaned_data["email"]
|
||||
requestor = self.request.user
|
||||
portfolio = form.cleaned_data["portfolio"]
|
||||
|
||||
requested_user = User.objects.filter(email=requested_email).first()
|
||||
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists()
|
||||
if not requested_user or not permission_exists:
|
||||
return self._make_invitation(requested_email, requestor)
|
||||
else:
|
||||
if permission_exists:
|
||||
messages.warning(self.request, "User is already a member of this portfolio.")
|
||||
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
|
||||
try:
|
||||
if not requested_user or not permission_exists:
|
||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
|
||||
form.save()
|
||||
messages.success(self.request, f"{requested_email} has been invited.")
|
||||
else:
|
||||
if permission_exists:
|
||||
messages.warning(self.request, "User is already a member of this portfolio.")
|
||||
except Exception as e:
|
||||
self._handle_exceptions(e, portfolio, requested_email)
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def _handle_exceptions(self, exception, portfolio, email):
|
||||
"""Handle exceptions raised during the process."""
|
||||
if isinstance(exception, EmailSendingError):
|
||||
logger.warning(
|
||||
"Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
|
||||
email,
|
||||
portfolio,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
elif isinstance(exception, MissingEmailError):
|
||||
messages.error(self.request, str(exception))
|
||||
logger.error(
|
||||
f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue