From 3136134be8ee19a66c0693a9b7d20a7bdfc0455b Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Mon, 24 Mar 2025 12:06:52 -0400 Subject: [PATCH] chore: move the program dashboard APIs (#36420) Moves the Program Dashboard APIs out of the deprecated remnants of the legacy learner dashboard, into the Programs djangoapp. Keeps the old legacy routes for this API, left over from the deprecated remnants of the legacy learner dashboard, alongside future-proofed routes which will work when the deprecated, legacy Program Dashboard is eventually replaced with functionality in the Learner Dashboard MFE. FIXES: APER-3949 --- cms/envs/common.py | 3 + lms/djangoapps/learner_dashboard/api/urls.py | 10 - .../learner_dashboard/api/v0/urls.py | 23 -- .../learner_dashboard/api/v0/views.py | 336 ---------------- .../api => learner_home/rest_api}/__init__.py | 0 lms/djangoapps/learner_home/rest_api/urls.py | 11 + lms/djangoapps/learner_home/urls.py | 3 + lms/urls.py | 10 +- openedx/core/djangoapps/programs/README.rst | 31 +- .../djangoapps/programs/rest_api}/__init__.py | 0 .../core/djangoapps/programs/rest_api/urls.py | 21 + .../programs/rest_api/v1}/__init__.py | 0 .../programs/rest_api/v1/tests/__init__.py | 0 .../programs/rest_api/v1}/tests/test_views.py | 127 +++--- .../djangoapps/programs/rest_api/v1/urls.py | 26 ++ .../djangoapps/programs/rest_api/v1/views.py | 361 ++++++++++++++++++ 16 files changed, 513 insertions(+), 449 deletions(-) delete mode 100644 lms/djangoapps/learner_dashboard/api/urls.py delete mode 100644 lms/djangoapps/learner_dashboard/api/v0/urls.py delete mode 100644 lms/djangoapps/learner_dashboard/api/v0/views.py rename lms/djangoapps/{learner_dashboard/api => learner_home/rest_api}/__init__.py (100%) create mode 100644 lms/djangoapps/learner_home/rest_api/urls.py rename {lms/djangoapps/learner_dashboard/api/v0 => openedx/core/djangoapps/programs/rest_api}/__init__.py (100%) create mode 100644 openedx/core/djangoapps/programs/rest_api/urls.py rename {lms/djangoapps/learner_dashboard/api/v0/tests => openedx/core/djangoapps/programs/rest_api/v1}/__init__.py (100%) create mode 100644 openedx/core/djangoapps/programs/rest_api/v1/tests/__init__.py rename {lms/djangoapps/learner_dashboard/api/v0 => openedx/core/djangoapps/programs/rest_api/v1}/tests/test_views.py (65%) create mode 100644 openedx/core/djangoapps/programs/rest_api/v1/urls.py create mode 100644 openedx/core/djangoapps/programs/rest_api/v1/views.py 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, + } + )