Skip to content

Commit 6989af9

Browse files
feat: Invite braze functionality and delete soft admin correction
1 parent 4b6b721 commit 6989af9

File tree

14 files changed

+3406
-86
lines changed

14 files changed

+3406
-86
lines changed

enterprise/api/utils.py

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,394 @@
11
"""
22
Utility functions for the Enterprise API.
33
"""
4+
import logging
5+
from typing import List, Optional, Set, Tuple
6+
47
from django.conf import settings
58
from django.contrib import auth
9+
from django.db import DatabaseError, transaction
10+
from django.db.models import F
11+
from django.db.models.functions import Lower
612
from django.utils.translation import gettext as _
713

814
from enterprise.constants import (
15+
BRAZE_ADMIN_INVITE_CAMPAIGN_SETTING,
16+
BRAZE_LEARNER_INVITE_CAMPAIGN_SETTING,
17+
ENTERPRISE_ADMIN_ROLE,
918
ENTERPRISE_CATALOG_ADMIN_ROLE,
1019
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
1120
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
21+
AdminInviteStatus,
1222
)
1323
from enterprise.models import (
1424
EnterpriseCustomer,
25+
EnterpriseCustomerAdmin,
1526
EnterpriseCustomerCatalog,
1627
EnterpriseCustomerInviteKey,
1728
EnterpriseCustomerReportingConfiguration,
1829
EnterpriseCustomerUser,
1930
EnterpriseFeatureRole,
2031
EnterpriseFeatureUserRoleAssignment,
2132
EnterpriseGroup,
33+
PendingEnterpriseCustomerAdminUser,
34+
SystemWideEnterpriseUserRoleAssignment,
2235
)
36+
from enterprise.tasks import send_enterprise_admin_invite_email
37+
38+
logger = logging.getLogger(__name__)
39+
40+
41+
def get_existing_admin_emails(enterprise_customer: EnterpriseCustomer) -> Set[str]:
42+
"""
43+
Retrieve normalized email addresses of existing ACTIVE enterprise admins.
44+
45+
Only includes admins who have:
46+
1. An EnterpriseCustomerAdmin record
47+
2. An active EnterpriseCustomerUser (active=True)
48+
3. An active admin role assignment in SystemWideEnterpriseUserRoleAssignment
49+
50+
Args:
51+
enterprise_customer: The enterprise customer instance.
52+
53+
Returns:
54+
Set of lowercased email addresses of active admins with valid role assignments.
55+
56+
Raises:
57+
DatabaseError: If database query fails.
58+
59+
Example:
60+
>>> emails = get_existing_admin_emails(customer)
61+
>>> 'admin@example.com' in emails
62+
True
63+
"""
64+
try:
65+
# Get user IDs with active admin role assignments
66+
users_with_admin_role = set(
67+
SystemWideEnterpriseUserRoleAssignment.objects.filter(
68+
enterprise_customer=enterprise_customer,
69+
role__name=ENTERPRISE_ADMIN_ROLE,
70+
).values_list('user_id', flat=True)
71+
)
72+
73+
# Return emails of admins who have active ECU AND active role assignment
74+
return set(
75+
EnterpriseCustomerAdmin.objects.filter(
76+
enterprise_customer_user__enterprise_customer=enterprise_customer,
77+
enterprise_customer_user__active=True,
78+
enterprise_customer_user__user_id__in=users_with_admin_role,
79+
)
80+
.annotate(email_l=Lower(F("enterprise_customer_user__user_fk__email")))
81+
.values_list("email_l", flat=True)
82+
)
83+
except DatabaseError:
84+
logger.exception(
85+
"Database error retrieving existing admin emails for enterprise customer: %s",
86+
enterprise_customer.uuid,
87+
)
88+
raise
89+
90+
91+
def get_inactive_admin_emails(enterprise_customer: EnterpriseCustomer) -> Tuple[Set[str], Set[str]]:
92+
"""
93+
Retrieve normalized email addresses of inactive enterprise admins.
94+
95+
This includes two categories:
96+
97+
1. Soft-deleted admins: EnterpriseCustomerUser has been deactivated (active=False)
98+
2. Role-removed admins: EnterpriseCustomerUser is still active but admin role assignment
99+
was removed from SystemWideEnterpriseUserRoleAssignment
100+
101+
Both cases occur when delete_admin endpoint is called with different outcomes
102+
based on whether the user has other roles.
103+
104+
Args:
105+
enterprise_customer: The enterprise customer instance.
106+
107+
Returns:
108+
Tuple of (soft_deleted_emails, role_removed_emails):
109+
- soft_deleted_emails: Set of lowercased emails for deactivated admins
110+
- role_removed_emails: Set of lowercased emails for active users with removed admin role
111+
112+
Raises:
113+
DatabaseError: If database query fails.
114+
115+
Example:
116+
>>> soft_deleted, role_removed = get_inactive_admin_emails(customer)
117+
>>> 'former_admin@example.com' in role_removed
118+
True
119+
"""
120+
try:
121+
soft_deleted_emails: Set[str] = set()
122+
role_removed_emails: Set[str] = set()
123+
124+
# Get all EnterpriseCustomerAdmin records for this customer
125+
admin_records = EnterpriseCustomerAdmin.objects.filter(
126+
enterprise_customer_user__enterprise_customer=enterprise_customer,
127+
).select_related('enterprise_customer_user__user_fk')
128+
129+
# Early return if no admin records exist
130+
if not admin_records:
131+
return soft_deleted_emails, role_removed_emails
132+
133+
# Batch fetch all user IDs with active admin role assignments to avoid N+1 queries
134+
admin_user_ids: List[int] = [
135+
admin.enterprise_customer_user.user_id
136+
for admin in admin_records
137+
if admin.enterprise_customer_user.user_id is not None
138+
]
139+
140+
users_with_admin_role: Set[int] = set()
141+
if admin_user_ids:
142+
users_with_admin_role = set(
143+
SystemWideEnterpriseUserRoleAssignment.objects.filter(
144+
user_id__in=admin_user_ids,
145+
enterprise_customer=enterprise_customer,
146+
role__name=ENTERPRISE_ADMIN_ROLE,
147+
).values_list('user_id', flat=True)
148+
)
149+
150+
# Check each admin record
151+
for admin in admin_records:
152+
ecu = admin.enterprise_customer_user
153+
154+
# Skip if user relationship is missing (data integrity issue)
155+
if not ecu or not ecu.user_fk or not ecu.user_id:
156+
logger.warning(
157+
"EnterpriseCustomerAdmin id=%s has missing user relationship for enterprise %s",
158+
admin.id,
159+
enterprise_customer.uuid
160+
)
161+
continue
162+
163+
user = ecu.user_fk
164+
165+
# Case 1: Soft-deleted (EnterpriseCustomerUser deactivated)
166+
if not ecu.active:
167+
soft_deleted_emails.add(user.email.lower())
168+
continue
169+
170+
# Case 2: Active EnterpriseCustomerUser but no admin role assignment
171+
if ecu.user_id not in users_with_admin_role:
172+
role_removed_emails.add(user.email.lower())
173+
174+
return soft_deleted_emails, role_removed_emails
175+
176+
except DatabaseError:
177+
logger.exception(
178+
"Database error retrieving inactive admin emails for enterprise customer: %s",
179+
enterprise_customer.uuid,
180+
)
181+
raise
182+
183+
184+
def get_existing_pending_emails(
185+
enterprise_customer: EnterpriseCustomer,
186+
normalized_emails: List[str]
187+
) -> Set[str]:
188+
"""
189+
Retrieve normalized email addresses of pending admin invitations.
190+
191+
Args:
192+
enterprise_customer: The enterprise customer instance.
193+
normalized_emails: List of normalized email addresses to check.
194+
195+
Returns:
196+
Set of lowercased email addresses that have pending invitations.
197+
198+
Raises:
199+
DatabaseError: If database query fails.
200+
201+
Example:
202+
>>> pending = get_existing_pending_emails(customer, ['user@example.com'])
203+
>>> 'user@example.com' in pending
204+
True
205+
"""
206+
try:
207+
return set(
208+
PendingEnterpriseCustomerAdminUser.objects.filter(
209+
enterprise_customer=enterprise_customer,
210+
)
211+
.annotate(email_l=Lower(F("user_email")))
212+
.filter(email_l__in=normalized_emails)
213+
.values_list("email_l", flat=True)
214+
)
215+
except DatabaseError:
216+
logger.exception(
217+
"Database error retrieving existing pending emails for enterprise customer: %s",
218+
enterprise_customer.uuid,
219+
)
220+
raise
221+
222+
223+
def create_pending_invites(
224+
enterprise_customer: EnterpriseCustomer,
225+
emails_to_invite: List[str]
226+
) -> List[PendingEnterpriseCustomerAdminUser]:
227+
"""
228+
Create pending admin invitations and trigger email notifications.
229+
230+
Creates PendingEnterpriseCustomerAdminUser records for new admin invites
231+
and enqueues Braze email tasks to be sent after transaction commits.
232+
233+
Args:
234+
enterprise_customer: The enterprise customer instance.
235+
emails_to_invite: List of normalized email addresses to invite.
236+
237+
Returns:
238+
List of created PendingEnterpriseCustomerAdminUser instances.
239+
240+
Raises:
241+
DatabaseError: If database operation fails.
242+
ValueError: If emails_to_invite is empty.
243+
RuntimeError: If called outside a transaction.atomic block.
244+
245+
Note:
246+
- Caller must wrap in transaction.atomic() to ensure atomicity
247+
- Uses get_or_create per email to avoid duplicate invite emails in race conditions
248+
- Emails are queued via transaction.on_commit() to send after transaction commits
249+
- Emails are routed to different Braze campaigns based on EnterpriseCustomerUser existence
250+
determined at invite creation time (before transaction commits)
251+
- This ensures emails only send if database changes succeed
252+
253+
Example:
254+
>>> with transaction.atomic():
255+
... invites = create_pending_invites(customer, ['new@example.com'])
256+
>>> len(invites) > 0
257+
True
258+
"""
259+
if not emails_to_invite:
260+
raise ValueError("emails_to_invite cannot be empty")
261+
262+
if not transaction.get_connection().in_atomic_block:
263+
raise RuntimeError("create_pending_invites must be called inside transaction.atomic().")
264+
265+
try:
266+
# Query existing EnterpriseCustomerUsers BEFORE creating invites to determine routing
267+
# This prevents race conditions where ECU is created between invite and email sending
268+
existing_ecu_emails = set(
269+
EnterpriseCustomerUser.objects.filter(
270+
enterprise_customer=enterprise_customer,
271+
active=True, # Only include active ECU records (exclude soft-deleted admins)
272+
user_id__isnull=False, # Ensure user exists
273+
user_fk__is_active=True, # Only include active users
274+
).select_related('user_fk').annotate(
275+
email_lower=Lower('user_fk__email')
276+
).filter(
277+
email_lower__in=emails_to_invite
278+
).values_list('email_lower', flat=True)
279+
)
280+
281+
created_invites = []
282+
for email in emails_to_invite:
283+
pending_invite, created = PendingEnterpriseCustomerAdminUser.objects.get_or_create(
284+
enterprise_customer=enterprise_customer,
285+
user_email=email,
286+
)
287+
if created:
288+
created_invites.append(pending_invite)
289+
290+
def _enqueue_email_jobs():
291+
"""Enqueue invite emails, routing to appropriate Braze campaigns."""
292+
if not created_invites:
293+
return
294+
295+
try:
296+
created_invite_emails = [invite.user_email for invite in created_invites]
297+
298+
# Split emails based on pre-determined ECU existence
299+
# Using existing_ecu_emails captured before transaction to avoid race conditions
300+
learner_emails = []
301+
new_admin_emails = []
302+
for email in created_invite_emails:
303+
if email in existing_ecu_emails:
304+
learner_emails.append(email)
305+
else:
306+
new_admin_emails.append(email)
307+
308+
# Send to existing learners with learner campaign
309+
if learner_emails:
310+
send_enterprise_admin_invite_email.delay(
311+
str(enterprise_customer.uuid),
312+
learner_emails,
313+
campaign_setting_name=BRAZE_LEARNER_INVITE_CAMPAIGN_SETTING
314+
)
315+
316+
# Send to new admins with admin campaign
317+
if new_admin_emails:
318+
send_enterprise_admin_invite_email.delay(
319+
str(enterprise_customer.uuid),
320+
new_admin_emails,
321+
campaign_setting_name=BRAZE_ADMIN_INVITE_CAMPAIGN_SETTING
322+
)
323+
324+
except Exception: # pylint: disable=broad-except
325+
# Log email queueing failures but don't fail the transaction
326+
# Invites are created successfully, emails can be re-sent manually
327+
logger.exception(
328+
"Failed to enqueue admin invite emails for enterprise customer: %s. "
329+
"Invite count: %d",
330+
enterprise_customer.uuid,
331+
len(created_invites)
332+
)
333+
334+
transaction.on_commit(_enqueue_email_jobs)
335+
return created_invites
336+
337+
except DatabaseError:
338+
logger.exception(
339+
"Database error creating pending invites for enterprise customer: %s",
340+
enterprise_customer.uuid,
341+
)
342+
raise
343+
344+
345+
def get_invite_status(
346+
email: str,
347+
existing_admin_emails: Set[str],
348+
existing_pending_emails: Set[str],
349+
soft_deleted_admin_emails: Optional[Set[str]] = None,
350+
role_removed_admin_emails: Optional[Set[str]] = None
351+
) -> str:
352+
"""
353+
Determine the invitation status for a given email address.
354+
355+
Args:
356+
email (str): The email address to check.
357+
existing_admin_emails (Set[str]): Set of existing active admin email addresses.
358+
existing_pending_emails (Set[str]): Set of pending invitation email addresses.
359+
soft_deleted_admin_emails (Optional[Set[str]]): Set of soft-deleted (inactive) admin email addresses.
360+
role_removed_admin_emails (Optional[Set[str]]): Set of active users who had admin role removed.
361+
362+
Returns:
363+
str: Status constant indicating email state:
364+
- AdminInviteStatus.INACTIVE_ADMIN if user is soft-deleted (deactivated)
365+
- AdminInviteStatus.ADMIN_ROLE_REMOVED if active user had admin role removed
366+
- AdminInviteStatus.EXISTING_ADMIN if user is already an active admin
367+
- AdminInviteStatus.PENDING_INVITE if invitation already sent
368+
- AdminInviteStatus.NEW_INVITE if this is a new invitation
369+
370+
Example:
371+
>>> status = get_invite_status('new@example.com', set(), set(), set(), set())
372+
>>> status == 'invite sent'
373+
True
374+
"""
375+
if soft_deleted_admin_emails is None:
376+
soft_deleted_admin_emails = set()
377+
if role_removed_admin_emails is None:
378+
role_removed_admin_emails = set()
379+
380+
# Check soft-deleted admins first (truly inactive)
381+
if email in soft_deleted_admin_emails:
382+
return AdminInviteStatus.INACTIVE_ADMIN
383+
# Then check role-removed (still active as learner)
384+
if email in role_removed_admin_emails:
385+
return AdminInviteStatus.ADMIN_ROLE_REMOVED
386+
if email in existing_admin_emails:
387+
return AdminInviteStatus.EXISTING_ADMIN
388+
if email in existing_pending_emails:
389+
return AdminInviteStatus.PENDING_INVITE
390+
return AdminInviteStatus.NEW_INVITE
391+
23392

24393
User = auth.get_user_model()
25394
SERVICE_USERNAMES = (

0 commit comments

Comments
 (0)