Skip to content

Commit

Permalink
Merge pull request #3252 from cisagov/bob/3019-portfolio-invitation-e…
Browse files Browse the repository at this point in the history
…mail

#3019: Portfolio and domain invitation emails
  • Loading branch information
dave-kennedy-ecs authored Jan 3, 2025
2 parents d9c7b1c + 8b29171 commit 1745f6a
Show file tree
Hide file tree
Showing 22 changed files with 1,500 additions and 605 deletions.
122 changes: 120 additions & 2 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
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
Expand All @@ -37,7 +41,7 @@
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
Expand Down Expand Up @@ -1312,6 +1316,8 @@ class Meta:
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)
Expand Down Expand Up @@ -1468,7 +1474,7 @@ class Meta:

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):
Expand All @@ -1478,6 +1484,118 @@ def changelist_view(self, request, extra_context=None):
# 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
Expand Down
10 changes: 5 additions & 5 deletions src/registrar/assets/src/js/getgov/portfolio-member-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'
}
);
}
Expand Down
5 changes: 5 additions & 0 deletions src/registrar/assets/src/sass/_theme/_alerts.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 3 additions & 2 deletions src/registrar/assets/src/sass/_theme/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down Expand Up @@ -265,4 +266,4 @@ abbr[title] {
margin: 0;
height: 1.5em;
width: 1.5em;
}
}
4 changes: 4 additions & 0 deletions src/registrar/assets/src/sass/_theme/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
2 changes: 1 addition & 1 deletion src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
# ),
path(
"members/new-member/",
views.NewMemberView.as_view(),
views.PortfolioAddMemberView.as_view(),
name="new-member",
),
path(
Expand Down
Loading

0 comments on commit 1745f6a

Please sign in to comment.