feat: Created an API to fetch progress details about a learner's program (#29975)

This commit is contained in:
Sameen Fatima
2022-03-04 16:11:28 +05:00
committed by GitHub
parent a838ab4b01
commit 0d9e845f1b
11 changed files with 397 additions and 50 deletions

View File

@@ -0,0 +1,10 @@
"""
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

@@ -0,0 +1,118 @@
"""
Unit tests for Learner Dashboard REST APIs and Views
"""
from unittest import mock
from uuid import uuid4
from django.urls import reverse_lazy
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.catalog.constants import PathwayType
from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory,
CourseRunFactory,
PathwayFactory,
ProgramFactory
)
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangolib.testing.utils import skip_unless_lms
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')
class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Unit tests for the program progress detail page."""
program_uuid = str(uuid4())
password = 'test'
url = reverse_lazy('learner_dashboard:v0:program_progress_detail', kwargs={'program_uuid': program_uuid})
@classmethod
def setUpClass(cls):
super().setUpClass()
modulestore_course = ModuleStoreCourseFactory()
course_run = CourseRunFactory(key=str(modulestore_course.id)) # lint-amnesty, pylint: disable=no-member
course = CourseFactory(course_runs=[course_run])
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
def setUp(self):
super().setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=self.password)
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'])
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')
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
assert self.pathway_data == industry_pathway
assert [] == credit_pathways
def test_api_returns_correct_program_data(self, mock_get_programs, mock_get_pathways):
"""
Verify that API returns program data in the correct format.
"""
self.create_programs_config()
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': '/'}]
response = self.client.get(self.url)
assert response.status_code == 200
self.assert_program_data_present(response)
self.assert_pathway_data_present(response)
def test_login_required(self, mock_get_programs, mock_get_pathways):
"""
Verify that API returns 401 to an unauthenticated user.
"""
self.create_programs_config()
mock_get_programs.return_value = self.program_data
mock_get_pathways.return_value = self.pathway_data
self.client.logout()
response = self.client.get(self.url)
assert response.status_code == 401
def test_404_if_no_program_data(self, mock_get_programs, _mock_get_pathways):
"""
Verify that the API returns 404 if program data is not available.
"""
self.create_programs_config()
mock_get_programs.return_value = {}
response = self.client.get(self.url)
assert response.status_code == 404
assert response.data['error_code'] == 'No program data available.'

View File

@@ -0,0 +1,13 @@
"""
Learner Dashboard API v0 URLs.
"""
from django.urls import re_path
from lms.djangoapps.learner_dashboard.api.v0.views import ProgramProgressDetailView
app_name = 'v0'
urlpatterns = [
re_path(r'^programs/(?P<program_uuid>[0-9a-f-]+)/progress_details/$', ProgramProgressDetailView.as_view(),
name='program_progress_detail'),
]

View File

@@ -0,0 +1,180 @@
""" API v0 views. """
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView
from openedx.core.djangoapps.programs.utils import (
get_certificates,
get_industry_and_credit_pathways,
get_program_urls,
get_program_and_course_data
)
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"
]
}
]
}
"""
authentication_classes = (
JwtAuthentication,
SessionAuthentication,
)
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

@@ -9,7 +9,6 @@ from urllib.parse import quote
from django.contrib.sites.shortcuts import get_current_site
from django.http import Http404
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _ # lint-amnesty, pylint: disable=unused-import
from django.utils.translation import to_locale
@@ -18,11 +17,8 @@ from web_fragments.fragment import Fragment
from common.djangoapps.student.models import anonymous_id_for_user
from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, program_tab_view_is_enabled, strip_course_id
from openedx.core.djangoapps.catalog.constants import PathwayType
from openedx.core.djangoapps.catalog.utils import get_pathways, get_programs
from openedx.core.djangoapps.credentials.utils import get_credentials_records_url
from lms.djangoapps.learner_dashboard.utils import program_tab_view_is_enabled
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangoapps.programs.models import (
ProgramDiscussionsConfiguration,
@@ -30,10 +26,12 @@ from openedx.core.djangoapps.programs.models import (
ProgramsApiConfig
)
from openedx.core.djangoapps.programs.utils import (
ProgramDataExtender,
ProgramProgressMeter,
get_certificates,
get_program_marketing_url
get_program_marketing_url,
get_industry_and_credit_pathways,
get_program_urls,
get_program_and_course_data
)
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from openedx.core.djangolib.markup import HTML
@@ -92,59 +90,28 @@ class ProgramDetailsFragmentView(EdxFragmentView):
"""View details about a specific program."""
programs_config = kwargs.get('programs_config') or ProgramsApiConfig.current()
user = request.user
site = request.site
if not programs_config.enabled or not request.user.is_authenticated:
raise Http404
meter = ProgramProgressMeter(request.site, user, uuid=program_uuid)
program_data = meter.programs[0]
if not program_data:
raise Http404
try:
mobile_only = json.loads(request.GET.get('mobile_only', 'false'))
except ValueError:
mobile_only = False
program_data = ProgramDataExtender(program_data, user, mobile_only=mobile_only).extend()
course_data = meter.progress(programs=[program_data], count_only=False)[0]
program_data, course_data = get_program_and_course_data(site, user, program_uuid, mobile_only)
if not program_data:
raise Http404
certificate_data = get_certificates(user, program_data)
program_data.pop('courses')
skus = program_data.get('skus')
ecommerce_service = EcommerceService()
# TODO: Don't have business logic of course-certificate==record-available here in LMS.
# Eventually, the UI should ask Credentials if there is a record available and get a URL from it.
# But this is here for now so that we can gate this URL behind both this business logic and
# a waffle flag. This feature is in active developoment.
program_record_url = get_credentials_records_url(program_uuid=program_uuid)
urls = get_program_urls(program_data)
if not certificate_data:
program_record_url = None
urls['program_record_url'] = None
industry_pathways = []
credit_pathways = []
try:
for pathway_id in program_data['pathway_ids']:
pathway = get_pathways(request.site, pathway_id)
if pathway and pathway['email']:
if pathway['pathway_type'] == PathwayType.CREDIT.value:
credit_pathways.append(pathway)
elif pathway['pathway_type'] == PathwayType.INDUSTRY.value:
industry_pathways.append(pathway)
# if pathway caching did not complete fully (no pathway_ids)
except KeyError:
pass
industry_pathways, credit_pathways = get_industry_and_credit_pathways(program_data, site)
urls = {
'program_listing_url': reverse('program_listing_view'),
'track_selection_url': strip_course_id(
reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY})
),
'commerce_api_url': reverse('commerce_api:v0:baskets:create'),
'buy_button_url': ecommerce_service.get_checkout_page_url(*skus),
'program_record_url': program_record_url,
}
program_discussion_lti = ProgramDiscussionLTI(program_uuid, request)
program_live_lti = ProgramLiveLTI(program_uuid, request)

View File

@@ -198,7 +198,7 @@ class TestProgramListing(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
@skip_unless_lms
@mock.patch(PROGRAMS_MODULE + '.get_pathways')
@mock.patch(PROGRAMS_UTILS_MODULE + '.get_pathways')
@mock.patch(PROGRAMS_UTILS_MODULE + '.get_programs')
class TestProgramDetails(ProgramsApiConfigMixin, CatalogIntegrationMixin, SharedModuleStoreTestCase):
"""Unit tests for the program details page."""

View File

@@ -195,6 +195,9 @@ urlpatterns = [
namespace='api_admin')),
path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')),
# Dashboard REST APIs
path('api/dashboard/', include('lms.djangoapps.learner_dashboard.api.urls', namespace='dashboard_api')),
path(
'api/experiments/',
include(

View File

@@ -29,13 +29,15 @@ from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.commerce.utils import EcommerceService
from openedx.core.djangoapps.catalog.api import get_programs_by_type
from openedx.core.djangoapps.catalog.constants import PathwayType
from openedx.core.djangoapps.catalog.utils import (
get_fulfillable_course_runs_for_entitlement,
get_programs,
get_pathways
)
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.utils import get_credentials
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_records_url
from openedx.core.djangoapps.enrollments.api import get_enrollments
from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.core.djangoapps.programs import ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER
@@ -50,6 +52,60 @@ DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc)
log = logging.getLogger(__name__)
def get_program_and_course_data(site, user, program_uuid, mobile_only=False):
"""Returns program and course data associated with the given user."""
course_data = {}
meter = ProgramProgressMeter(site, user, uuid=program_uuid)
program_data = meter.programs[0]
if program_data:
program_data = ProgramDataExtender(program_data, user, mobile_only=mobile_only).extend()
course_data = meter.progress(programs=[program_data], count_only=False)[0]
return program_data, course_data
def get_program_urls(program_data):
"""Returns important urls of program."""
from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, strip_course_id
program_uuid = program_data.get('uuid')
skus = program_data.get('skus')
ecommerce_service = EcommerceService()
# TODO: Don't have business logic of course-certificate==record-available here in LMS.
# Eventually, the UI should ask Credentials if there is a record available and get a URL from it.
# But this is here for now so that we can gate this URL behind both this business logic and
# a waffle flag. This feature is in active developoment.
program_record_url = get_credentials_records_url(program_uuid=program_uuid)
urls = {
'program_listing_url': reverse('program_listing_view'),
'track_selection_url': strip_course_id(
reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY})
),
'commerce_api_url': reverse('commerce_api:v0:baskets:create'),
'buy_button_url': ecommerce_service.get_checkout_page_url(*skus),
'program_record_url': program_record_url,
}
return urls
def get_industry_and_credit_pathways(program_data, site):
"""Returns pathways of a program."""
industry_pathways = []
credit_pathways = []
try:
for pathway_id in program_data['pathway_ids']:
pathway = get_pathways(site, pathway_id)
if pathway and pathway['email']:
if pathway['pathway_type'] == PathwayType.CREDIT.value:
credit_pathways.append(pathway)
elif pathway['pathway_type'] == PathwayType.INDUSTRY.value:
industry_pathways.append(pathway)
# if pathway caching did not complete fully (no pathway_ids)
except KeyError:
pass
return industry_pathways, credit_pathways
def get_program_marketing_url(programs_config, mobile_only=False):
"""Build a URL used to link to programs on the marketing site."""
if mobile_only: