diff --git a/cms/envs/common.py b/cms/envs/common.py index 7666903041..367665ee50 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1831,6 +1831,9 @@ INSTALLED_APPS = [ # Search 'openedx.core.djangoapps.content.search', + # For Programs API + 'lms.djangoapps.program_enrollments', + 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', 'openedx.features.discounts', diff --git a/lms/djangoapps/learner_dashboard/api/urls.py b/lms/djangoapps/learner_dashboard/api/urls.py deleted file mode 100644 index 07b808a81e..0000000000 --- a/lms/djangoapps/learner_dashboard/api/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Learner Dashboard API URLs. -""" - -from django.urls import include, path - -app_name = 'learner_dashboard' -urlpatterns = [ - path('v0/', include('lms.djangoapps.learner_dashboard.api.v0.urls')), -] diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py deleted file mode 100644 index 93035c817d..0000000000 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Learner Dashboard API v0 URLs. -""" - -from django.urls import re_path - -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 deleted file mode 100644 index 1579fdd26a..0000000000 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ /dev/null @@ -1,336 +0,0 @@ -""" API v0 views. """ -import logging - -from enterprise.models import EnterpriseCourseEnrollment -from rest_framework.permissions import IsAuthenticated -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_and_course_data, - get_program_urls, -) - - -logger = logging.getLogger(__name__) - - -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 - } - } - ] - """ - - 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) - # return empty reponse if no enterprise enrollments exists for a user - if not enrollments: - return Response([]) - - 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'], - 'logo_image_url': authoring_organization['logo_image_url'] - } - ) - - 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** - - * Get progress details of a learner enrolled in a program. - - **Example Request** - - GET api/dashboard/v0/programs/{program_uuid}/progress_details/ - - **GET Parameters** - - A GET request must include the following parameters. - - * program_uuid: A string representation of uuid of the program. - - **GET Response Values** - - If the request for information about the program is successful, an HTTP 200 "OK" response - is returned. - - The HTTP 200 response has the following values. - - * urls: Urls to enroll/purchase a course or view program record. - - * program_data: Holds meta information about the program. - - * course_data: Learner's progress details for all courses in the program (in-progress/remaining/completed). - - * certificate_data: Details about learner's certificates status for all courses in the program and the - program itself. - - * industry_pathways: Industry pathways for the program, comes under additional credit opportunities. - - * credit_pathways: Credit pathways for the program, comes under additional credit opportunities. - - **Example GET Response** - - { - "urls": { - "program_listing_url": "/dashboard/programs/", - "track_selection_url": "/course_modes/choose/", - "commerce_api_url": "/api/commerce/v0/baskets/", - "buy_button_url": "http://ecommerce.com/basket/add/?", - "program_record_url": "https://credentials.example.com/records/programs/121234235525242344" - }, - "program_data": { - "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", - "title": "edX Demonstration Program", - "subtitle": "", - "type": "MicroMasters", - "status": "active", - "marketing_slug": "demo-program", - "marketing_url": "micromasters/demo-program", - "authoring_organizations": [], - "card_image_url": "http://edx.devstack.lms:18000/asset-v1:edX+DemoX+Demo_Course.jpg", - "is_program_eligible_for_one_click_purchase": false, - "pathway_ids": [ - 1, - 2 - ], - "is_learner_eligible_for_one_click_purchase": false, - "skus": ["AUD122342"], - }, - "course_data": { - "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", - "completed": [], - "in_progress": [], - "not_started": [ - { - "key": "edX+DemoX", - "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", - "title": "Demonstration Course", - "course_runs": [], - "entitlements": [], - "owners": [], - "image": "", - "short_description": "", - "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", - } - ], - }, - "certificate_data": [{ - "type": "course", - "title": "edX Demo Course", - 'url': "/certificates/6e57d3cce8e34cfcb60bd8e8b04r07e0", - }], - "industry_pathways": [ - { - "id": 2, - "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", - "name": "Demo Industry Pathway", - "org_name": "edX", - "email": "edx@edx.com", - "description": "Sample demo industry pathway", - "destination_url": "http://rit.edu/online/pathways/gtx-analytics-essential-tools-methods", - "pathway_type": "industry", - "program_uuids": [ - "a156a6e2-de91-4ce7-947a-888943e6b12a" - ] - } - ], - "credit_pathways": [ - { - "id": 1, - "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", - "name": "Demo Credit Pathway", - "org_name": "edX", - "email": "edx@edx.com", - "description": "Sample demo credit pathway!", - "destination_url": "http://rit.edu/online/pathways/ritx-design-thinking", - "pathway_type": "credit", - "program_uuids": [ - "a156a6e2-de91-4ce7-947a-888943e6b12a" - ] - } - ] - } - """ - - permission_classes = (IsAuthenticated,) - - def get(self, request, program_uuid): - """ - Retrieves progress details of a user in a specified program. - - Args: - request (Request): Django request object. - program_uuid (string): URI element specifying uuid of the program. - - Return: - """ - user = request.user - site = request.site - program_data, course_data = get_program_and_course_data(site, user, program_uuid) - if not program_data: - return Response( - status=404, - data={'error_code': 'No program data available.'} - ) - - certificate_data = get_certificates(user, program_data) - program_data.pop('courses') - - urls = get_program_urls(program_data) - if not certificate_data: - urls['program_record_url'] = None - - industry_pathways, credit_pathways = get_industry_and_credit_pathways(program_data, site) - - return Response( - { - 'urls': urls, - 'program_data': program_data, - 'course_data': course_data, - 'certificate_data': certificate_data, - 'industry_pathways': industry_pathways, - 'credit_pathways': credit_pathways, - } - ) diff --git a/lms/djangoapps/learner_dashboard/api/__init__.py b/lms/djangoapps/learner_home/rest_api/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/__init__.py rename to lms/djangoapps/learner_home/rest_api/__init__.py diff --git a/lms/djangoapps/learner_home/rest_api/urls.py b/lms/djangoapps/learner_home/rest_api/urls.py new file mode 100644 index 0000000000..647ac84fa4 --- /dev/null +++ b/lms/djangoapps/learner_home/rest_api/urls.py @@ -0,0 +1,11 @@ +""" +Programs API URLs. +""" + +from django.urls import include, path + +from openedx.core.djangoapps.programs.rest_api.v1 import urls as v1_programs_rest_api_urls + +urlpatterns = [ + path("v1/", include((v1_programs_rest_api_urls, "v1"))), +] diff --git a/lms/djangoapps/learner_home/urls.py b/lms/djangoapps/learner_home/urls.py index ad7cfef463..c56ccb5971 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -6,6 +6,8 @@ from django.urls import path from django.urls import include, re_path from lms.djangoapps.learner_home import views +from .rest_api import urls as rest_api_urls + app_name = "learner_home" @@ -13,4 +15,5 @@ app_name = "learner_home" urlpatterns = [ re_path(r"^init/?", views.InitializeView.as_view(), name="initialize"), path("mock/", include("lms.djangoapps.learner_home.mock.urls")), + path("", include(rest_api_urls)), ] diff --git a/lms/urls.py b/lms/urls.py index 9c7dd433a6..1ae425c9a9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -192,12 +192,12 @@ urlpatterns = [ path('api-admin/', include(('openedx.core.djangoapps.api_admin.urls', 'openedx.core.djangoapps.api_admin'), namespace='api_admin')), - # Learner Dashboard - path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')), - path('api/dashboard/', include('lms.djangoapps.learner_dashboard.api.urls', namespace='dashboard_api')), - - # Learner Home + # Learner Home and Program Dashboard path('api/learner_home/', include('lms.djangoapps.learner_home.urls', namespace='learner_home')), + path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')), + # This is the legacy URL for the program dashboard API when the legacy learner dashboard existed. + # Current-and-future advertised URLs for this API will be under 'api/learner_home' + path('api/dashboard/', include('openedx.core.djangoapps.programs.rest_api.urls', namespace='dashboard_api')), path( 'api/experiments/', diff --git a/openedx/core/djangoapps/programs/README.rst b/openedx/core/djangoapps/programs/README.rst index 6c5a3946c2..c2ccb4c023 100644 --- a/openedx/core/djangoapps/programs/README.rst +++ b/openedx/core/djangoapps/programs/README.rst @@ -2,20 +2,29 @@ Status: Maintenance Responsibilities ================ -The Programs app is responsible (along with the `credentials app`_) -for communicating with the `credentials service`_, which is -the system of record for a learner's Program Certificates, and which (when enabled by the edX -instance) is the system of record for accessing all of a learner's credentials. +The Programs app is responsible for: -It also hosts program discussion forum and program live configuration. - -.. _credentials service: https://github.com/openedx/credentials - -.. _credentials app: https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/credentials +* Communicating with the `credentials service`_ (along with the `credentials app`_). +* Program discussion forum and program live configuration. +* The REST API used to render the program dashboard. Legacy routes for this API, left over + from the deprecated remnants of the legacy learner dashboard, exist alongside future-proofed + routes which will work when the deprecated, legacy Program Dashboard is replaced with functionality + in the Learner Dashboard MFE. See Also ======== -* ``lms/djangoapps/learner_dashboard/``, which hosts the program dashboard. -* ``openedx/core/djangoapps/credentials`` +* `course_discovery_`: The system of record for the definition of a program. +* `credentials service_`: The system of record for a learner's Program Certificates and Program Records. +* `learner_record_`: The MFE displaying Program Records to learners. +* `legacy learner_dashboard_`: The legacy front-end for the program dashboard. +.. _course_discovery: https://github.com/openedx/course-discovery/ + +.. _credentials app: https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/credentials + +.. _credentials service: https://github.com/openedx/credentials + +.. _legacy learner_dashboard: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/learner_dashboard + +.. _learner_record: https://github.com/openedx/frontend-app-learner-record \ No newline at end of file diff --git a/lms/djangoapps/learner_dashboard/api/v0/__init__.py b/openedx/core/djangoapps/programs/rest_api/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/v0/__init__.py rename to openedx/core/djangoapps/programs/rest_api/__init__.py diff --git a/openedx/core/djangoapps/programs/rest_api/urls.py b/openedx/core/djangoapps/programs/rest_api/urls.py new file mode 100644 index 0000000000..6533f709b8 --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/urls.py @@ -0,0 +1,21 @@ +""" +Programs API URLs. + +This is legacy URLs for the program dashboard API from when the legacy learner +dashboard existed. Current-and-future advertised URLs for this API will be +under `api/learner_home`. This is why there is a version numbering discrepancy. +While these will still be reachable from `/dashboard/v0/programs` for backward +compatibility, the API will now be part of `/learner_dashboard/v1/programs`. +""" + +from django.urls import include, path + +from openedx.core.djangoapps.programs.rest_api.v1 import ( + urls as v1_programs_rest_api_urls, +) + +app_name = "openedx.core.djangoapps.programs" + +urlpatterns = [ + path("v0/", include((v1_programs_rest_api_urls, "v0"), namespace="v0")), +] diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py b/openedx/core/djangoapps/programs/rest_api/v1/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py rename to openedx/core/djangoapps/programs/rest_api/v1/__init__.py diff --git a/openedx/core/djangoapps/programs/rest_api/v1/tests/__init__.py b/openedx/core/djangoapps/programs/rest_api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py similarity index 65% rename from lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py rename to openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py index 48479920a4..e80d4c615a 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py @@ -8,10 +8,6 @@ from uuid import uuid4 from django.core.cache import cache from django.urls import reverse_lazy from enterprise.models import EnterpriseCourseEnrollment -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import ( - CourseFactory as ModuleStoreCourseFactory, -) from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, @@ -40,17 +36,24 @@ from openedx.features.enterprise_support.tests.factories import ( EnterpriseCustomerFactory, EnterpriseCustomerUserFactory, ) +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ( + CourseFactory as ModuleStoreCourseFactory, +) -PROGRAMS_UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' +PROGRAMS_UTILS_MODULE = "openedx.core.djangoapps.programs.utils" @skip_unless_lms -@mock.patch(PROGRAMS_UTILS_MODULE + '.get_pathways') -@mock.patch(PROGRAMS_UTILS_MODULE + '.get_programs') +@mock.patch(PROGRAMS_UTILS_MODULE + ".get_pathways") +@mock.patch(PROGRAMS_UTILS_MODULE + ".get_programs") class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Unit tests for the program progress detail page.""" + program_uuid = str(uuid4()) - url = reverse_lazy('learner_dashboard:v0:program_progress_detail', kwargs={'program_uuid': program_uuid}) + url = reverse_lazy( + "openedx.core.djangoapps.programs:v0:program_progress_detail", kwargs={"program_uuid": program_uuid} + ) @classmethod def setUpClass(cls): @@ -62,9 +65,9 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes cls.program_data = ProgramFactory(uuid=cls.program_uuid, courses=[course]) cls.pathway_data = PathwayFactory() - cls.program_data['pathway_ids'] = [cls.pathway_data['id']] - cls.pathway_data['program_uuids'] = [cls.program_data['uuid']] - del cls.pathway_data['programs'] # lint-amnesty, pylint: disable=unsupported-delete-operation + cls.program_data["pathway_ids"] = [cls.pathway_data["id"]] + cls.pathway_data["program_uuids"] = [cls.program_data["uuid"]] + del cls.pathway_data["programs"] # lint-amnesty, pylint: disable=unsupported-delete-operation def setUp(self): super().setUp() @@ -74,25 +77,25 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes def assert_program_data_present(self, response): """Verify that program data is present.""" - self.assertContains(response, 'program_data') - self.assertContains(response, 'course_data') - self.assertContains(response, 'urls') - self.assertContains(response, 'certificate_data') - self.assertContains(response, self.program_data['title']) + self.assertContains(response, "program_data") + self.assertContains(response, "course_data") + self.assertContains(response, "urls") + self.assertContains(response, "certificate_data") + self.assertContains(response, self.program_data["title"]) def assert_pathway_data_present(self, response): - """ Verify that the correct pathway data is present. """ - self.assertContains(response, 'industry_pathways') - self.assertContains(response, 'credit_pathways') + """Verify that the correct pathway data is present.""" + self.assertContains(response, "industry_pathways") + self.assertContains(response, "credit_pathways") - industry_pathways = response.data['industry_pathways'] - credit_pathways = response.data['credit_pathways'] - if self.pathway_data['pathway_type'] == PathwayType.CREDIT.value: - credit_pathway, = credit_pathways # Verify that there is only one credit pathway + industry_pathways = response.data["industry_pathways"] + credit_pathways = response.data["credit_pathways"] + if self.pathway_data["pathway_type"] == PathwayType.CREDIT.value: + (credit_pathway,) = credit_pathways # Verify that there is only one credit pathway assert self.pathway_data == credit_pathway assert [] == industry_pathways - elif self.pathway_data['pathway_type'] == PathwayType.INDUSTRY.value: - industry_pathway, = industry_pathways # Verify that there is only one industry pathway + elif self.pathway_data["pathway_type"] == PathwayType.INDUSTRY.value: + (industry_pathway,) = industry_pathways # Verify that there is only one industry pathway assert self.pathway_data == industry_pathway assert [] == credit_pathways @@ -104,11 +107,11 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes mock_get_programs.return_value = self.program_data mock_get_pathways.return_value = self.pathway_data - with mock.patch('lms.djangoapps.learner_dashboard.api.v0.views.get_certificates') as certs: - certs.return_value = [{'type': 'program', 'url': '/'}] + with mock.patch("openedx.core.djangoapps.programs.rest_api.v1.views.get_certificates") as certs: + certs.return_value = [{"type": "program", "url": "/"}] response = self.client.get(self.url) - assert response.status_code == 200 + self.assertEqual(200, response.status_code) self.assert_program_data_present(response) self.assert_pathway_data_present(response) @@ -135,15 +138,16 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes response = self.client.get(self.url) assert response.status_code == 404 - assert response.data['error_code'] == 'No program data available.' + assert response.data["error_code"] == "No program data available." +@skip_unless_lms class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): """Unit tests for the program details page.""" enterprise_uuid = str(uuid4()) program_uuid = str(uuid4()) - url = reverse_lazy('learner_dashboard:v0:program_list', kwargs={'enterprise_uuid': enterprise_uuid}) + url = reverse_lazy("openedx.core.djangoapps.programs:v0:program_list", kwargs={"enterprise_uuid": enterprise_uuid}) @classmethod def setUpClass(cls): @@ -155,30 +159,27 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): 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 + 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 + 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', - 'logo_image_url': 'http://test.org/media/organization/logos/test-logo.png' - }], + title="Journey to cooking", + type="MicroMasters", + authoring_organizations=[ + { + "key": "MAX", + "logo_image_url": "http://test.org/media/organization/logos/test-logo.png", + } + ], ) - cls.site = SiteFactory(domain='test.localhost') + cls.site = SiteFactory(domain="test.localhost") def setUp(self): super().setUp() @@ -187,10 +188,10 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): ProgramEnrollmentFactory.create( user=self.user, program_uuid=self.program_uuid, - external_user_key='0001', + external_user_key="0001", ) - @with_site_configuration(configuration={'COURSE_CATALOG_API_URL': 'foo'}) + @with_site_configuration(configuration={"COURSE_CATALOG_API_URL": "foo"}) def test_program_list(self): """ Verify API returns proper response. @@ -198,7 +199,7 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): cache.set( SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), [self.program_uuid], - None + None, ) response = self.client.get(self.url) @@ -206,33 +207,31 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): 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, - 'all_unenrolled': False, + 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, + "all_unenrolled": False, } - @with_site_configuration(configuration={'COURSE_CATALOG_API_URL': 'foo'}) + @with_site_configuration(configuration={"COURSE_CATALOG_API_URL": "foo"}) def test_program_empty_list_if_no_enterprise_enrollments(self): """ Verify API returns empty response if no enterprise enrollments exists for a learner. """ # delete all enterprise course enrollments for the user - EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=self.user.id - ).delete() + EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user__user_id=self.user.id).delete() cache.set( SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), [self.program_uuid], - None + None, ) response = self.client.get(self.url) diff --git a/openedx/core/djangoapps/programs/rest_api/v1/urls.py b/openedx/core/djangoapps/programs/rest_api/v1/urls.py new file mode 100644 index 0000000000..415a543a92 --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/v1/urls.py @@ -0,0 +1,26 @@ +""" +REST APIs for Programs. +""" + +from django.urls import re_path + +from openedx.core.djangoapps.programs.rest_api.v1.views import ( + ProgramProgressDetailView, + Programs, +) + +ENTERPRISE_UUID_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}" +PROGRAM_UUID_PATTERN = r"[0-9a-f-]+" + +urlpatterns = [ + re_path( + rf"^programs/(?P{ENTERPRISE_UUID_PATTERN})/$", + Programs.as_view(), + name="program_list", + ), + re_path( + rf"^programs/(?P{PROGRAM_UUID_PATTERN})/progress_details/$", + ProgramProgressDetailView.as_view(), + name="program_progress_detail", + ), +] diff --git a/openedx/core/djangoapps/programs/rest_api/v1/views.py b/openedx/core/djangoapps/programs/rest_api/v1/views.py new file mode 100644 index 0000000000..e271ec686a --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/v1/views.py @@ -0,0 +1,361 @@ +"""Views for the Programs REST API v1.""" + +import logging + +from enterprise.models import EnterpriseCourseEnrollment +from rest_framework.permissions import IsAuthenticated +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_and_course_data, + get_program_urls, +) + +logger = logging.getLogger(__name__) + + +class Programs(APIView): + """ + **Use Case** + + * Get a list of all programs in which request user has enrolled. + + **Example Request** + + GET /api/dashboard/v1/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 + } + } + ] + """ + + 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) + # return empty reponse if no enterprise enrollments exists for a user + if not enrollments: + return Response([]) + + 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"], + "logo_image_url": authoring_organization["logo_image_url"], + } + ) + + 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** + + * Get progress details of a learner enrolled in a program. + + **Example Request** + + GET api/dashboard/v1/programs/{program_uuid}/progress_details/ + + **GET Parameters** + + A GET request must include the following parameters. + + * program_uuid: A string representation of uuid of the program. + + **GET Response Values** + + If the request for information about the program is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * urls: Urls to enroll/purchase a course or view program record. + + * program_data: Holds meta information about the program. + + * course_data: Learner's progress details for all courses in the program (in-progress/remaining/completed). + + * certificate_data: Details about learner's certificates status for all courses in the program and the + program itself. + + * industry_pathways: Industry pathways for the program, comes under additional credit opportunities. + + * credit_pathways: Credit pathways for the program, comes under additional credit opportunities. + + **Example GET Response** + + { + "urls": { + "program_listing_url": "/dashboard/programs/", + "track_selection_url": "/course_modes/choose/", + "commerce_api_url": "/api/commerce/v1/baskets/", + "buy_button_url": "http://ecommerce.com/basket/add/?", + "program_record_url": "https://credentials.example.com/records/programs/121234235525242344" + }, + "program_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "title": "edX Demonstration Program", + "subtitle": "", + "type": "MicroMasters", + "status": "active", + "marketing_slug": "demo-program", + "marketing_url": "micromasters/demo-program", + "authoring_organizations": [], + "card_image_url": "http://edx.devstack.lms:18000/asset-v1:edX+DemoX+Demo_Course.jpg", + "is_program_eligible_for_one_click_purchase": false, + "pathway_ids": [ + 1, + 2 + ], + "is_learner_eligible_for_one_click_purchase": false, + "skus": ["AUD122342"], + }, + "course_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "completed": [], + "in_progress": [], + "not_started": [ + { + "key": "edX+DemoX", + "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", + "title": "Demonstration Course", + "course_runs": [], + "entitlements": [], + "owners": [], + "image": "", + "short_description": "", + "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", + } + ], + }, + "certificate_data": [{ + "type": "course", + "title": "edX Demo Course", + 'url': "/certificates/6e57d3cce8e34cfcb60bd8e8b04r07e0", + }], + "industry_pathways": [ + { + "id": 2, + "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", + "name": "Demo Industry Pathway", + "org_name": "edX", + "email": "edx@edx.com", + "description": "Sample demo industry pathway", + "destination_url": "http://rit.edu/online/pathways/gtx-analytics-essential-tools-methods", + "pathway_type": "industry", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ], + "credit_pathways": [ + { + "id": 1, + "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", + "name": "Demo Credit Pathway", + "org_name": "edX", + "email": "edx@edx.com", + "description": "Sample demo credit pathway!", + "destination_url": "http://rit.edu/online/pathways/ritx-design-thinking", + "pathway_type": "credit", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ] + } + """ + + permission_classes = (IsAuthenticated,) + + def get(self, request, program_uuid): + """ + Retrieves progress details of a user in a specified program. + + Args: + request (Request): Django request object. + program_uuid (string): URI element specifying uuid of the program. + + Return: + """ + user = request.user + site = request.site + program_data, course_data = get_program_and_course_data( + site, user, program_uuid + ) + if not program_data: + return Response( + status=404, data={"error_code": "No program data available."} + ) + + certificate_data = get_certificates(user, program_data) + program_data.pop("courses") + + urls = get_program_urls(program_data) + if not certificate_data: + urls["program_record_url"] = None + + industry_pathways, credit_pathways = get_industry_and_credit_pathways( + program_data, site + ) + + return Response( + { + "urls": urls, + "program_data": program_data, + "course_data": course_data, + "certificate_data": certificate_data, + "industry_pathways": industry_pathways, + "credit_pathways": credit_pathways, + } + )