Skip to content

Commit

Permalink
Merge branch 'main' into kiram15/ENT-7691
Browse files Browse the repository at this point in the history
  • Loading branch information
kiram15 committed Nov 13, 2023
2 parents 98b0bc8 + c0227be commit f1275b3
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 23 deletions.
5 changes: 5 additions & 0 deletions enterprise_access/apps/api/filters/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""
Base FilterSet utility classes.
"""
from django_filters import BaseInFilter, CharFilter
from django_filters import rest_framework as drf_filters


class CharInFilter(BaseInFilter, CharFilter):
pass


class HelpfulFilterSet(drf_filters.FilterSet):
"""
Using an explicit FilterSet object works nicely with drf-spectacular
Expand Down
5 changes: 4 additions & 1 deletion enterprise_access/apps/api/filters/content_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
API Filters for resources defined in the ``assignment_policy`` app.
"""
from ...content_assignments.models import AssignmentConfiguration, LearnerContentAssignment
from .base import HelpfulFilterSet
from .base import CharInFilter, HelpfulFilterSet


class AssignmentConfigurationFilter(HelpfulFilterSet):
Expand All @@ -18,11 +18,14 @@ class LearnerContentAssignmentAdminFilter(HelpfulFilterSet):
"""
Base filter for LearnerContentAssignment views.
"""
learner_state = CharInFilter(field_name='learner_state', lookup_expr='in')

class Meta:
model = LearnerContentAssignment
fields = [
'content_key',
'learner_email',
'lms_user_id',
'state',
'learner_state',
]
30 changes: 27 additions & 3 deletions enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,9 @@ def test_list(self, role_context_dict):
assert actual_assignment_uuids == expected_assignment_uuids

expected_learner_state_counts = [
{'count': 1, 'learner_state': 'failed'},
{'count': 1, 'learner_state': 'waiting'},
{'count': 1, 'learner_state': 'notifying'},
{'count': 1, 'learner_state': 'failed'},
]
assert response_json['learner_state_counts'] == expected_learner_state_counts

Expand Down Expand Up @@ -556,6 +556,30 @@ def test_cancel_non_cancelable_returns_422(self):
self.assignment_allocated_post_link.refresh_from_db()
assert self.assignment_accepted.state == LearnerContentAssignmentStateChoices.ACCEPTED

def test_learner_state_query_param_filter(self):
"""
Test that the list view supports filtering on one or more ``learner_state`` values via a query parameter.
"""
# Set the JWT-based auth that we'll use for every request.
self.set_jwt_cookie([{'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}])

# Fetch our list of assignments associated with the test enterprise.
assignments_for_enterprise_customer = LearnerContentAssignment.objects.filter(
assignment_configuration__enterprise_customer_uuid=TEST_ENTERPRISE_UUID
)
# Double check we have stuff to work with
assert assignments_for_enterprise_customer.count() > 1

# Hit the view with a learner_state query param.
learner_states_to_query = [AssignmentLearnerStates.WAITING, AssignmentLearnerStates.FAILED]
learner_state_query_param_value = ",".join(learner_states_to_query)
response = self.client.get(
ADMIN_ASSIGNMENTS_LIST_ENDPOINT + f"?learner_state={learner_state_query_param_value}"
)
# Assert the results only contain the requested ``learner_state`` values.
for assignment in response.json().get('results'):
assert assignment.get('learner_state') in learner_states_to_query

def test_assignment_search_query_param(self):
"""
Test that the list view follows the default Django API filtering with the usage of the ``search`` query param.
Expand All @@ -577,7 +601,7 @@ def test_assignment_search_query_param(self):
ADMIN_ASSIGNMENTS_LIST_ENDPOINT + f"?search={first_assignment.content_title}"
)
# Assert any of the results contain the content title matching the first assignment's.
for assignment in response.data.get('results'):
for assignment in response.json().get('results'):
assert assignment.get('content_title') == first_assignment.content_title

# Hit the view with a search query param for the learner email of another assignment.
Expand All @@ -586,7 +610,7 @@ def test_assignment_search_query_param(self):
ADMIN_ASSIGNMENTS_LIST_ENDPOINT + f"?search={second_assignment.learner_email}"
)
# Assert any of the results contain the learner email matching the second assignment's.
for assignment in response.data.get('results'):
for assignment in response.json().get('results'):
assert assignment.get('learner_email') == second_assignment.learner_email

# Hit the view with a search query param for a random string that should not match any assignments.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
Admin-facing REST API views for LearnerContentAssignments in the content_assignments app.
"""
import logging
from collections import Counter

from django.db.models import Count
from drf_spectacular.utils import extend_schema
from edx_rbac.decorators import permission_required
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
Expand All @@ -15,8 +15,8 @@
from enterprise_access.apps.api import filters, serializers, utils
from enterprise_access.apps.api.v1.views.utils import PaginationWithPageCount
from enterprise_access.apps.content_assignments import api as assignments_api
from enterprise_access.apps.content_assignments.constants import AssignmentActions, LearnerContentAssignmentStateChoices
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment, LearnerContentAssignmentAction
from enterprise_access.apps.content_assignments.constants import AssignmentLearnerStates
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment
from enterprise_access.apps.core.constants import (
CONTENT_ASSIGNMENT_ADMIN_READ_PERMISSION,
CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION
Expand Down Expand Up @@ -89,7 +89,7 @@ class LearnerContentAssignmentAdminViewSet(

# Settings that control list ordering, powered by OrderingFilter.
# Fields in `ordering_fields` are what we allow to be passed to the "?ordering=" query param.
ordering_fields = ['recent_action_time', 'learner_state_sort_order']
ordering_fields = ['recent_action_time', 'learner_state_sort_order', 'content_quantity']
# `ordering` defines the default order.
ordering = ['-recent_action_time']

Expand Down Expand Up @@ -122,7 +122,7 @@ def get_queryset(self):
# * learner_state_sort_order
# * recent_action
# * recent_action_time
queryset = LearnerContentAssignment.annotate_dynamic_fields_onto_queryset(queryset)
queryset = LearnerContentAssignment.annotate_dynamic_fields_onto_queryset(queryset).prefetch_related('actions')

return queryset

Expand Down Expand Up @@ -151,13 +151,18 @@ def list(self, request, *args, **kwargs):
Lists ``LearnerContentAssignment`` records, filtered by the given query parameters.
"""
response = super().list(request, *args, **kwargs)
queryset = self.get_queryset()
learner_state_counts = queryset.values('learner_state') \
.exclude(learner_state__isnull=True) \
.annotate(count=Count('uuid', distinct=True)) \
.order_by('-count')

# Add the learner_state_overview to the default response.
# Compute the learner_state_counts for the filtered queryset.
queryset = self.filter_queryset(self.get_queryset())
learner_state_counter = Counter(
queryset.exclude(learner_state__isnull=True).values_list('learner_state', flat=True)
)
learner_state_counts = [
{'learner_state': state, 'count': count}
for state, count in learner_state_counter.most_common()
]

# Add the learner_state_counts to the default response.
response.data['learner_state_counts'] = learner_state_counts
return response

Expand Down
4 changes: 4 additions & 0 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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 @@ -442,6 +443,9 @@ def cancel_assignments(assignments: Iterable[LearnerContentAssignment]) -> dict:
assignment_to_cancel.state = LearnerContentAssignmentStateChoices.CANCELLED

cancelled_assignments = _update_and_refresh_assignments(cancelable_assignments, ['state'])
for cancelled_assignment in cancelled_assignments:
send_cancel_email_for_pending_assignment.delay(cancelled_assignment.uuid)

return {
'cancelled': list(set(cancelled_assignments) | already_cancelled_assignments),
'non_cancelable': list(non_cancelable_assignments),
Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/apps/content_assignments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ class AssignmentActions:
LEARNER_LINKED = 'learner_linked'
NOTIFIED = 'notified'
REMINDED = 'reminded'
CANCELLED_NOTIFICATION = 'cancelled'

CHOICES = (
(LEARNER_LINKED, 'Learner linked to customer'),
(NOTIFIED, 'Learner notified of assignment'),
(REMINDED, 'Learner reminded about assignment'),
(CANCELLED_NOTIFICATION, 'Learner assignment cancelled'),
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.6 on 2023-11-06 19:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('content_assignments', '0010_alter_learnercontentassignmentaction_assignment'),
]

operations = [
migrations.AlterModelOptions(
name='learnercontentassignmentaction',
options={'ordering': ['created']},
),
migrations.AlterField(
model_name='historicallearnercontentassignmentaction',
name='action_type',
field=models.CharField(choices=[('learner_linked', 'Learner linked to customer'), ('notified', 'Learner notified of assignment'), ('reminded', 'Learner reminded about assignment'), ('cancelled', 'Learner assignment cancelled')], db_index=True, help_text='The type of action take on the related assignment record.', max_length=255),
),
migrations.AlterField(
model_name='learnercontentassignmentaction',
name='action_type',
field=models.CharField(choices=[('learner_linked', 'Learner linked to customer'), ('notified', 'Learner notified of assignment'), ('reminded', 'Learner reminded about assignment'), ('cancelled', 'Learner assignment cancelled')], db_index=True, help_text='The type of action take on the related assignment record.', max_length=255),
),
]
55 changes: 54 additions & 1 deletion enterprise_access/apps/content_assignments/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from django.apps import apps
from django.conf import settings

import enterprise_access.apps.content_assignments.api as content_api
from enterprise_access.apps.api_client.braze_client import BrazeApiClient
from enterprise_access.apps.api_client.lms_client import LmsApiClient
from enterprise_access.apps.content_assignments.constants import AssignmentActionErrors, AssignmentActions
Expand Down Expand Up @@ -82,13 +81,67 @@ def create_pending_enterprise_learner_for_assignment_task(learner_content_assign
)


@shared_task(base=LoggedTaskWithRetry)
def send_cancel_email_for_pending_assignment(cancelled_assignment_uuid):
"""
Send email via braze for cancelling pending assignment
Args:
cancelled_assignment: (string) the cancelled assignment uuid
"""
learner_content_assignment_model = apps.get_model('content_assignments.LearnerContentAssignment')

try:
assignment = learner_content_assignment_model.objects.get(uuid=cancelled_assignment_uuid)
except learner_content_assignment_model.DoesNotExist:
logger.warning(f'request with uuid: {cancelled_assignment_uuid} does not exist.')
return
learner_content_assignment_action = LearnerContentAssignmentAction(
assignment=assignment, action_type=AssignmentActions.CANCELLED_NOTIFICATION
)

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)
lms_user_id = assignment.lms_user_id
admin_emails = [user['email'] for user in enterprise_customer_data['admin_users']]
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_name"] = assignment.content_title
braze_client_instance.send_campaign_message(
settings.BRAZE_ASSIGNMENT_CANCELLED_NOTIFICATION_CAMPAIGN,
recipients=[recipient],
trigger_properties=braze_trigger_properties,
)
learner_content_assignment_action.completed_at = datetime.now()
learner_content_assignment_action.save()
logger.info(f'Sending braze campaign message for cancelled assignment {assignment}')
return
except Exception as exc:
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()
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
import enterprise_access.apps.content_assignments.api as content_api # pylint: disable=import-outside-toplevel
learner_content_assignment_model = apps.get_model('content_assignments.LearnerContentAssignment')
subsidy_policy_model = apps.get_model('subsidy_access_policy.SubsidyAccessPolicy')
try:
Expand Down
6 changes: 5 additions & 1 deletion enterprise_access/apps/content_assignments/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,8 @@ def test_allocate_assignments_happy_path(self, mock_get_and_cache_content_metada
(cancelled_assignment, errored_assignment, created_assignment)
], any_order=True)

def test_cancel_assignments_happy_path(self):
@mock.patch('enterprise_access.apps.content_assignments.api.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 Expand Up @@ -383,6 +384,9 @@ def test_cancel_assignments_happy_path(self):
self.assertEqual(accepted_assignment.state, LearnerContentAssignmentStateChoices.ACCEPTED)
self.assertEqual(cancelled_assignment.state, LearnerContentAssignmentStateChoices.CANCELLED)
self.assertEqual(errored_assignment.state, LearnerContentAssignmentStateChoices.CANCELLED)
mock_notify.delay.assert_has_calls([
mock.call(assignment.uuid) for assignment in (allocated_assignment, errored_assignment)
], any_order=True)

@mock.patch(
'enterprise_access.apps.content_assignments.api.create_pending_enterprise_learner_for_assignment_task'
Expand Down
49 changes: 43 additions & 6 deletions enterprise_access/apps/content_assignments/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
Tests for Enterprise Access content_assignments tasks.
"""

from unittest import mock
from uuid import uuid4

Expand All @@ -15,16 +14,14 @@
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_reminder_email_for_pending_assignment
)
from enterprise_access.apps.content_assignments.tests.factories import (
AssignmentConfigurationFactory,
LearnerContentAssignmentFactory
)
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
AssignedLearnerCreditAccessPolicyFactory,
PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory
)
from enterprise_access.apps.subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory
from test_utils import APITestWithMocks

TEST_ENTERPRISE_UUID = uuid4()
Expand Down Expand Up @@ -206,7 +203,47 @@ def setUp(self):
lms_user_id=TEST_LMS_USER_ID,
assignment_configuration=self.assignment_configuration,
)
self.policy = PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory()

@mock.patch('enterprise_access.apps.content_assignments.tasks.LmsApiClient')
@mock.patch('enterprise_access.apps.content_assignments.tasks.BrazeApiClient')
def test_send_cancel_email_for_pending_assignment(self, mock_braze_client, mock_lms_client):
"""
Verify send_cancel_email_for_pending_assignment hits braze client with expected args
"""
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_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_cancel_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
)

mock_braze_client.return_value.send_campaign_message.assert_any_call(
'test-assignment-cancelled-campaign',
recipients=[mock_recipient],
trigger_properties={
'contact_admin_link': mock_admin_mailto,
'organization': self.enterprise_customer_name,
'course_name': self.assignment.content_title
},
)
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.api.get_content_metadata_for_assignments')
Expand Down
Loading

0 comments on commit f1275b3

Please sign in to comment.