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
This commit is contained in:
Deborah Kaplan
2025-03-24 12:06:52 -04:00
committed by GitHub
parent 43493c79ca
commit 3136134be8
16 changed files with 513 additions and 449 deletions

View File

@@ -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',

View File

@@ -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')),
]

View File

@@ -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<enterprise_uuid>{UUID_REGEX_PATTERN})/$',
Programs.as_view(),
name='program_list'
),
re_path(r'^programs/(?P<program_uuid>[0-9a-f-]+)/progress_details/$', ProgramProgressDetailView.as_view(),
name='program_progress_detail'),
]

View File

@@ -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,
}
)

View File

@@ -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"))),
]

View File

@@ -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)),
]

View File

@@ -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/',

View File

@@ -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

View File

@@ -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")),
]

View File

@@ -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)

View File

@@ -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>{ENTERPRISE_UUID_PATTERN})/$",
Programs.as_view(),
name="program_list",
),
re_path(
rf"^programs/(?P<program_uuid>{PROGRAM_UUID_PATTERN})/progress_details/$",
ProgramProgressDetailView.as_view(),
name="program_progress_detail",
),
]

View File

@@ -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,
}
)