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: remind pending assignment functionality #325

Closed
wants to merge 11 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from requests.exceptions import HTTPError
from rest_framework import serializers

from enterprise_access.apps.content_assignments.api import get_content_metadata_for_assignments
from enterprise_access.apps.content_assignments.content_metadata_api import get_content_metadata_for_assignments
from enterprise_access.apps.subsidy_access_policy.constants import CENTS_PER_DOLLAR, PolicyTypes
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def cancel(self, request, *args, uuid=None, **kwargs):
# if the assignment is not cancelable, this is a no-op.
cancellation_info = assignments_api.cancel_assignments([assignment_to_cancel])

# If the response contains one element in the `cancelled` list, that is the one we sent, indicating succcess.
# If the response contains one element in the `cancelled` list, that is the one we sent, indicating success.
cancellation_succeeded = len(cancellation_info['cancelled']) == 1

if cancellation_succeeded:
Expand All @@ -204,3 +204,32 @@ def cancel(self, request, *args, uuid=None, **kwargs):
return Response(response_serializer.data, status=status.HTTP_200_OK)
else:
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)

def remind(self, request, *args, uuid=None, **kwargs):
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
"""
Send reminders to a single learners with associated ``LearnerContentAssignment``
record by uuid.

Raises:
404 if the assignment was not found.
422 if the assignment was not able to be reminded.
"""
try:
assignment_to_remind = self.get_queryset().get(uuid=uuid)
except LearnerContentAssignment.DoesNotExist:
return Response(None, status=status.HTTP_404_NOT_FOUND)

# if the assignment is not remindable, this is a no-op.
reminder_info = assignments_api.remind_assignments([assignment_to_remind])
kiram15 marked this conversation as resolved.
Show resolved Hide resolved

# If the response contains one element in the `reminded` list, that is the one we sent, indicating success.
reminder_succeeded = len(reminder_info['reminded']) == 1

if reminder_succeeded:
# Serialize the assignment object obtained via get_queryset() instead of the one from the assignments_api.
# Only the former has the additional dynamic fields annotated, and those are required for serialization.
assignment_to_remind.refresh_from_db()
response_serializer = serializers.LearnerContentAssignmentAdminResponseSerializer(assignment_to_remind)
return Response(response_serializer.data, status=status.HTTP_200_OK)
else:
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
60 changes: 44 additions & 16 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator

from enterprise_access.apps.content_assignments.tasks import send_cancel_email_for_pending_assignment
from enterprise_access.apps.content_metadata.api import get_and_cache_catalog_content_metadata
from enterprise_access.apps.core.models import User
from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata

Expand Down Expand Up @@ -425,6 +423,9 @@ def cancel_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict:
'non-cancelable': <list of 0 or more non-cancelable assignments, e.g. already accepted assignments>,
}
"""
# pylint: disable=import-outside-toplevel
from enterprise_access.apps.content_assignments.tasks import send_cancel_email_for_pending_assignment

cancelable_assignments = set(
assignment for assignment in assignments
if assignment.state in LearnerContentAssignmentStateChoices.CANCELABLE_STATES
Expand Down Expand Up @@ -452,23 +453,50 @@ def cancel_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict:
}


def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments):
def remind_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict:
"""
Fetches (from cache or enterprise-catalog API call) content metadata
in bulk for the `content_keys` of the given assignments, provided
such metadata is related to the given `enterprise_catalog_uuid`.
Bulk remind assignments.

This is a no-op for assignments in the following states: [accepted, errored, canceled]. We only allow
assignments which are in the allocated state. Reminded and already-reminded assignments are bundled in
the response because this function is meant to be idempotent.


Args:
assignments (list(LearnerContentAssignment)): One or more assignments to remind.

Returns:
A dict mapping every content key of the provided assignments
to a content metadata dictionary, or null if no such dictionary
could be found for a given key.
A dict representing reminded and non-remindable assignments:
{
'reminded': <list of 0 or more reminded or already-reminded assignments>,
'non-remindable': <list of 0 or more non-remindable assignments>,
}
"""
content_keys = sorted({assignment.content_key for assignment in assignments})
content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys)
metadata_by_key = {
record['key']: record for record in content_metadata_list
}
from enterprise_access.apps.content_assignments.tasks import ( # pylint: disable=import-outside-toplevel
send_reminder_email_for_pending_assignment
)
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
remindable_assignments = set(
assignment for assignment in assignments
if assignment.state in LearnerContentAssignmentStateChoices.REMINDABLE_STATES
)
already_reminded_assignments = set(
assignment for assignment in assignments
if assignment.state == LearnerContentAssignmentStateChoices.REMINDED
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
)
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
non_remindable_assignments = set(assignments) - remindable_assignments - already_reminded_assignments

logger.info(f'Skipping {len(non_remindable_assignments)} non-remindable assignments.')
logger.info(f'Skipping {len(already_reminded_assignments)} already reminded assignments.')
logger.info(f'Reminding {len(remindable_assignments)} assignments.')

for assignment_to_remind in remindable_assignments:
assignment_to_remind.state = LearnerContentAssignmentStateChoices.REMINDED

reminded_assignments = _update_and_refresh_assignments(remindable_assignments, ['state'])
for reminded_assignment in reminded_assignments:
send_reminder_email_for_pending_assignment.delay(reminded_assignment.uuid)

return {
assignment.content_key: metadata_by_key.get(assignment.content_key)
for assignment in assignments
'reminded': list(set(reminded_assignments) | already_reminded_assignments),
'non_remindable': list(non_remindable_assignments),
}
7 changes: 6 additions & 1 deletion enterprise_access/apps/content_assignments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ class LearnerContentAssignmentStateChoices:
ACCEPTED = 'accepted'
CANCELLED = 'cancelled'
ERRORED = 'errored'
REMINDED = 'reminded'

CHOICES = (
(ALLOCATED, 'Allocated'),
(ACCEPTED, 'Accepted'),
(CANCELLED, 'Cancelled'),
(ERRORED, 'Errored'),
(REMINDED, 'Reminded')
)

# States which allow reallocation by an admin.
REALLOCATE_STATES = (CANCELLED, ERRORED)

# States which allow cancellation by an admin.
CANCELABLE_STATES = (ALLOCATED, ERRORED)
CANCELABLE_STATES = (ALLOCATED, ERRORED, REMINDED)

# States which allow reminders by an admin.
REMINDABLE_STATES = (ALLOCATED)
kiram15 marked this conversation as resolved.
Show resolved Hide resolved


class AssignmentActions:
Expand Down
27 changes: 27 additions & 0 deletions enterprise_access/apps/content_assignments/content_metadata_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
API file interacting with assignment metadata (created to avoid a circular
import error)
"""
from enterprise_access.apps.content_metadata.api import get_and_cache_catalog_content_metadata


def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments):
"""
Fetches (from cache or enterprise-catalog API call) content metadata
in bulk for the `content_keys` of the given assignments, provided
such metadata is related to the given `enterprise_catalog_uuid`.

Returns:
A dict mapping every content key of the provided assignments
to a content metadata dictionary, or null if no such dictionary
could be found for a given key.
"""
content_keys = sorted({assignment.content_key for assignment in assignments})
content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys)
metadata_by_key = {
record['key']: record for record in content_metadata_list
}
return {
assignment.content_key: metadata_by_key.get(assignment.content_key)
for assignment in assignments
}
75 changes: 75 additions & 0 deletions enterprise_access/apps/content_assignments/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,78 @@ def send_cancel_email_for_pending_assignment(cancelled_assignment_uuid):
learner_content_assignment_action.traceback = exc
learner_content_assignment_action.save()
raise


@shared_task(base=LoggedTaskWithRetry)
def send_reminder_email_for_pending_assignment(assignment_uuid):
"""
Send email via braze for reminding users of their pending assignment
Args:
assignment_uuid: (string) the subsidy request uuid
"""
# importing this here to get around a cyclical import error
# pylint: disable=import-outside-toplevel
from enterprise_access.apps.content_assignments.content_metadata_api import get_content_metadata_for_assignments
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
kiram15 marked this conversation as resolved.
Show resolved Hide resolved

learner_content_assignment_model = apps.get_model('content_assignments.LearnerContentAssignment')
subsidy_policy_model = apps.get_model('subsidy_access_policy.SubsidyAccessPolicy')
try:
assignment = learner_content_assignment_model.objects.get(uuid=assignment_uuid)
except learner_content_assignment_model.DoesNotExist:
logger.warning(f'request with uuid: {assignment_uuid} does not exist.')
return

try:
policy = subsidy_policy_model.objects.get(
assignment_configuration=assignment.assignment_configuration
)
except subsidy_policy_model.DoesNotExist:
logger.warning(f'policy with assignment config: {assignment.assignment_configuration} does not exist.')
return

learner_content_assignment_action = LearnerContentAssignmentAction(
assignment=assignment, action_type=AssignmentActions.REMINDED,
)
braze_trigger_properties = {}
braze_client_instance = BrazeApiClient()
lms_client = LmsApiClient()
enterprise_customer_uuid = assignment.assignment_configuration.enterprise_customer_uuid
enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid)
admin_emails = [user['email'] for user in enterprise_customer_data['admin_users']]
course_metadata = get_content_metadata_for_assignments(
policy.catalog_uuid, assignment.assignment_configuration
)
learner_portal_url = '{}/{}'.format(
settings.ENTERPRISE_LEARNER_PORTAL_URL,
enterprise_customer_data['slug'],
)
lms_user_id = assignment.lms_user_id
braze_trigger_properties['contact_admin_link'] = braze_client_instance.generate_mailto_link(admin_emails)

try:
recipient = braze_client_instance.create_recipient(
user_email=assignment.learner_email,
lms_user_id=assignment.lms_user_id,
)
braze_trigger_properties["organization"] = enterprise_customer_data['name']
braze_trigger_properties["course_title"] = assignment.content_title
braze_trigger_properties["enrollment_deadline"] = course_metadata['normalized_metadata']['enroll_by_date']
braze_trigger_properties["start_date"] = course_metadata['normalized_metadata']['start_date']
braze_trigger_properties["course_partner"] = course_metadata['owners'][0]['name']
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only taking the first owner's name to send, is that an OK default to have? Or should I create a method that loops through if there's multiple and do "Harvard and Stanford" or something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine for now. We can circle back on this with relative ease in the future.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, we generally show all partners (when there are multiple) on the course cards in the UIs, comma-separated. I feel we should likely include all partner names in the email, for consistency with what's shown in the UI for admins/learners (similar to what you suggested, Kira). Main concern is that don't tackle this, we'd intentionally be favoring one course partner over another, which isn't great.

If supporting multiple partners is deferred for this PR, I feel it should be tackled as a follow-up for sure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's a good point!

braze_trigger_properties["course_card_image"] = course_metadata['card_image_url']
braze_trigger_properties["learner_portal_link"] = learner_portal_url

logger.info(f'Sending braze campaign message for reminded assignment {assignment}')
braze_client_instance.send_campaign_message(
settings.BRAZE_ASSIGNMENT_REMINDER_NOTIFICATION_CAMPAIGN,
recipients=[recipient],
trigger_properties=braze_trigger_properties,
)
learner_content_assignment_action.completed_at = datetime.now()
learner_content_assignment_action.save()
return
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error(f"Unable to send email for {lms_user_id} due to exception: {exc}")
learner_content_assignment_action.error_reason = AssignmentActionErrors.EMAIL_ERROR
learner_content_assignment_action.traceback = exc
learner_content_assignment_action.save()
kiram15 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def test_allocate_assignments_happy_path(self, mock_get_and_cache_content_metada
(cancelled_assignment, errored_assignment, created_assignment)
], any_order=True)

@mock.patch('enterprise_access.apps.content_assignments.api.send_cancel_email_for_pending_assignment')
@mock.patch('enterprise_access.apps.content_assignments.tasks.send_cancel_email_for_pending_assignment')
def test_cancel_assignments_happy_path(self, mock_notify):
"""
Tests the allocation of new assignments against a given configuration.
Expand Down
68 changes: 67 additions & 1 deletion enterprise_access/apps/content_assignments/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
from enterprise_access.apps.content_assignments.tasks import (
create_pending_enterprise_learner_for_assignment_task,
send_cancel_email_for_pending_assignment
send_cancel_email_for_pending_assignment,
send_reminder_email_for_pending_assignment
)
from enterprise_access.apps.content_assignments.tests.factories import (
AssignmentConfigurationFactory,
Expand Down Expand Up @@ -243,3 +244,68 @@ def test_send_cancel_email_for_pending_assignment(self, mock_braze_client, mock_
},
)
assert mock_braze_client.return_value.send_campaign_message.call_count == 1

@mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.objects')
@mock.patch('enterprise_access.apps.content_assignments.content_metadata_api.get_content_metadata_for_assignments')
@mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient')
@mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient')
def test_send_reminder_email_for_pending_assignment(
self, mock_braze_client, mock_lms_client, mock_get_metadata,
mock_policy_model, # pylint: disable=unused-argument
):
"""
Verify send_reminder_email_for_pending_assignment hits braze client with expected args
"""
content_key = 'demoX'
admin_email = 'test@admin.com'
mock_lms_client.return_value.get_enterprise_customer_data.return_value = {
'uuid': TEST_ENTERPRISE_UUID,
'slug': 'test-slug',
'admin_users': [{
'email': admin_email,
'lms_user_id': 1
}],
'name': self.enterprise_customer_name,
}
mock_recipient = {
'external_user_id': 1
}
mock_get_metadata.return_value = {
'key': content_key,
'normalized_metadata': {
'start_date': '2020-01-01 12:00:00Z',
'end_date': '2022-01-01 12:00:00Z',
'enroll_by_date': '2021-01-01 12:00:00Z',
'content_price': 123,
},
'owners': [
{'name': 'Smart Folks', 'logo_image_url': 'http://pictures.yes'},
],
'card_image_url': 'https://itsanimage.com'
}

mock_admin_mailto = f'mailto:{admin_email}'
mock_braze_client.return_value.create_recipient.return_value = mock_recipient
mock_braze_client.return_value.generate_mailto_link.return_value = mock_admin_mailto
send_reminder_email_for_pending_assignment(self.assignment.uuid)

# Make sure our LMS client got called correct times and with what we expected
mock_lms_client.return_value.get_enterprise_customer_data.assert_called_with(
self.assignment_configuration.enterprise_customer_uuid
)

assert mock_braze_client.return_value.send_campaign_message.call_count == 1
mock_braze_client.return_value.send_campaign_message.assert_called_once_with(
'test-assignment-remind-campaign',
recipients=[mock_recipient],
trigger_properties={
'contact_admin_link': mock_admin_mailto,
'organization': self.enterprise_customer_name,
'course_title': self.assignment.content_title,
'enrollment_deadline': '2021-01-01 12:00:00Z',
'start_date': '2020-01-01 12:00:00Z',
'course_partner': 'Smart Folks',
'course_card_image': 'https://itsanimage.com',
'learner_portal_link': 'http://enterprise-learner-portal.example.com/test-slug'
},
)
4 changes: 2 additions & 2 deletions enterprise_access/apps/subsidy_request/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def send_admins_email_with_new_requests_task(enterprise_customer_uuid):
Args:
enterprise_customer_uuid (str): enterprise customer uuid identifier
Raises:
HTTPError if Braze client callfails with an HTTPError
HTTPError if Braze client call fails with an HTTPError
"""
config_model = apps.get_model('subsidy_request.SubsidyRequestCustomerConfiguration')
customer_config = config_model.objects.get(
Expand Down Expand Up @@ -99,10 +99,10 @@ def send_admins_email_with_new_requests_task(enterprise_customer_uuid):
)
return

braze_trigger_properties = {}
lms_client = LmsApiClient()
enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid)
enterprise_slug = enterprise_customer_data.get('slug')
braze_trigger_properties = {}
braze_trigger_properties['manage_requests_url'] = _get_manage_requests_url(subsidy_model, enterprise_slug)

braze_trigger_properties['requests'] = []
Expand Down
1 change: 1 addition & 0 deletions enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ def root(*path_fragments):
BRAZE_APPROVE_NOTIFICATION_CAMPAIGN = ''
BRAZE_DECLINE_NOTIFICATION_CAMPAIGN = ''
BRAZE_AUTO_DECLINE_NOTIFICATION_CAMPAIGN = ''
BRAZE_ASSIGNMENT_REMINDER_NOTIFICATION_CAMPAIGN = ''
BRAZE_ASSIGNMENT_CANCELLED_NOTIFICATION_CAMPAIGN = ''

BRAZE_API_URL = ''
Expand Down
1 change: 1 addition & 0 deletions enterprise_access/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
BRAZE_DECLINE_NOTIFICATION_CAMPAIGN = 'test-decline-campaign'
BRAZE_AUTO_DECLINE_NOTIFICATION_CAMPAIGN = 'test-campaign-id'
BRAZE_NEW_REQUESTS_NOTIFICATION_CAMPAIGN = 'test-new-subsidy-campaign'
BRAZE_ASSIGNMENT_REMINDER_NOTIFICATION_CAMPAIGN = 'test-assignment-remind-campaign'
BRAZE_ASSIGNMENT_CANCELLED_NOTIFICATION_CAMPAIGN = 'test-assignment-cancelled-campaign'

# SEGMENT CONFIGURATION
Expand Down
Loading