From c35025fd9d1118e8d41c0cbbf4e96cf057bc60da Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Wed, 2 Mar 2022 12:14:57 +0500 Subject: [PATCH] feat: api to fetch all programs where an enterprise learner is enrolled --- .../api/v0/tests/test_views.py | 94 ++++++++++- .../learner_dashboard/api/v0/urls.py | 9 +- .../learner_dashboard/api/v0/views.py | 158 +++++++++++++++++- openedx/core/djangoapps/programs/utils.py | 8 +- 4 files changed, 261 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index f94ba85101..ad94339d67 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -5,11 +5,15 @@ Unit tests for Learner Dashboard REST APIs and Views from unittest import mock from uuid import uuid4 +from django.core.cache import cache from django.urls import reverse_lazy from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import ProgramCacheMixin +from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory +from openedx.core.djangoapps.catalog.cache import SITE_PROGRAM_UUIDS_CACHE_KEY_TPL from openedx.core.djangoapps.catalog.constants import PathwayType from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory, @@ -18,7 +22,14 @@ from openedx.core.djangoapps.catalog.tests.factories import ( ProgramFactory ) from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, + EnterpriseCustomerFactory, + EnterpriseCustomerUserFactory +) PROGRAMS_UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' @@ -116,3 +127,84 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes response = self.client.get(self.url) assert response.status_code == 404 assert response.data['error_code'] == 'No program data available.' + + +class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): + """Unit tests for the program details page.""" + + enterprise_uuid = str(uuid4()) + program_uuid = str(uuid4()) + password = 'test' + url = reverse_lazy('learner_dashboard:v0:program_list', kwargs={'enterprise_uuid': enterprise_uuid}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user = UserFactory() + modulestore_course = ModuleStoreCourseFactory() + course_run = CourseRunFactory(key=str(modulestore_course.id)) + course = CourseFactory(course_runs=[course_run]) + enterprise_customer = EnterpriseCustomerFactory(uuid=cls.enterprise_uuid) + enterprise_customer_user = EnterpriseCustomerUserFactory( + user_id=cls.user.id, + enterprise_customer=enterprise_customer + ) + CourseEnrollmentFactory( + is_active=True, + course_id=modulestore_course.id, + user=cls.user + ) + EnterpriseCourseEnrollmentFactory( + course_id=modulestore_course.id, + enterprise_customer_user=enterprise_customer_user + ) + + cls.program = ProgramFactory( + uuid=cls.program_uuid, + courses=[course], + title='Journey to cooking', + type='MicroMasters', + authoring_organizations=[{ + 'key': 'MAX' + }], + ) + cls.site = SiteFactory(domain='test.localhost') + + def setUp(self): + super().setUp() + self.client.login(username=self.user.username, password=self.password) + self.set_program_in_catalog_cache(self.program_uuid, self.program) + ProgramEnrollmentFactory.create( + user=self.user, + program_uuid=self.program_uuid, + external_user_key='0001', + ) + + @with_site_configuration(configuration={'COURSE_CATALOG_API_URL': 'foo'}) + def test_program_list(self): + """ + Verify API returns proper response. + """ + cache.set( + SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), + [self.program_uuid], + None + ) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + program = response.data[0] + + assert len(program) + assert program['uuid'] == self.program['uuid'] + assert program['title'] == self.program['title'] + assert program['type'] == self.program['type'] + assert program['authoring_organizations'] == self.program['authoring_organizations'] + assert program['banner_image'] == self.program['banner_image'] + assert program['progress'] == { + 'uuid': self.program['uuid'], + 'completed': 0, + 'in_progress': 0, + 'not_started': 1 + } diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py index eaa71b9029..bb178172e9 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ b/lms/djangoapps/learner_dashboard/api/v0/urls.py @@ -4,10 +4,17 @@ Learner Dashboard API v0 URLs. from django.urls import re_path -from lms.djangoapps.learner_dashboard.api.v0.views import ProgramProgressDetailView +from lms.djangoapps.learner_dashboard.api.v0.views import Programs, ProgramProgressDetailView + +UUID_REGEX_PATTERN = r'[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}' app_name = 'v0' urlpatterns = [ + re_path( + fr'^programs/(?P{UUID_REGEX_PATTERN})/$', + Programs.as_view(), + name='program_list' + ), re_path(r'^programs/(?P[0-9a-f-]+)/progress_details/$', ProgramProgressDetailView.as_view(), name='program_progress_detail'), ] diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 0a52156f06..256ba7295b 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -1,18 +1,170 @@ """ API v0 views. """ +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from enterprise.models import EnterpriseCourseEnrollment from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework.response import Response from rest_framework.views import APIView + +from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.programs.utils import ( + ProgramProgressMeter, get_certificates, get_industry_and_credit_pathways, - get_program_urls, - get_program_and_course_data + get_program_and_course_data, + get_program_urls ) +class Programs(APIView): + """ + **Use Case** + + * Get a list of all programs in which request user has enrolled. + + **Example Request** + + GET /api/dashboard/v0/programs/{enterprise_uuid}/ + + **GET Parameters** + + A GET request must include the following parameters. + + * enterprise_uuid: UUID of an enterprise customer. + + **Example GET Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "edX Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e8.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "edX" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] + """ + + authentication_classes = (JwtAuthentication, SessionAuthentication,) + + permission_classes = (IsAuthenticated,) + + def get(self, request, enterprise_uuid): + """ + Return a list of a enterprise learner's all enrolled programs with their progress. + + Args: + request (Request): DRF request object. + enterprise_uuid (string): UUID of an enterprise customer. + """ + user = request.user + + enrollments = self._get_enterprise_course_enrollments(enterprise_uuid, user) + meter = ProgramProgressMeter( + request.site, + user, + enrollments=enrollments, + mobile_only=False, + include_course_entitlements=False + ) + engaged_programs = meter.engaged_programs + progress = meter.progress(programs=engaged_programs) + programs = self._extract_minimal_required_programs_data(engaged_programs) + programs = self._combine_programs_data_and_progress(programs, progress) + + return Response(programs) + + def _combine_programs_data_and_progress(self, programs_data, programs_progress): + """ + Return the combined program and progress data so that api clinet can easily process the data. + """ + for program_data in programs_data: + program_progress = next((item for item in programs_progress if item['uuid'] == program_data['uuid']), None) + program_data['progress'] = program_progress + + return programs_data + + def _extract_minimal_required_programs_data(self, programs_data): + """ + Return only the minimal required program data need for program listing page. + """ + def transform(key, value): + transformers = {'authoring_organizations': transform_authoring_organizations} + + if key in transformers: + return transformers[key](value) + + return value + + def transform_authoring_organizations(authoring_organizations): + """ + Extract only the required data for `authoring_organizations` for a program + """ + transformed_authoring_organizations = [] + for authoring_organization in authoring_organizations: + transformed_authoring_organizations.append({'key': authoring_organization['key']}) + + return transformed_authoring_organizations + + program_data_keys = ['uuid', 'title', 'type', 'banner_image', 'authoring_organizations'] + programs = [] + for program_data in programs_data: + program = {} + for program_data_key in program_data_keys: + program[program_data_key] = transform(program_data_key, program_data[program_data_key]) + + programs.append(program) + + return programs + + def _get_enterprise_course_enrollments(self, enterprise_uuid, user): + """ + Return only enterprise enrollments for a user. + """ + enterprise_enrollment_course_ids = list(EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=user.id, + enterprise_customer_user__enterprise_customer__uuid=enterprise_uuid, + ).values_list('course_id', flat=True)) + + course_enrollments = CourseEnrollment.enrollments_for_user(user).filter( + course_id__in=enterprise_enrollment_course_ids + ).select_related('course') + + return list(course_enrollments) + + class ProgramProgressDetailView(APIView): """ **Use Case** diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index ed19f0aa32..87a408b413 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -152,7 +152,7 @@ class ProgramProgressMeter: will only inspect this one program, not all programs the user may be engaged with. """ - def __init__(self, site, user, enrollments=None, uuid=None, mobile_only=False): + def __init__(self, site, user, enrollments=None, uuid=None, mobile_only=False, include_course_entitlements=True): self.site = site self.user = user self.mobile_only = mobile_only @@ -172,8 +172,10 @@ class ProgramProgressMeter: # We can't use dict.keys() for this because the course run ids need to be ordered self.course_run_ids.append(enrollment_id) - self.entitlements = list(CourseEntitlement.unexpired_entitlements_for_user(self.user)) - self.course_uuids = [str(entitlement.course_uuid) for entitlement in self.entitlements] + self.course_uuids = [] + if include_course_entitlements: + self.entitlements = list(CourseEntitlement.unexpired_entitlements_for_user(self.user)) + self.course_uuids = [str(entitlement.course_uuid) for entitlement in self.entitlements] if uuid: self.programs = [get_programs(uuid=uuid)]