Skip to content

Commit

Permalink
feat: add nudge braze email using commands (#388)
Browse files Browse the repository at this point in the history
* feat: add nudge braze email using commands

* chore: PR feedback

* feat: Nudge API

* chore: PR feedback 2

* chore: Additional logging and exception handling
  • Loading branch information
brobro10000 authored Jan 31, 2024
1 parent f328d19 commit c1a4ad6
Show file tree
Hide file tree
Showing 10 changed files with 1,169 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LearnerContentAssignmentActionSerializer(serializers.ModelSerializer):
"""
A read-only Serializer for responding to requests for ``LearnerContentAssignmentAction`` records.
"""

class Meta:
model = LearnerContentAssignmentAction
fields = [
Expand Down Expand Up @@ -173,6 +174,50 @@ class LearnerContentAssignmentActionRequestSerializer(serializers.Serializer):
)


class LearnerContentAssignmentNudgeRequestSerializer(serializers.Serializer):
"""
Request serializer to validate nudge endpoint query params.
For view: LearnerContentAssignmentAdminViewSet.nudge
"""
assignment_uuids = serializers.ListField(
child=serializers.UUIDField(),
allow_empty=False,
help_text="A list of executive education assignment uuids associated with an assignment configuration"
)
days_before_course_start_date = serializers.IntegerField(
min_value=1,
help_text="The number days ahead of a course start_date we want to send a nudge email for"
)


class LearnerContentAssignmentNudgeResponseSerializer(serializers.Serializer):
"""
Response serializer for nudge endpoint.
For view: LearnerContentAssignmentAdminViewSet.nudge
"""
nudged_assignment_uuids = serializers.ListField(
child=serializers.UUIDField(),
allow_empty=False,
help_text="A list of uuids that have been sent to the celery task to nudge"
)
unnudged_assignment_uuids = serializers.ListField(
child=serializers.UUIDField(),
allow_empty=True,
help_text="A list of uuids that have not been sent to the celery task to nudge"
)


class LearnerContentAssignmentNudgeHTTP422ErrorSerializer(serializers.Serializer):
"""
Response serializer for nudge endpoint 422 errors.
For view: LearnerContentAssignmentAdminViewSet.nudge
"""
error_message = serializers.CharField()


class ContentMetadataForAssignmentSerializer(serializers.Serializer):
"""
Serializer to help return additional content metadata for assignments. These fields should
Expand Down
146 changes: 145 additions & 1 deletion enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from uuid import UUID, uuid4

import ddt
from django.utils import timezone
from rest_framework import status
from rest_framework.reverse import reverse

Expand Down Expand Up @@ -81,7 +82,6 @@ def setUp(self):
super().setUp()
# Start in an unauthenticated state.
self.client.logout()

# This assignment has just been allocated, so its lms_user_id is null.
self.assignment_allocated_pre_link = LearnerContentAssignmentFactory(
state=LearnerContentAssignmentStateChoices.ALLOCATED,
Expand All @@ -96,6 +96,9 @@ def setUp(self):
lms_user_id=TEST_OTHER_LMS_USER_ID,
transaction_uuid=None,
assignment_configuration=self.assignment_configuration,
content_key='edX+edXPrivacy101',
content_quantity=-321,
content_title='edx: Privacy 101'
)
self.assignment_allocated_post_link.add_successful_linked_action()
self.assignment_allocated_post_link.add_successful_notified_action()
Expand All @@ -118,6 +121,9 @@ def setUp(self):
lms_user_id=TEST_OTHER_LMS_USER_ID,
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
content_key='edX+edXAccessibility101',
content_quantity=-123,
content_title='edx: Accessibility 101'
)
self.assignment_accepted.add_successful_linked_action()
self.assignment_accepted.add_successful_notified_action()
Expand Down Expand Up @@ -261,6 +267,20 @@ def test_admin_assignment_readwrite_views_unauthorized_forbidden(self, role_cont
response = self.client.post(cancel_url, query_params)
assert response.status_code == expected_response_code

# Call the nudge endpoint.
nudge_kwargs = {
'assignment_configuration_uuid': str(TEST_ASSIGNMENT_CONFIG_UUID),
}
nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs)
query_params = {
'assignment_uuids': [str(self.assignment_accepted.uuid)],
'days_before_course_start_date': 3
}

# nudge endpoint
response = self.client.post(nudge_url, query_params)
assert response.status_code == expected_response_code


@ddt.ddt
class TestAssignmentsUnauthorizedCRUD(CRUDViewTestMixin, APITest):
Expand Down Expand Up @@ -623,6 +643,130 @@ def test_cancel(self, mock_send_cancel_email):
assert self.assignment_allocated_post_link.state == LearnerContentAssignmentStateChoices.CANCELLED
mock_send_cancel_email.delay.assert_called_once_with(self.assignment_allocated_post_link.uuid)

@mock.patch('enterprise_access.apps.content_assignments.api.get_content_metadata_for_assignments')
@mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay')
def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for_assignments):
"""
Test that the nudge view nudges the assignment and returns an appropriate response with 200 status code and
the expected results of serialization.
"""
# Set the JWT-based auth to an operator.
self.set_jwt_cookie([
{'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}
])

start_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=14)
end_date = timezone.now().replace(microsecond=0) + timezone.timedelta(days=180)
enrollment_end = timezone.now().replace(microsecond=0) - timezone.timedelta(days=5)

# Mock content metadata for assignment
mock_content_metadata_for_assignments.return_value = {
'edX+edXAccessibility101': {
'key': 'edX+edXAccessibility101',
'normalized_metadata': {
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
'content_price': 123,
},
'course_type': 'executive-education-2u',
},
}
# Call the nudge endpoint.
nudge_kwargs = {
'assignment_configuration_uuid': self.assignment_configuration.uuid,
}
nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs)
query_params = {
'assignment_uuids': [str(self.assignment_accepted.uuid)],
'days_before_course_start_date': 14
}

expected_response = {
"nudged_assignment_uuids": [str(self.assignment_accepted.uuid)],
"unnudged_assignment_uuids": []
}

response = self.client.post(nudge_url, query_params)

# Verify the API response.
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_response

mock_send_nudge_email.assert_called_once_with(self.assignment_accepted.uuid, 14)

@mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay')
def test_nudge_allocated_assignment(self, mock_send_nudge_email):
"""
Test that the nudge view doesn't nudge the assignment and
returns an appropriate response with 422 status code and
the expected results of serialization.
"""
# Set the JWT-based auth to an operator.
self.set_jwt_cookie([
{'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}
])

# Call the nudge endpoint.
nudge_kwargs = {
'assignment_configuration_uuid': self.assignment_configuration.uuid,
}
nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs)
query_params = {
'assignment_uuids': [str(self.assignment_allocated_post_link.uuid)],
'days_before_course_start_date': 14
}

response = self.client.post(nudge_url, query_params)

expected_response = {
"error_message": "Could not process the nudge email(s) for assignment_configuration_uuid: {0}"
.format(self.assignment_configuration.uuid),
}

# Verify the API response.
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == expected_response

mock_send_nudge_email.assert_not_called()

@mock.patch('enterprise_access.apps.content_assignments.tasks.send_exec_ed_enrollment_warmer.delay')
def test_nudge_no_assignments(self, mock_send_nudge_email):
"""
Test that the nudge view doesn't nudge the assignment and
returns an appropriate response with 422 status code and
the expected results of serialization.
"""
# Set the JWT-based auth to an operator.
self.set_jwt_cookie([
{'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}
])

# Call the nudge endpoint.
nudge_kwargs = {
'assignment_configuration_uuid': self.assignment_configuration.uuid,
}
nudge_url = reverse('api:v1:admin-assignments-nudge', kwargs=nudge_kwargs)

query_params = {
'assignment_uuids': [str(uuid4())],
'days_before_course_start_date': 14
}

response = self.client.post(nudge_url, query_params)

expected_response = {
"error_message": "The list of assignments provided are not "
"associated to the assignment_configuration_uuid: {0}"
.format(self.assignment_configuration.uuid)
}

# Verify the API response.
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == expected_response

mock_send_nudge_email.assert_not_called()

def test_bulk_cancel(self):
"""
Test that the cancel view cancels the assignment and returns an appropriate response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

from enterprise_access.apps.api import filters, serializers, utils
from enterprise_access.apps.api.serializers.content_assignments.assignment import (
LearnerContentAssignmentActionRequestSerializer
LearnerContentAssignmentActionRequestSerializer,
LearnerContentAssignmentNudgeHTTP422ErrorSerializer,
LearnerContentAssignmentNudgeRequestSerializer,
LearnerContentAssignmentNudgeResponseSerializer
)
from enterprise_access.apps.api.v1.views.utils import PaginationWithPageCount
from enterprise_access.apps.content_assignments import api as assignments_api
Expand Down Expand Up @@ -347,3 +350,61 @@ def remind_all(self, request, *args, **kwargs):
return Response(status=status.HTTP_202_ACCEPTED)
except Exception: # pylint: disable=broad-except
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)

@extend_schema(
tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG],
summary='Nudge assignments by UUID.',
request=LearnerContentAssignmentNudgeRequestSerializer,
parameters=None,
responses={
status.HTTP_200_OK: LearnerContentAssignmentNudgeResponseSerializer,
status.HTTP_422_UNPROCESSABLE_ENTITY: LearnerContentAssignmentNudgeHTTP422ErrorSerializer,
}
)
@permission_required(CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION, fn=assignment_admin_permission_fn)
@action(detail=False, methods=['post'])
def nudge(self, request, *args, **kwargs):
"""
Send nudges to a list of learners with associated ``LearnerContentAssignment``
record by list of uuids.
```
Raises:
400 If assignment_uuids list length is 0 or the value for days_before_course_start_date is less than 1
422 If the nudge_assignments call fails for any other reason
```
"""
serializer = LearnerContentAssignmentNudgeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
assignment_configuration_uuid = self.requested_assignment_configuration_uuid
assignments = self.get_queryset().filter(
assignment_configuration__uuid=assignment_configuration_uuid,
uuid__in=serializer.data['assignment_uuids'],
)
days_before_course_start_date = serializer.data['days_before_course_start_date']
try:
if len(assignments) == 0:
error_message = (
"The list of assignments provided are not associated to the assignment_configuration_uuid: {0}"
.format(assignment_configuration_uuid)
)
return Response(
data={"error_message": error_message}, status=status.HTTP_422_UNPROCESSABLE_ENTITY
)
result = assignments_api.nudge_assignments(
assignments,
assignment_configuration_uuid,
days_before_course_start_date
)
response_serializer = LearnerContentAssignmentNudgeResponseSerializer(data=result)
response_serializer.is_valid(raise_exception=True)
return Response(data=response_serializer.data, status=status.HTTP_200_OK)
except Exception: # pylint: disable=broad-except
error_message = (
"Could not process the nudge email(s) for assignment_configuration_uuid: {0}"
.format(assignment_configuration_uuid)
)
return Response(
data={"error_message": error_message},
status=status.HTTP_422_UNPROCESSABLE_ENTITY
)
Loading

0 comments on commit c1a4ad6

Please sign in to comment.