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 12 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 @@ -1699,3 +1699,15 @@ class Meta:
client_secret = serializers.CharField(read_only=True, default=generate_client_secret())
redirect_uris = serializers.CharField(required=False)
updated = serializers.DateTimeField(required=False, read_only=True)


class EnterpriseGroupAssignLearnersRequestDataSerializer(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
)
59 changes: 50 additions & 9 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
from enterprise.utils import localized_utcnow

LOGGER = getEnterpriseLogger(__name__)
Expand Down Expand Up @@ -94,13 +95,19 @@ def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)

@action(detail=True, methods=['get'])
def get_learners(self, *args, **kwargs):
def get_learners(self, request, *args, **kwargs):
"""
Endpoint Location: GET api/v1/enterprise-group/<group_uuid>/learners/
Endpoint Location to return all learners: GET api/v1/enterprise-group/<group_uuid>/learners/

Endpoint Location to return pending learners:
GET api/v1/enterprise-group/<group_uuid>/learners?pending_users_only=true

Request Arguments:
- ``group_uuid`` (URL location, required): The uuid of the group from which learners should be listed.

Optional query params:
- ``pending_users_only`` (string, optional): Specify that results should only contain pending learners

Returns: Paginated list of learners that are associated with the enterprise group uuid::

{
Expand All @@ -109,19 +116,26 @@ def get_learners(self, *args, **kwargs):
'previous': null,
'results': [
{
'learner_uuid': 'enterprise_customer_user_id',
'pending_learner_id': 'pending_enterprise_customer_user_id',
'enterprise_group_membership_uuid': 'enterprise_group_membership_uuid',
'learner_id': integer or None,
'pending_learner_id': integer or None,
'enterprise_group_membership_uuid': UUID,
'member_details': {
'user_email': string,
'user_name': string,
},
'recent_action': string,
'member_status': string,
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
},
],
}

"""

group_uuid = kwargs.get('group_uuid')
filter_for_pecus = request.query_params.get('pending_users_only', 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,18 +171,32 @@ def assign_learners(self, request, group_uuid):
customer = group.enterprise_customer
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
except models.EnterpriseGroup.DoesNotExist as exc:
raise Http404 from exc
if requested_emails := request.POST.dict().get('learner_emails'):
if requested_emails := request.POST.getlist('learner_emails'):
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
request_data = {}
request_data['act_by_date'] = request.data.get('act_by_date')
request_data['catalog_uuid'] = request.data.get('catalog_uuid')
request_data['learner_emails'] = requested_emails
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

param_serializers = serializers.EnterpriseGroupAssignLearnersRequestDataSerializer(
data=request_data
)
param_serializers.is_valid()
if not param_serializers.is_valid():
return Response(param_serializers.errors, status=400)

act_by_date = param_serializers.validated_data.get('act_by_date')
catalog_uuid = param_serializers.validated_data.get('catalog_uuid')
learner_emails = param_serializers.validated_data.get('learner_emails')
total_records_processed = 0
total_existing_users_processed = 0
total_new_users_processed = 0
for user_email_batch in utils.batch(requested_emails.rstrip(',').split(',')[: 1000], batch_size=200):
for user_email_batch in utils.batch(learner_emails[: 1000], batch_size=200):
user_emails_to_create = []
memberships_to_create = []
# ecus: enterprise customer users
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 @@ -239,6 +267,15 @@ def assign_learners(self, request, group_uuid):
'new_learners': total_new_users_processed,
'existing_learners': total_existing_users_processed,
}
membership_uuids = [membership.uuid for membership in memberships]
if act_by_date is not None and catalog_uuid is not None:
for membership_uuid_batch in utils.batch(membership_uuids, batch_size=200):
send_group_membership_invitation_notification.delay(
customer.uuid,
membership_uuid_batch,
act_by_date,
catalog_uuid
)
return Response(data, status=201)
return Response(data="Error: missing request data: `learner_emails`.", status=400)

Expand All @@ -261,6 +298,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 @@ -274,6 +312,9 @@ def remove_learners(self, request, group_uuid):
group_q & (ecu_in_q | pecu_in_q),
)
records_deleted += len(records_to_delete)
records_to_delete_uuids = [record.uuid for record in records_to_delete]
for records_to_delete_uuids_batch in utils.batch(records_to_delete_uuids, batch_size=200):
send_group_membership_removal_notification.delay(customer.uuid, records_to_delete_uuids_batch)
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):
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,
},
}
4 changes: 3 additions & 1 deletion enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4259,7 +4259,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 @@ -4287,6 +4287,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='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'
Loading
Loading