Skip to content

Commit

Permalink
feat: send group membership invite, remove, and reminder emails
Browse files Browse the repository at this point in the history
feat: send group membership invite, remove, and reminder emails

feat: send group membership invite, remove, and reminder emails

feat: send group membership invite, remove, and reminder emails

fix: file name

chore: refactored

chore: refactored

chore: refactored sending group reminder email to realized learners

fix: deleted unused file

chore: update

fix: updated tests

chore: removed comment

chore: removed empty line
  • Loading branch information
katrinan029 committed Mar 27, 2024
1 parent 769115c commit b61b4e6
Show file tree
Hide file tree
Showing 8 changed files with 418 additions and 34 deletions.
3 changes: 2 additions & 1 deletion enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,10 +599,11 @@ class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer):
learner_id = serializers.IntegerField(source='enterprise_customer_user.id', allow_null=True)
pending_learner_id = serializers.IntegerField(source='pending_enterprise_customer_user.id', allow_null=True)
enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True)
enterprise_customer = EnterpriseCustomerSerializer(source='group.enterprise_customer', read_only=True)

class Meta:
model = models.EnterpriseGroupMembership
fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid')
fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid', 'enterprise_customer')


class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer):
Expand Down
20 changes: 18 additions & 2 deletions enterprise/api/v1/views/enterprise_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from enterprise.api.v1 import serializers
from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet
from enterprise.logging import getEnterpriseLogger
from enterprise.tasks import send_group_membership_invitation_notification, send_group_membership_removal_notification

LOGGER = getEnterpriseLogger(__name__)

Expand Down Expand Up @@ -111,16 +112,19 @@ def get_learners(self, *args, **kwargs):
'learner_uuid': 'enterprise_customer_user_id',
'pending_learner_id': 'pending_enterprise_customer_user_id',
'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid',
'enterprise_customer': EnterpriseCustomerSerializer
},
],
}
"""

group_uuid = kwargs.get('group_uuid')
# GET api/v1/enterprise-group/<group_uuid>/learners?filter_for_pecus=true
filter_for_pecus = self.request.query_params.get('filter_for_pecus', None)
try:
group_object = self.get_queryset().get(uuid=group_uuid)
members = group_object.get_all_learners()
members = group_object.get_all_learners(filter_for_pecus)
page = self.paginate_queryset(members)
serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True)
response = self.get_paginated_response(serializer.data)
Expand Down Expand Up @@ -157,6 +161,8 @@ def assign_learners(self, request, group_uuid):
except models.EnterpriseGroup.DoesNotExist as exc:
raise Http404 from exc
if requested_emails := request.POST.dict().get('learner_emails'):
budget_expiration = request.POST.dict().get('budget_expiration')
catalog_uuid = request.POST.dict().get('catalog_uuid')
total_records_processed = 0
total_existing_users_processed = 0
total_new_users_processed = 0
Expand All @@ -167,7 +173,6 @@ def assign_learners(self, request, group_uuid):
ecus = []
# Gather all existing User objects associated with the email batch
existing_users = User.objects.filter(email__in=user_email_batch)

# Build and create a list of EnterpriseCustomerUser objects for the emails of existing Users
# Ignore conflicts in case any of the ent customer user objects already exist
ecu_by_email = {
Expand Down Expand Up @@ -237,6 +242,14 @@ def assign_learners(self, request, group_uuid):
'new_learners': total_new_users_processed,
'existing_learners': total_existing_users_processed,
}
if budget_expiration is not None and catalog_uuid is not None:
for membership in memberships:
send_group_membership_invitation_notification.delay(
customer.uuid,
membership.uuid,
budget_expiration,
catalog_uuid
)
return Response(data, status=201)
return Response(data="Error: missing request data: `learner_emails`.", status=400)

Expand All @@ -259,6 +272,7 @@ def remove_learners(self, request, group_uuid):
"""
try:
group = self.get_queryset().get(uuid=group_uuid)
customer = group.enterprise_customer
except models.EnterpriseGroup.DoesNotExist as exc:
raise Http404 from exc
if requested_emails := request.POST.dict().get('learner_emails'):
Expand All @@ -272,6 +286,8 @@ def remove_learners(self, request, group_uuid):
group_q & (ecu_in_q | pecu_in_q),
)
records_deleted += len(records_to_delete)
for record in records_to_delete:
send_group_membership_removal_notification.delay(customer.uuid, record.uuid)
records_to_delete.delete()
data = {
'records_deleted': records_deleted,
Expand Down
86 changes: 74 additions & 12 deletions enterprise/api_client/braze.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,88 @@

logger = logging.getLogger(__name__)

ENTERPRISE_BRAZE_ALIAS_LABEL = 'Enterprise' # Do Not change this, this is consistent with other uses across edX repos.

class BrazeAPIClient:

class BrazeAPIClient(BrazeClient):
"""
API client for calls to Braze.
"""
@classmethod
def get_braze_client(cls):
""" Returns a Braze client. """
if not BrazeClient:
return None

# fetching them from edx-platform settings
def __init__(self):
braze_api_key = getattr(settings, 'EDX_BRAZE_API_KEY', None)
braze_api_url = getattr(settings, 'EDX_BRAZE_API_SERVER', None)
required_settings = ['EDX_BRAZE_API_KEY', 'EDX_BRAZE_API_SERVER']
for setting in required_settings:
if not getattr(settings, setting, None):
msg = f'Missing {setting} in settings required for Braze API Client.'
logger.error(msg)
raise ValueError(msg)

if not braze_api_key or not braze_api_url:
return None

return BrazeClient(
super().__init__(
api_key=braze_api_key,
api_url=braze_api_url,
app_id='',
)

def generate_mailto_link(self, emails):
"""
Generate a mailto link for the given emails.
"""
if emails:
return f'mailto:{",".join(emails)}'

return None

def create_recipient(
self,
user_email,
lms_user_id,
trigger_properties=None,
):
"""
Create a recipient object using the given user_email and lms_user_id.
Identifies the given email address with any existing Braze alias records
via the provided ``lms_user_id``.
"""

user_alias = {
'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL,
'alias_name': user_email,
}

# Identify the user alias in case it already exists. This is necessary so
# we don't accidentally create a duplicate Braze profile.
self.identify_users([{
'external_id': lms_user_id,
'user_alias': user_alias
}])

attributes = {
"user_alias": user_alias,
"email": user_email,
"is_enterprise_learner": True,
"_update_existing_only": False,
}

return {
'external_user_id': lms_user_id,
'attributes': attributes,
# If a profile does not already exist, Braze will create a new profile before sending a message.
'send_to_existing_only': False,
'trigger_properties': trigger_properties or {},
}

def create_recipient_no_external_id(self, user_email):
"""
Create a Braze recipient dict identified only by an alias based on their email.
"""
return {
'attributes': {
'email': user_email,
'is_enterprise_learner': True,
},
'user_alias': {
'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL,
'alias_name': user_email,
},
}
4 changes: 3 additions & 1 deletion enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4249,7 +4249,7 @@ class Meta:
unique_together = (("name", "enterprise_customer"),)
ordering = ['-modified']

def get_all_learners(self):
def get_all_learners(self, filter_for_pecus):
"""
Returns all users associated with the group, whether the group specifies the entire org else all associated
membership records.
Expand Down Expand Up @@ -4277,6 +4277,8 @@ def get_all_learners(self):
))
return members
else:
if filter_for_pecus is not None:
return self.members.filter(is_removed=False, enterprise_customer_user_id__isnull=True)
return self.members.filter(is_removed=False)


Expand Down
5 changes: 5 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,8 @@ def root(*args):
ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth'

EDX_BRAZE_API_KEY='894e2287-66d5-4e41-b04e-67aba70dabf4'
EDX_BRAZE_API_SERVER=None
BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID = 'test-invitation-campaign-id'
BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID = 'test-removal-campaign-id'
153 changes: 151 additions & 2 deletions enterprise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
from edx_django_utils.monitoring import set_code_owner_attribute

from django.apps import apps
from django.conf import settings
from django.core import mail
from django.db import IntegrityError

from enterprise.api_client.braze import BrazeAPIClient
from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL, BrazeAPIClient
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient

from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID
from enterprise.utils import get_enterprise_customer, send_email_notification_message

Expand Down Expand Up @@ -127,6 +130,22 @@ def enterprise_customer_user_model():
return apps.get_model('enterprise', 'EnterpriseCustomerUser')


def pending_enterprise_customer_user_model():
"""
Returns the ``PendingEnterpriseCustomerUser`` class.
This function is needed to avoid circular ref issues when model classes call tasks in this module.
"""
return apps.get_model('enterprise', 'PendingEnterpriseCustomerUser')


def enterprise_group_membership_model():
"""
Returns the ``EnterpriseGroupMembership`` class.
This function is needed to avoid circular ref issues when model classes call tasks in this module.
"""
return apps.get_model('enterprise', 'EnterpriseGroupMembership')


def enterprise_course_enrollment_model():
"""
Returns the ``EnterpriseCourseEnrollment`` class.
Expand Down Expand Up @@ -169,7 +188,7 @@ def send_sso_configured_email(
}

try:
braze_client_instance = BrazeAPIClient.get_braze_client()
braze_client_instance = BrazeAPIClient()
braze_client_instance.send_campaign_message(
braze_campaign_id,
recipients=[contact_email],
Expand All @@ -182,3 +201,133 @@ def send_sso_configured_email(
)
LOGGER.exception(message)
raise exc


@shared_task
@set_code_owner_attribute
def send_group_membership_invitation_notification(
enterprise_customer_uuid,
group_membership_uuid,
budget_expiration,
catalog_uuid
):
"""
Send braze email notification when member is invited to a group.
Arguments:
* enterprise_customer_uuid (string)
* group_membership_uuid (string)
* budget_expiration (datetime)
* catalog_uuid (string)
"""
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
group_membership = enterprise_group_membership_model().objects.filter(
uuid=group_membership_uuid
).values().last()
braze_client_instance = BrazeAPIClient()
enterprise_catalog_client = EnterpriseCatalogApiClient()
braze_trigger_properties = {}
contact_email = enterprise_customer.contact_email
enterprise_customer_name = enterprise_customer.name
braze_trigger_properties['contact_admin_link'] = braze_client_instance.generate_mailto_link(contact_email)
braze_trigger_properties['enterprise_customer_name'] = enterprise_customer_name
braze_trigger_properties[
'catalog_content_count'
] = enterprise_catalog_client.get_catalog_content_count(catalog_uuid)
braze_trigger_properties['budget_end_date'] = budget_expiration
if group_membership['enterprise_customer_user_id'] is None:
pending_enterprise_customer_user = pending_enterprise_customer_user_model().objects.get(
id=group_membership['pending_enterprise_customer_user_id'])
learner_email = pending_enterprise_customer_user.user_email
recipient = braze_client_instance.create_recipient_no_external_id(
learner_email,
)
# We need an alias record to exist in Braze before
# sending to any previously-unidentified users.
braze_client_instance.create_braze_alias(
[learner_email],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
else:
enterprise_customer_user = enterprise_customer_user_model().objects.get(
user_id=group_membership['enterprise_customer_user_id'])
learner_email = enterprise_customer_user.user_email
recipient = braze_client_instance.create_recipient(
user_email=learner_email,
lms_user_id=enterprise_customer_user.user_id,
)

try:
braze_client_instance.send_campaign_message(
settings.BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID,
recipients=[recipient],
trigger_properties=braze_trigger_properties,
)
except BrazeClientError as exc:
message = (
"Groups learner invitation email could not be sent "
f"to {learner_email} for enterprise {enterprise_customer_name}."
)
LOGGER.exception(message)
raise exc


@shared_task
@set_code_owner_attribute
def send_group_membership_removal_notification(enterprise_customer_uuid, group_membership_uuid):
"""
Send braze email notification when learner is removed from a group.
Arguments:
* enterprise_customer_uuid (string)
* group_membership_uuid (string)
"""
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
group_membership = enterprise_group_membership_model().objects.filter(
uuid=group_membership_uuid
).values().last()
braze_client_instance = BrazeAPIClient()
braze_trigger_properties = {}
braze_campaign_id = settings.BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID
contact_email = enterprise_customer.contact_email
enterprise_customer_name = enterprise_customer.name
braze_trigger_properties['contact_admin_link'] = braze_client_instance.generate_mailto_link(contact_email)
braze_trigger_properties['enterprise_customer_name'] = enterprise_customer_name

if group_membership['enterprise_customer_user_id'] is None:
pending_enterprise_customer_user = pending_enterprise_customer_user_model().objects.get(
id=group_membership['pending_enterprise_customer_user_id']
)
learner_email = pending_enterprise_customer_user.user_email
recipient = braze_client_instance.create_recipient_no_external_id(
learner_email,
)
# We need an alias record to exist in Braze before
# sending to any previously-unidentified users.
braze_client_instance.create_braze_alias(
[learner_email],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
else:
enterprise_customer_user = enterprise_customer_user_model().objects.get(
user_id=group_membership['enterprise_customer_user_id']
)
learner_email = enterprise_customer_user.user_email
recipient = braze_client_instance.create_recipient(
user_email=learner_email,
lms_user_id=enterprise_customer_user.user_id,
)

try:
braze_client_instance.send_campaign_message(
braze_campaign_id,
recipients=[recipient],
trigger_properties=braze_trigger_properties,
)
except BrazeClientError as exc:
message = (
"Groups learner removal email could not be sent "
f"to {learner_email} for enterprise {enterprise_customer_name}."
)
LOGGER.exception(message)
raise exc
Loading

0 comments on commit b61b4e6

Please sign in to comment.