Add endpoint for checking learner program enrollments
JIRA:EDUCATOR-4327
This commit is contained in:
@@ -54,6 +54,7 @@ class ListViewTestMixin(object):
|
||||
def setUpClass(cls):
|
||||
super(ListViewTestMixin, cls).setUpClass()
|
||||
cls.program_uuid = '00000000-1111-2222-3333-444444444444'
|
||||
cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}'
|
||||
cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
|
||||
cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444'
|
||||
|
||||
@@ -77,6 +78,37 @@ class ListViewTestMixin(object):
|
||||
return reverse(self.view_name, kwargs=kwargs)
|
||||
|
||||
|
||||
class LearnerProgramEnrollmentTest(ListViewTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for the LearnerProgramEnrollment view class
|
||||
"""
|
||||
view_name = 'programs_api:v1:learner_program_enrollments'
|
||||
|
||||
def test_401_if_anonymous(self):
|
||||
response = self.client.get(reverse(self.view_name))
|
||||
assert status.HTTP_401_UNAUTHORIZED == response.status_code
|
||||
|
||||
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
|
||||
def test_200_if_no_programs_enrolled(self, mock_get_programs):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
response = self.client.get(reverse(self.view_name))
|
||||
assert status.HTTP_200_OK == response.status_code
|
||||
assert response.data == []
|
||||
assert mock_get_programs.call_count == 1
|
||||
|
||||
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=[
|
||||
{'uuid': 'boop', 'marketing_slug': 'garbage-program'},
|
||||
{'uuid': 'boop-boop', 'marketing_slug': 'garbage-study'},
|
||||
{'uuid': 'boop-boop-boop', 'marketing_slug': 'garbage-life'},
|
||||
])
|
||||
def test_200_many_programs(self, mock_get_programs):
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
response = self.client.get(reverse(self.view_name))
|
||||
assert status.HTTP_200_OK == response.status_code
|
||||
assert len(response.data) == 3
|
||||
assert mock_get_programs.call_count == 1
|
||||
|
||||
|
||||
class ProgramEnrollmentListTest(ListViewTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for GET calls to the Program Enrollments API.
|
||||
|
||||
@@ -8,12 +8,18 @@ from lms.djangoapps.program_enrollments.api.v1.views import (
|
||||
ProgramEnrollmentsView,
|
||||
ProgramCourseEnrollmentsView,
|
||||
ProgramCourseEnrollmentOverviewView,
|
||||
LearnerProgramEnrollmentsView,
|
||||
)
|
||||
from openedx.core.constants import COURSE_ID_PATTERN
|
||||
|
||||
app_name = 'lms.djangoapps.program_enrollments'
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'^programs/enrollments/$',
|
||||
LearnerProgramEnrollmentsView.as_view(),
|
||||
name='learner_program_enrollments'
|
||||
),
|
||||
url(
|
||||
r'^programs/{program_uuid}/enrollments/$'.format(program_uuid=PROGRAM_UUID_PATTERN),
|
||||
ProgramEnrollmentsView.as_view(),
|
||||
|
||||
@@ -440,6 +440,58 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
|
||||
)
|
||||
|
||||
|
||||
class LearnerProgramEnrollmentsView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
A view for checking the currently logged-in learner's program enrollments
|
||||
|
||||
Path: `/api/program_enrollments/v1/programs/enrollments/`
|
||||
|
||||
Returns:
|
||||
* 200: OK - Contains a list of all programs in which the learner is enrolled.
|
||||
* 401: The requesting user is not authenticated.
|
||||
|
||||
The list will be a list of objects with the following keys:
|
||||
* `uuid` - the identifier of the program in which the learner is enrolled.
|
||||
* `slug` - the string from which a link to the corresponding program page can be constructed.
|
||||
|
||||
Example:
|
||||
[
|
||||
{
|
||||
'uuid': '00000000-1111-2222-3333-444444444444',
|
||||
'slug': 'deadbeef'
|
||||
},
|
||||
{
|
||||
'uuid': '00000000-1111-2222-3333-444444444445',
|
||||
'slug': 'undead-cattle'
|
||||
}
|
||||
]
|
||||
"""
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
OAuth2AuthenticationAllowInactiveUser,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
How to respond to a GET request to this endpoint
|
||||
"""
|
||||
program_enrollments = ProgramEnrollment.objects.filter(
|
||||
user=request.user,
|
||||
status__in=('enrolled', 'pending')
|
||||
)
|
||||
|
||||
uuids = [enrollment.program_uuid for enrollment in program_enrollments]
|
||||
|
||||
catalog_data_of_programs = get_programs(uuids=uuids) or []
|
||||
programs_in_which_learner_is_enrolled = [{'uuid': program['uuid'], 'slug': program['marketing_slug']}
|
||||
for program
|
||||
in catalog_data_of_programs]
|
||||
|
||||
return Response(programs_in_which_learner_is_enrolled, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProgramSpecificViewMixin(object):
|
||||
"""
|
||||
A mixin for views that operate on or within a specific program.
|
||||
|
||||
@@ -208,6 +208,27 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
self.assertEqual(actual_program, [expected_program])
|
||||
self.assertFalse(mock_warning.called)
|
||||
|
||||
def test_get_via_uuids(self, mock_warning, _mock_info):
|
||||
first_program = ProgramFactory()
|
||||
second_program = ProgramFactory()
|
||||
|
||||
cache.set(
|
||||
PROGRAM_CACHE_KEY_TPL.format(uuid=first_program['uuid']),
|
||||
first_program,
|
||||
None
|
||||
)
|
||||
cache.set(
|
||||
PROGRAM_CACHE_KEY_TPL.format(uuid=second_program['uuid']),
|
||||
second_program,
|
||||
None
|
||||
)
|
||||
|
||||
results = get_programs(uuids=[first_program['uuid'], second_program['uuid']])
|
||||
|
||||
assert first_program in results
|
||||
assert second_program in results
|
||||
assert not mock_warning.called
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@mock.patch(UTILS_MODULE + '.logger.info')
|
||||
|
||||
@@ -30,6 +30,8 @@ from student.models import CourseEnrollment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
missing_details_msg_tpl = u'Failed to get details for program {uuid} from the cache.'
|
||||
|
||||
|
||||
def create_catalog_api_client(user, site=None):
|
||||
"""Returns an API client which can be used to make Catalog API requests."""
|
||||
@@ -82,7 +84,7 @@ def check_catalog_integration_and_get_user(error_message_field):
|
||||
return None, catalog_integration
|
||||
|
||||
|
||||
def get_programs(site=None, uuid=None, course=None): # pylint: disable=redefined-outer-name
|
||||
def get_programs(site=None, uuid=None, uuids=None, course=None): # pylint: disable=redefined-outer-name
|
||||
"""Read programs from the cache.
|
||||
|
||||
The cache is populated by a management command, cache_programs.
|
||||
@@ -90,17 +92,16 @@ def get_programs(site=None, uuid=None, course=None): # pylint: disable=redefine
|
||||
Keyword Arguments:
|
||||
site (Site): django.contrib.sites.models object
|
||||
uuid (string): UUID identifying a specific program to read from the cache.
|
||||
uuids (list of string): UUIDs identifying a specific programs to read from the cache.
|
||||
course (string): course id identifying a specific course run to read from the cache.
|
||||
|
||||
Returns:
|
||||
list of dict, representing programs.
|
||||
dict, if a specific program is requested.
|
||||
"""
|
||||
if len([arg for arg in (site, uuid, course) if arg is not None]) != 1:
|
||||
if len([arg for arg in (site, uuid, uuids, course) if arg is not None]) != 1:
|
||||
raise TypeError('get_programs takes exactly one argument')
|
||||
|
||||
missing_details_msg_tpl = u'Failed to get details for program {uuid} from the cache.'
|
||||
|
||||
if uuid:
|
||||
program = cache.get(PROGRAM_CACHE_KEY_TPL.format(uuid=uuid))
|
||||
if not program:
|
||||
@@ -113,12 +114,22 @@ def get_programs(site=None, uuid=None, course=None): # pylint: disable=redefine
|
||||
# Currently, the cache does not differentiate between a cache miss and a course
|
||||
# without programs. After this is changed, log any cache misses here.
|
||||
return []
|
||||
else:
|
||||
elif site:
|
||||
uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [])
|
||||
if not uuids:
|
||||
logger.warning(u'Failed to get program UUIDs from the cache for site {}.'.format(site.domain))
|
||||
|
||||
programs = cache.get_many([PROGRAM_CACHE_KEY_TPL.format(uuid=uuid) for uuid in uuids])
|
||||
return get_programs_by_uuids(uuids)
|
||||
|
||||
|
||||
def get_programs_by_uuids(uuids):
|
||||
"""
|
||||
Gets a list of programs for the provided uuids
|
||||
"""
|
||||
# a list of UUID objects would be a perfectly reasonable parameter to provide
|
||||
uuid_strings = [six.text_type(handle) for handle in uuids]
|
||||
|
||||
programs = cache.get_many([PROGRAM_CACHE_KEY_TPL.format(uuid=handle) for handle in uuid_strings])
|
||||
programs = list(programs.values())
|
||||
|
||||
# The get_many above sometimes fails to bring back details cached on one or
|
||||
@@ -129,7 +140,7 @@ def get_programs(site=None, uuid=None, course=None): # pylint: disable=redefine
|
||||
# immediately afterwards will succeed in bringing back all the keys. This
|
||||
# behavior can be mitigated by trying again for the missing keys, which is
|
||||
# what we do here. Splitting the get_many into smaller chunks may also help.
|
||||
missing_uuids = set(uuids) - set(program['uuid'] for program in programs)
|
||||
missing_uuids = set(uuid_strings) - set(program['uuid'] for program in programs)
|
||||
if missing_uuids:
|
||||
logger.info(
|
||||
u'Failed to get details for {count} programs. Retrying.'.format(count=len(missing_uuids))
|
||||
@@ -138,7 +149,7 @@ def get_programs(site=None, uuid=None, course=None): # pylint: disable=redefine
|
||||
retried_programs = cache.get_many([PROGRAM_CACHE_KEY_TPL.format(uuid=uuid) for uuid in missing_uuids])
|
||||
programs += list(retried_programs.values())
|
||||
|
||||
still_missing_uuids = set(uuids) - set(program['uuid'] for program in programs)
|
||||
still_missing_uuids = set(uuid_strings) - set(program['uuid'] for program in programs)
|
||||
for uuid in still_missing_uuids:
|
||||
logger.warning(missing_details_msg_tpl.format(uuid=uuid))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user