diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py index d3ccb01876493db9cb74e9ba0ef60d7bd379d5e5..16bbf5b817b229ab9cdf9b82147c3e2ce1e5a952 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -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. diff --git a/lms/djangoapps/program_enrollments/api/v1/urls.py b/lms/djangoapps/program_enrollments/api/v1/urls.py index 4089716154d368300078d9cd921bfdf5dc08c0fb..6ec312d49a384e5df23eb5470799269a351808ee 100644 --- a/lms/djangoapps/program_enrollments/api/v1/urls.py +++ b/lms/djangoapps/program_enrollments/api/v1/urls.py @@ -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(), diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index c11493db2eb13537dfaa1332c294994531f81aeb..8c74b1813a3baaf2831b165e30235eedfc4ea907 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -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. diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py index 082ad7ecda481aade74bc692b9643ab552a17b7e..9708d14c22e9c3ef0c2d101230812a4088a02ce0 100644 --- a/openedx/core/djangoapps/catalog/tests/test_utils.py +++ b/openedx/core/djangoapps/catalog/tests/test_utils.py @@ -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') diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 89c41ced23b0d75929f473de73cba5fbc5a09baa..191d337ef2e7d48686765e8b40496399a7577e5c 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -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))