Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#3019: Portfolio and domain invitation emails [AD] #3252

Merged
merged 36 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a2e6238
wip
rachidatecs Dec 12, 2024
d766aa9
refactored _send_domain_invitation_email in domain.py view
dave-kennedy-ecs Dec 13, 2024
d2d44bf
first refactor attempt, moving send_domain_invitation_email to a helper
dave-kennedy-ecs Dec 13, 2024
b4826ea
refactored form_valid to break into smaller chunks (to satisfy linter)
dave-kennedy-ecs Dec 13, 2024
dd32cb9
portfolio invitation emails refactored and implemented in helper
dave-kennedy-ecs Dec 13, 2024
ddf7d92
moving around logic, prepping for refactor
dave-kennedy-ecs Dec 16, 2024
d888c61
wip
dave-kennedy-ecs Dec 16, 2024
f09627b
work in progress tracing current form implementation
dave-kennedy-ecs Dec 16, 2024
060f2d5
refactor form and view for add portfolio member
rachidatecs Dec 16, 2024
ae3ce1f
wip
dave-kennedy-ecs Dec 16, 2024
7bbee19
portfolio invitations working in admin view as well as user view
dave-kennedy-ecs Dec 17, 2024
5cb6c3f
error cleanup
dave-kennedy-ecs Dec 17, 2024
d2343b7
static content on invitation and permissions change forms
rachidatecs Dec 17, 2024
7658810
merge main and integrate ModelForm for PortfolioInvitation
dave-kennedy-ecs Dec 19, 2024
08b985d
PortfolioMemberForm inheriting from BasePortfolioMemberForm
dave-kennedy-ecs Dec 19, 2024
be657e1
new portfolio member form inheriting from base
dave-kennedy-ecs Dec 19, 2024
4e4e2c0
Fix background color bug
rachidatecs Dec 19, 2024
1fe97eb
a little bit of linting
dave-kennedy-ecs Dec 20, 2024
955d0a7
cleanup and comments
dave-kennedy-ecs Dec 20, 2024
d27352b
Fix broken tests
rachidatecs Dec 20, 2024
1b16d6d
portfolio views tests
dave-kennedy-ecs Dec 21, 2024
8326bbb
tests on admin and forms
rachidatecs Dec 21, 2024
f2bd0ad
merge
rachidatecs Dec 21, 2024
d36e2e2
domain manager tests and linted
dave-kennedy-ecs Dec 21, 2024
d7493cc
friendlier error message
dave-kennedy-ecs Dec 30, 2024
a8340f5
Merge branch 'main' into bob/3019-portfolio-invitation-email
dave-kennedy-ecs Dec 30, 2024
267364e
fixed a test
dave-kennedy-ecs Dec 30, 2024
e845fe1
linted
dave-kennedy-ecs Dec 30, 2024
b86d8bc
Merge branch 'main' into bob/3019-portfolio-invitation-email
dave-kennedy-ecs Dec 31, 2024
2007daa
performed some cleanup
dave-kennedy-ecs Dec 31, 2024
b39c143
simplified logic on check for existing permission in admin
dave-kennedy-ecs Jan 2, 2025
0c07e93
more cleanup in admin
dave-kennedy-ecs Jan 2, 2025
c5325a6
updated logic to account for domain portfolio assignment
dave-kennedy-ecs Jan 2, 2025
c70a16d
fixed tests
dave-kennedy-ecs Jan 3, 2025
84f6445
merge of main
dave-kennedy-ecs Jan 3, 2025
8b29171
removed some extraneous log statements
dave-kennedy-ecs Jan 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 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"
dave-kennedy-ecs marked this conversation as resolved.
Show resolved Hide resolved

# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
Expand All @@ -1478,6 +1484,120 @@ 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.
dave-kennedy-ecs marked this conversation as resolved.
Show resolved Hide resolved
"""
if not change: # Only send email if this is a new PortfolioInvitation (creation)
portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user

requested_user = User.objects.filter(email=requested_email).first()
permission_exists = UserPortfolioPermission.objects.filter(
user=requested_user, portfolio=portfolio
).exists()
try:
if not requested_user or not permission_exists:
dave-kennedy-ecs marked this conversation as resolved.
Show resolved Hide resolved
# if requested user does not exist or permission does not exist, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
messages.success(request, f"{requested_email} has been invited.")
elif permission_exists:
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
opts = self.model._meta
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non blocking question why "opts"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If confusing, can change. Was modeling code after super class, django.contrib.admin.options.ModelAdmin, in which many of their methods get the options from _meta.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dave-kennedy-ecs It looks like opts is being used on line 1673 after pulling from obj (its using obj._meta instead of self.model._meta). The value for the var definition here is never actually being used, only the _meta property from obj. Seems like a typo and makes it a bit hard to follow -- can you delete the opts definition on this line?


# 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 = {
dave-kennedy-ecs marked this conversation as resolved.
Show resolved Hide resolved
**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');
dave-kennedy-ecs marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading