Skip to content

Commit a8c2bf2

Browse files
feat: Invite admin and trigger braze functionality has been updated
1 parent 9b02566 commit a8c2bf2

File tree

15 files changed

+2563
-85
lines changed

15 files changed

+2563
-85
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Unreleased
1717
----------
1818
* nothing unreleased
1919

20+
[6.6.8] - 2026-03-05
21+
---------------------
22+
* feat: Add enterprise admin invite via Braze email campaign
23+
2024
[6.6.7] - 2026-03-04
2125
---------------------
2226
* feat: expose admin list endpoint with search and pagination

enterprise/api/utils.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,212 @@
11
"""
22
Utility functions for the Enterprise API.
33
"""
4+
import logging
5+
from typing import List, Set
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 (
915
ENTERPRISE_CATALOG_ADMIN_ROLE,
1016
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
1117
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
18+
AdminInviteStatus,
1219
)
1320
from enterprise.models import (
1421
EnterpriseCustomer,
22+
EnterpriseCustomerAdmin,
1523
EnterpriseCustomerCatalog,
1624
EnterpriseCustomerInviteKey,
1725
EnterpriseCustomerReportingConfiguration,
1826
EnterpriseCustomerUser,
1927
EnterpriseFeatureRole,
2028
EnterpriseFeatureUserRoleAssignment,
2129
EnterpriseGroup,
30+
PendingEnterpriseCustomerAdminUser,
2231
)
32+
from enterprise.tasks import send_enterprise_admin_invite_email
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
def get_existing_admin_emails(enterprise_customer: EnterpriseCustomer) -> Set[str]:
38+
"""
39+
Retrieve normalized email addresses of existing enterprise admins.
40+
41+
Args:
42+
enterprise_customer: The enterprise customer instance.
43+
44+
Returns:
45+
Set of lowercased email addresses.
46+
47+
Raises:
48+
DatabaseError: If database query fails.
49+
50+
Example:
51+
>>> emails = get_existing_admin_emails(customer)
52+
>>> 'admin@example.com' in emails
53+
True
54+
"""
55+
try:
56+
return set(
57+
EnterpriseCustomerAdmin.objects.filter(
58+
enterprise_customer_user__enterprise_customer=enterprise_customer,
59+
enterprise_customer_user__active=True,
60+
enterprise_customer_user__user_fk__is_active=True,
61+
)
62+
.annotate(email_l=Lower(F("enterprise_customer_user__user_fk__email")))
63+
.values_list("email_l", flat=True)
64+
)
65+
except DatabaseError:
66+
logger.exception(
67+
"Database error retrieving existing admin emails for enterprise customer: %s",
68+
enterprise_customer.uuid,
69+
)
70+
raise
71+
72+
73+
def get_existing_pending_emails(
74+
enterprise_customer: EnterpriseCustomer,
75+
normalized_emails: List[str]
76+
) -> Set[str]:
77+
"""
78+
Retrieve normalized email addresses of pending admin invitations.
79+
80+
Args:
81+
enterprise_customer: The enterprise customer instance.
82+
normalized_emails: List of normalized email addresses to check.
83+
84+
Returns:
85+
Set of lowercased email addresses that have pending invitations.
86+
87+
Raises:
88+
DatabaseError: If database query fails.
89+
90+
Example:
91+
>>> pending = get_existing_pending_emails(customer, ['user@example.com'])
92+
>>> 'user@example.com' in pending
93+
True
94+
"""
95+
try:
96+
return set(
97+
PendingEnterpriseCustomerAdminUser.objects.filter(
98+
enterprise_customer=enterprise_customer,
99+
)
100+
.annotate(email_l=Lower(F("user_email")))
101+
.filter(email_l__in=normalized_emails)
102+
.values_list("email_l", flat=True)
103+
)
104+
except DatabaseError:
105+
logger.exception(
106+
"Database error retrieving existing pending emails for enterprise customer: %s",
107+
enterprise_customer.uuid,
108+
)
109+
raise
110+
111+
112+
def create_pending_invites(
113+
enterprise_customer: EnterpriseCustomer,
114+
emails_to_invite: List[str]
115+
) -> List[PendingEnterpriseCustomerAdminUser]:
116+
"""
117+
Create pending admin invitations and trigger email notifications.
118+
119+
Creates PendingEnterpriseCustomerAdminUser records for new admin invites
120+
and enqueues Braze email tasks to be sent after transaction commits.
121+
122+
Args:
123+
enterprise_customer: The enterprise customer instance.
124+
emails_to_invite: List of normalized email addresses to invite.
125+
126+
Returns:
127+
List of created PendingEnterpriseCustomerAdminUser instances.
128+
129+
Raises:
130+
DatabaseError: If database operation fails.
131+
ValueError: If emails_to_invite is empty.
132+
RuntimeError: If called outside a transaction.atomic block.
133+
134+
Note:
135+
- Caller must wrap in transaction.atomic() to ensure atomicity
136+
- Uses get_or_create per email to avoid duplicate invite emails in race conditions
137+
- Emails are queued via transaction.on_commit() to send after transaction commits
138+
- This ensures emails only send if database changes succeed
139+
140+
Example:
141+
>>> with transaction.atomic():
142+
... invites = create_pending_invites(customer, ['new@example.com'])
143+
>>> len(invites) > 0
144+
True
145+
"""
146+
if not emails_to_invite:
147+
raise ValueError("emails_to_invite cannot be empty")
148+
149+
if not transaction.get_connection().in_atomic_block:
150+
raise RuntimeError("create_pending_invites must be called inside transaction.atomic().")
151+
152+
try:
153+
created_invites = []
154+
for email in emails_to_invite:
155+
pending_invite, created = PendingEnterpriseCustomerAdminUser.objects.get_or_create(
156+
enterprise_customer=enterprise_customer,
157+
user_email=email,
158+
)
159+
if created:
160+
created_invites.append(pending_invite)
161+
162+
def _enqueue_email_jobs():
163+
# Send invite email only for newly created pending invites.
164+
if created_invites:
165+
send_enterprise_admin_invite_email.delay(
166+
str(enterprise_customer.uuid),
167+
[invite.user_email for invite in created_invites]
168+
)
169+
170+
transaction.on_commit(_enqueue_email_jobs)
171+
return created_invites
172+
except DatabaseError:
173+
logger.exception(
174+
"Database error creating pending invites for enterprise customer: %s",
175+
enterprise_customer.uuid,
176+
)
177+
raise
178+
179+
180+
def get_invite_status(
181+
email: str,
182+
existing_admin_emails: Set[str],
183+
existing_pending_emails: Set[str]
184+
) -> str:
185+
"""
186+
Determine the invitation status for a given email address.
187+
188+
Args:
189+
email (str): The email address to check.
190+
existing_admin_emails (set): Set of existing admin email addresses.
191+
existing_pending_emails (set): Set of pending invitation email addresses.
192+
193+
Returns:
194+
str: Status constant indicating email state:
195+
- AdminInviteStatus.EXISTING_ADMIN if user is already an admin
196+
- AdminInviteStatus.PENDING_INVITE if invitation already sent
197+
- AdminInviteStatus.NEW_INVITE if this is a new invitation
198+
199+
Example:
200+
>>> status = get_invite_status('new@example.com', set(), set())
201+
>>> status == 'invite sent'
202+
True
203+
"""
204+
if email in existing_admin_emails:
205+
return AdminInviteStatus.EXISTING_ADMIN
206+
if email in existing_pending_emails:
207+
return AdminInviteStatus.PENDING_INVITE
208+
return AdminInviteStatus.NEW_INVITE
209+
23210

24211
User = auth.get_user_model()
25212
SERVICE_USERNAMES = (

enterprise/api/v1/serializers.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from django.contrib import auth
1818
from django.contrib.sites.models import Site
1919
from django.core import exceptions as django_exceptions
20+
from django.core.exceptions import ValidationError as DjangoValidationError
21+
from django.core.validators import validate_email
2022
from django.db import IntegrityError, transaction
2123
from django.utils.translation import gettext_lazy as _
2224

@@ -2395,3 +2397,65 @@ class EnterpriseAdminMemberSerializer(serializers.Serializer):
23952397
format="%b %d, %Y",
23962398
)
23972399
status = serializers.CharField()
2400+
2401+
2402+
class AdminInviteSerializer(serializers.Serializer):
2403+
"""
2404+
Accepts a list of email addresses for processing.
2405+
2406+
Example::
2407+
2408+
{
2409+
"emails": ["a@x.com", "b@x.com"]
2410+
}
2411+
2412+
Validation:
2413+
2414+
- Emails are validated for proper format.
2415+
- Emails are stripped and lowercased.
2416+
- Empty lists are not allowed.
2417+
- Duplicate emails are not allowed.
2418+
- (Optional) Additional business rules such as domain restrictions can be applied.
2419+
"""
2420+
emails = serializers.ListField(
2421+
child=serializers.EmailField(),
2422+
allow_empty=False,
2423+
required=True,
2424+
error_messages={
2425+
"required": "The 'emails' field is required.",
2426+
"empty": "The 'emails' field is required.",
2427+
},
2428+
)
2429+
2430+
def validate_emails(self, value):
2431+
"""
2432+
Validate email format and check for duplicates.
2433+
2434+
Args:
2435+
value: List of email strings
2436+
2437+
Returns:
2438+
List of normalized (stripped, lowercased) emails
2439+
2440+
Raises:
2441+
ValidationError: If any email has invalid format or duplicates exist
2442+
"""
2443+
normalized_emails = []
2444+
2445+
for email in value:
2446+
# Strip and lowercase
2447+
normalized_email = email.strip().lower()
2448+
2449+
# Validate email format
2450+
try:
2451+
validate_email(normalized_email)
2452+
except DjangoValidationError as exc:
2453+
raise serializers.ValidationError(f"Invalid email format: {email}") from exc
2454+
2455+
normalized_emails.append(normalized_email)
2456+
2457+
# Check for duplicates
2458+
if len(normalized_emails) != len(set(normalized_emails)):
2459+
raise serializers.ValidationError("Duplicate emails are not allowed.")
2460+
2461+
return normalized_emails

enterprise/api/v1/urls.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,6 @@
249249
enterprise_admin_members.EnterpriseAdminMembersViewSet.as_view({'get': 'list'}),
250250
name='enterprise-admin-members',
251251
),
252-
re_path(
253-
r'^enterprise-customer/(?P<enterprise_customer_uuid>[A-Za-z0-9-]+)/admins/(?P<admin_pk>[A-Za-z0-9-]+)/?$',
254-
EnterpriseCustomerAdminViewSet.as_view({'delete': 'delete_admin'}),
255-
name='enterprise-customer-admin-delete'
256-
),
257252
]
258253

259254
urlpatterns += router.urls

0 commit comments

Comments
 (0)