From 0d9e845f1bb4737c6f17586b7fe36ee2e2a61a61 Mon Sep 17 00:00:00 2001 From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com> Date: Fri, 4 Mar 2022 16:11:28 +0500 Subject: [PATCH] feat: Created an API to fetch progress details about a learner's program (#29975) --- .../learner_dashboard/api/__init__.py | 0 lms/djangoapps/learner_dashboard/api/urls.py | 10 + .../learner_dashboard/api/v0/__init__.py | 0 .../api/v0/tests/__init__.py | 0 .../api/v0/tests/test_views.py | 118 ++++++++++++ .../learner_dashboard/api/v0/urls.py | 13 ++ .../learner_dashboard/api/v0/views.py | 180 ++++++++++++++++++ lms/djangoapps/learner_dashboard/programs.py | 63 ++---- .../learner_dashboard/tests/test_programs.py | 2 +- lms/urls.py | 3 + openedx/core/djangoapps/programs/utils.py | 58 +++++- 11 files changed, 397 insertions(+), 50 deletions(-) create mode 100644 lms/djangoapps/learner_dashboard/api/__init__.py create mode 100644 lms/djangoapps/learner_dashboard/api/urls.py create mode 100644 lms/djangoapps/learner_dashboard/api/v0/__init__.py create mode 100644 lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py create mode 100644 lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py create mode 100644 lms/djangoapps/learner_dashboard/api/v0/urls.py create mode 100644 lms/djangoapps/learner_dashboard/api/v0/views.py diff --git a/lms/djangoapps/learner_dashboard/api/__init__.py b/lms/djangoapps/learner_dashboard/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/api/urls.py b/lms/djangoapps/learner_dashboard/api/urls.py new file mode 100644 index 0000000000..07b808a81e --- /dev/null +++ b/lms/djangoapps/learner_dashboard/api/urls.py @@ -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')), +] diff --git a/lms/djangoapps/learner_dashboard/api/v0/__init__.py b/lms/djangoapps/learner_dashboard/api/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py b/lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py new file mode 100644 index 0000000000..f94ba85101 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -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.' diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py new file mode 100644 index 0000000000..eaa71b9029 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/api/v0/urls.py @@ -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[0-9a-f-]+)/progress_details/$', ProgramProgressDetailView.as_view(), + name='program_progress_detail'), +] diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py new file mode 100644 index 0000000000..0a52156f06 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -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, + } + ) diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index a896cd5bf8..c91a1fde97 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -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) diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index 743ca633c0..376276475d 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -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.""" diff --git a/lms/urls.py b/lms/urls.py index 53e5ee2df3..0f249a379b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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( diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index b8ba89c6e7..ed19f0aa32 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -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: