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:
@@ -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',
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
11
lms/djangoapps/learner_home/rest_api/urls.py
Normal file
11
lms/djangoapps/learner_home/rest_api/urls.py
Normal 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"))),
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
10
lms/urls.py
10
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/',
|
||||
|
||||
@@ -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
|
||||
21
openedx/core/djangoapps/programs/rest_api/urls.py
Normal file
21
openedx/core/djangoapps/programs/rest_api/urls.py
Normal 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")),
|
||||
]
|
||||
@@ -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)
|
||||
26
openedx/core/djangoapps/programs/rest_api/v1/urls.py
Normal file
26
openedx/core/djangoapps/programs/rest_api/v1/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
361
openedx/core/djangoapps/programs/rest_api/v1/views.py
Normal file
361
openedx/core/djangoapps/programs/rest_api/v1/views.py
Normal 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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user