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: add nudge braze email using commands #388

Merged
merged 6 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading