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

feat: send group membership invite and remove emails #2049

Merged
merged 23 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
12 changes: 12 additions & 0 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,17 @@ class Meta:
updated = serializers.DateTimeField(required=False, read_only=True)


class EnterpriseGroupRequestDataSerializer(serializers.Serializer):
"""
Serializer for the Enterprise Group Assign Learners endpoint query params
"""
catalog_uuid = serializers.UUIDField(required=False, allow_null=True)
act_by_date = serializers.DateTimeField(required=False, allow_null=True)
learner_emails = serializers.ListField(
child=serializers.EmailField(required=True),
allow_empty=False)


class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer):
"""
Serializer for the Enterprise Group Learners endpoint query filter
Expand All @@ -1705,3 +1716,4 @@ class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer):
],
required=False,
)
pending_users_only = serializers.BooleanField(required=False, default=False)
261 changes: 151 additions & 110 deletions enterprise/api/v1/views/enterprise_group.py

Large diffs are not rendered by default.

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):
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
"""
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,
},
}
13 changes: 11 additions & 2 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4330,7 +4330,7 @@ def _get_implicit_group_members(self, user_query=None):
))
return members

def _get_explicit_group_members(self, user_query=None, fetch_removed=False):
def _get_explicit_group_members(self, user_query=None, fetch_removed=False, pending_users_only=None,):
"""
Fetch explicitly defined members of a group, indicated by an existing membership record
"""
Expand All @@ -4345,9 +4345,16 @@ def _get_explicit_group_members(self, user_query=None, fetch_removed=False):
# pecu has user_email as a field, so we can filter directly through the ORM with the user_query
pecu_filter = Q(pending_enterprise_customer_user__user_email__icontains=user_query)
members = members.filter(ecu_filter | pecu_filter)
if pending_users_only:
members = members.filter(is_removed=False, enterprise_customer_user_id__isnull=True)
return members

def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetch_removed=False):
def get_all_learners(self,
user_query=None,
sort_by=None,
desc_order=False,
fetch_removed=False,
pending_users_only=False):
"""
Returns all users associated with the group, whether the group specifies the entire org else all associated
membership records.
Expand All @@ -4369,6 +4376,8 @@ def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetc
'recent_action': lambda t: t.recent_action,
}
members = sorted(members, key=lambda_keys.get(sort_by), reverse=desc_order)
if pending_users_only:
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
members = self._get_explicit_group_members(user_query, fetch_removed, pending_users_only)
return members


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='test-api-key'
EDX_BRAZE_API_SERVER='test-api-server'
BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID = 'test-invitation-campaign-id'
BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID = 'test-removal-campaign-id'
158 changes: 155 additions & 3 deletions enterprise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
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 +129,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,10 +187,17 @@ def send_sso_configured_email(
}

try:
braze_client_instance = BrazeAPIClient.get_braze_client()
braze_client_instance = BrazeAPIClient()
recipient = braze_client_instance.create_recipient_no_external_id(
contact_email,
)
braze_client_instance.create_braze_alias(
[contact_email],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
braze_client_instance.send_campaign_message(
braze_campaign_id,
recipients=[contact_email],
recipients=[recipient],
trigger_properties=braze_trigger_properties,
)
except BrazeClientError as exc:
Expand All @@ -182,3 +207,130 @@ 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,
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
membership_uuids,
act_by_date,
catalog_uuid
):
"""
Send braze email notification when member is invited to a group.

Arguments:
* enterprise_customer_uuid (string)
* memberships (list)
* act_by_date (datetime)
* catalog_uuid (string)
"""
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
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['act_by_date'] = act_by_date.strftime('%B %d, %Y')
pecu_emails = []
ecus = []
membership_records = enterprise_group_membership_model().objects.filter(uuid__in=membership_uuids)
for group_membership in membership_records:
if group_membership.pending_enterprise_customer_user is not None:
pecu_emails.append(group_membership.pending_enterprise_customer_user.user_email)
else:
ecus.append({
'user_email': group_membership.enterprise_customer_user.user_email,
'user_id': group_membership.enterprise_customer_user.user_id
})
recipients = []
for pecu_email in pecu_emails:
recipients.append(braze_client_instance.create_recipient_no_external_id(pecu_email))
braze_client_instance.create_braze_alias(
[pecu_emails],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
for ecu in ecus:
recipients.append(braze_client_instance.create_recipient(
user_email=ecu['user_email'],
lms_user_id=ecu['user_id']))
try:
braze_client_instance.send_campaign_message(
settings.BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID,
recipients=recipients,
trigger_properties=braze_trigger_properties,
)
except BrazeClientError as exc:
message = (
"Groups learner invitation email could not be sent "
f"to {recipients} 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, membership_uuids, catalog_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)
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)
pecu_emails = []
ecus = []
membership_records = enterprise_group_membership_model().objects.filter(uuid__in=membership_uuids)
for group_membership in membership_records:
if group_membership.pending_enterprise_customer_user is not None:
pecu_emails.append(group_membership.pending_enterprise_customer_user.user_email)
else:
ecus.append({
'user_email': group_membership.enterprise_customer_user.user_email,
'user_id': group_membership.enterprise_customer_user.user_id
})

recipients = []
for pecu_email in pecu_emails:
recipients.append(braze_client_instance.create_recipient_no_external_id(pecu_email))
braze_client_instance.create_braze_alias(
[pecu_emails],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
for ecu in ecus:
recipients.append(braze_client_instance.create_recipient(
user_email=ecu['user_email'],
lms_user_id=ecu['user_id']
))
try:
braze_client_instance.send_campaign_message(
settings.BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID,
recipients=recipients,
trigger_properties=braze_trigger_properties,
)
except BrazeClientError as exc:
message = (
"Groups learner invitation email could not be sent "
f"to {recipients} for enterprise {enterprise_customer_name}."
)
LOGGER.exception(message)
raise exc
Loading
Loading