From ec9ac34886bbea03865524947f94dbef2a6f0d30 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 27 Apr 2020 11:46:44 -0400 Subject: [PATCH] Paginate course card API for Programs Learner Portal * Pull business logic of ProgramCourseEnrollmentOverviewView out of view class and into utils.py. * Add UserProgramCourseEnrollmentsView, which is a paginated version of ProgramCourseEnrollmentOverviewView with a URL that is parameterized on the user (to enable masquerading in MST-109). * Add get_certificates_for_user_by_course_keys to certs API to make enrollments overviews REST API use fewer SQL queries. * Document new course cards API with edx-api-doc-tools. In a follow-up ticket, the Programs Learner Portal will switch to the new paginatd API in order to speed up its page load. MST-126 --- lms/djangoapps/certificates/api.py | 22 + lms/djangoapps/certificates/tests/test_api.py | 20 + .../rest_api/v1/serializers.py | 69 ++- .../rest_api/v1/tests/test_views.py | 437 +++++++++++++----- .../program_enrollments/rest_api/v1/urls.py | 10 + .../program_enrollments/rest_api/v1/utils.py | 208 ++++++++- .../program_enrollments/rest_api/v1/views.py | 257 +++++----- openedx/core/apidocs.py | 40 ++ 8 files changed, 793 insertions(+), 270 deletions(-) diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 65858c7aaa..d0176aa0fc 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -132,6 +132,28 @@ def get_certificate_for_user(username, course_key): return format_certificate_for_user(username, cert) +def get_certificates_for_user_by_course_keys(user, course_keys): + """ + Retrieve certificate information for a particular user for a set of courses. + + Arguments: + user (User) + course_keys (set[CourseKey]) + + Returns: dict[CourseKey: dict] + Mapping from course keys to dict of certificate data. + Course keys for courses for which the user does not have a certificate + will be omitted. + """ + certs = GeneratedCertificate.eligible_certificates.filter( + user=user, course_id__in=course_keys + ) + return { + cert.course_id: format_certificate_for_user(user.username, cert) + for cert in certs + } + + def get_recently_modified_certificates(course_keys=None, start_date=None, end_date=None): """ Returns a QuerySet of GeneratedCertificate objects filtered by the input diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index f09385a417..783677874c 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -372,6 +372,11 @@ class CertificateGetTests(SharedModuleStoreTestCase): display_name='Verified Course 2', cert_html_view_enabled=False ) + cls.no_cert_course = CourseFactory.create( + org='edx', + number='verified_3', + display_name='Verified Course 3', + ) # certificate for the first course GeneratedCertificateFactory.create( user=cls.student, @@ -442,6 +447,21 @@ class CertificateGetTests(SharedModuleStoreTestCase): self.assertEqual(certs[0]['download_url'], 'www.google.com') self.assertEqual(certs[1]['download_url'], 'www.gmail.com') + def test_get_certificates_for_user_by_course_keys(self): + """ + Test to get certificates for a user for certain course keys, + in a dictionary indexed by those course keys. + """ + certs = certs_api.get_certificates_for_user_by_course_keys( + user=self.student, + course_keys={self.web_cert_course.id, self.no_cert_course.id}, + ) + assert set(certs.keys()) == {self.web_cert_course.id} + cert = certs[self.web_cert_course.id] + self.assertEqual(cert['username'], self.student.username) + self.assertEqual(cert['course_key'], self.web_cert_course.id) + self.assertEqual(cert['download_url'], 'www.google.com') + def test_no_certificate_for_user(self): """ Test the case when there is no certificate for a user for a specific course. diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py b/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py index 8402945c59..9618021a69 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py @@ -149,17 +149,64 @@ class CourseRunOverviewSerializer(serializers.Serializer): CourseRunProgressStatuses.COMPLETED ] - course_run_id = serializers.CharField() - display_name = serializers.CharField() - resume_course_run_url = serializers.CharField(required=False) - course_run_url = serializers.CharField() - start_date = serializers.DateTimeField() - end_date = serializers.DateTimeField() - course_run_status = serializers.ChoiceField(allow_blank=False, choices=STATUS_CHOICES) - emails_enabled = serializers.BooleanField(required=False) - due_dates = serializers.ListField(child=DueDateSerializer()) - micromasters_title = serializers.CharField(required=False) - certificate_download_url = serializers.CharField(required=False) + course_run_id = serializers.CharField( + help_text="ID for the course run.", + ) + display_name = serializers.CharField( + help_text="Display name of the course run.", + ) + resume_course_run_url = serializers.CharField( + required=False, + help_text=( + "The absolute url that takes the user back to their position in the " + "course run; if absent, user has not made progress in the course." + ), + ) + course_run_url = serializers.CharField( + help_text="The absolute url for the course run.", + ) + start_date = serializers.DateTimeField( + help_text="Start date for the course run; null if no start date.", + ) + end_date = serializers.DateTimeField( + help_text="End date for the course run; null if no end date.", + ) + course_run_status = serializers.ChoiceField( + allow_blank=False, + choices=STATUS_CHOICES, + help_text="The user's status of the course run.", + ) + emails_enabled = serializers.BooleanField( + required=False, + help_text=( + "Boolean representing whether emails are enabled for the course;" + "if absent, the bulk email feature is either not enable at the platform" + "level or is not enabled for the course; if True or False, bulk email" + "feature is enabled, and value represents whether or not user wants" + "to receive emails." + ), + ) + due_dates = serializers.ListField( + child=DueDateSerializer(), + help_text=( + "List of subsection due dates for the course run. " + "Due dates are only returned if the course run is in progress." + ), + ) + micromasters_title = serializers.CharField( + required=False, + help_text=( + "Title of the MicroMasters program that the course run is a part of; " + "if absent, the course run is not a part of a MicroMasters program." + ), + ) + certificate_download_url = serializers.CharField( + required=False, + help_text=( + "URL to download a certificate, if available; " + "if absent, certificate is not downloadable." + ), + ) class CourseRunOverviewListSerializer(serializers.Serializer): diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py index 737edd55c1..7bba79bde5 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py @@ -2,7 +2,6 @@ Unit tests for ProgramEnrollment views. """ - import json from collections import OrderedDict, defaultdict from datetime import datetime, timedelta @@ -51,6 +50,7 @@ from openedx.core.djangoapps.catalog.tests.factories import ( from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangolib.testing.utils import CacheIsolationMixin +from student.models import CourseEnrollment from student.roles import CourseStaffRole from student.tests.factories import CourseEnrollmentFactory, UserFactory from third_party_auth.tests.factories import SAMLProviderConfigFactory @@ -58,6 +58,7 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCourseFactory from xmodule.modulestore.tests.factories import ItemFactory +from .. import views from ..constants import ( ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS, @@ -68,6 +69,7 @@ from ..constants import ( _DJANGOAPP_PATCH_FORMAT = 'lms.djangoapps.program_enrollments.{}' _REST_API_PATCH_FORMAT = _DJANGOAPP_PATCH_FORMAT.format('rest_api.v1.{}') _VIEW_PATCH_FORMAT = _REST_API_PATCH_FORMAT.format('views.{}') +_UTILS_PATCH_FORMAT = _REST_API_PATCH_FORMAT.format('utils.{}') _get_users_patch_path = _DJANGOAPP_PATCH_FORMAT.format('api.writing.get_users_by_external_keys') @@ -1594,7 +1596,7 @@ class ProgramCourseEnrollmentOverviewGetTests( Tests for the ProgramCourseEnrollmentOverview view GET method. """ patch_resume_url = mock.patch( - _VIEW_PATCH_FORMAT.format('get_resume_urls_for_enrollments'), + _UTILS_PATCH_FORMAT.format('get_resume_urls_for_enrollments'), autospec=True, ) @@ -1610,8 +1612,9 @@ class ProgramCourseEnrollmentOverviewGetTests( cls.course_run = CourseRunFactory.create(key=text_type(cls.course_id)) cls.course = CourseFactory.create(course_runs=[cls.course_run]) + cls.username = 'student' cls.password = 'password' - cls.student = UserFactory.create(username='student', password=cls.password) + cls.student = UserFactory.create(username=cls.username, password=cls.password) # only freeze time when defining these values and not on the whole test case # as test_multiple_enrollments_all_enrolled relies on actual differences in modified datetimes @@ -1681,29 +1684,59 @@ class ProgramCourseEnrollmentOverviewGetTests( verify_uuid=uuid4(), ) + def log_in(self, user=None): + """ + Log in `self.client` as `user` if provided or `self.student` otherwise. + """ + return self.client.login( + username=(user or self.student).username, + password=self.password, + ) + def get_url(self, program_uuid=None): - """ Returns the primary URL requested by the test case. """ + """ + Returns the primary URL requested by the test case. + + May be overriden by subclasses of this test case. + """ kwargs = {'program_uuid': program_uuid or self.program_uuid} return reverse('programs_api:v1:program_course_enrollments_overview', kwargs=kwargs) + def get_status_and_course_runs(self): + """ + GETs the endpoint at `self.get_url`. + + May be overriden by subclasses of this test case. + + Returns: (status, course_runs) + * status (int): HTTP status code. + * course_runs (list[dict]|None): List of dicts if 200 OK; else, None. + """ + url = self.get_url() + response = self.client.get(url) + return ( + response.status_code, + response.data['course_runs'] if response.status_code == 200 else None + ) + def test_401_if_anonymous(self): - response = self.client.get(self.get_url(self.program_uuid)) - assert status.HTTP_401_UNAUTHORIZED == response.status_code + response_status_code, _ = self.get_status_and_course_runs() + assert status.HTTP_401_UNAUTHORIZED == response_status_code def test_404_if_no_program_with_key(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() self.set_program_in_catalog_cache(self.program_uuid, None) - response = self.client.get(self.get_url(self.program_uuid)) - assert status.HTTP_404_NOT_FOUND == response.status_code + response_status_code, _ = self.get_status_and_course_runs() + assert status.HTTP_404_NOT_FOUND == response_status_code def test_403_if_not_enrolled_in_program(self): # delete program enrollment ProgramEnrollment.objects.all().delete() - self.client.login(username=self.student.username, password=self.password) - response = self.client.get(self.get_url(self.program_uuid)) - assert status.HTTP_403_FORBIDDEN == response.status_code + self.log_in() + response_status_code, _ = self.get_status_and_course_runs() + assert status.HTTP_403_FORBIDDEN == response_status_code def _add_new_course_to_program(self, course_run_key, program): """ @@ -1734,11 +1767,11 @@ class ProgramCourseEnrollmentOverviewGetTests( if not other_enrollment_active: other_enrollment.deactivate() - self.client.login(username=self.student.username, password=self.password) - response = self.client.get(self.get_url(self.program_uuid)) + self.log_in() + response_status_code, response_course_runs = self.get_status_and_course_runs() - self.assertEqual(status.HTTP_200_OK, response.status_code) - actual_course_run_ids = {run['course_run_id'] for run in response.data['course_runs']} + self.assertEqual(status.HTTP_200_OK, response_status_code) + actual_course_run_ids = {run['course_run_id'] for run in response_course_runs} expected_course_run_ids = {text_type(self.course_id)} if other_enrollment_active: expected_course_run_ids.add(text_type(other_course_key)) @@ -1746,63 +1779,66 @@ class ProgramCourseEnrollmentOverviewGetTests( @patch_resume_url def test_blank_resume_url_omitted(self, mock_get_resume_urls): - self.client.login(username=self.student.username, password=self.password) + self.log_in() mock_get_resume_urls.return_value = {self.course_id: ''} - response = self.client.get(self.get_url(self.program_uuid)) - self.assertNotIn('resume_course_run_url', response.data['course_runs'][0]) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertNotIn('resume_course_run_url', response_course_runs[0]) @patch_resume_url def test_relative_resume_url_becomes_absolute(self, mock_get_resume_urls): - self.client.login(username=self.student.username, password=self.password) + self.log_in() resume_url = '/resume-here' mock_get_resume_urls.return_value = {self.course_id: resume_url} - response = self.client.get(self.get_url(self.program_uuid)) - response_resume_url = response.data['course_runs'][0]['resume_course_run_url'] + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + response_resume_url = response_course_runs[0]['resume_course_run_url'] self.assertTrue(response_resume_url.startswith("http://testserver")) self.assertTrue(response_resume_url.endswith(resume_url)) @patch_resume_url def test_absolute_resume_url_stays_absolute(self, mock_get_resume_urls): - self.client.login(username=self.student.username, password=self.password) + self.log_in() resume_url = 'http://www.resume.com/' mock_get_resume_urls.return_value = {self.course_id: resume_url} - response = self.client.get(self.get_url(self.program_uuid)) - response_resume_url = response.data['course_runs'][0]['resume_course_run_url'] + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + response_resume_url = response_course_runs[0]['resume_course_run_url'] self.assertEqual(response_resume_url, resume_url) def test_no_url_without_certificate(self): - self.client.login(username=self.student.username, password=self.password) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertNotIn('certificate_download_url', response.data['course_runs'][0]) + self.log_in() + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertNotIn('certificate_download_url', response_course_runs[0]) def test_relative_certificate_url_becomes_absolute(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() self.create_generated_certificate( download_url=self.relative_certificate_download_url ) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - response_url = response.data['course_runs'][0]['certificate_download_url'] + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + response_url = response_course_runs[0]['certificate_download_url'] self.assertTrue(response_url.startswith("http://testserver")) self.assertTrue(response_url.endswith(self.relative_certificate_download_url)) def test_absolute_certificate_url_stays_absolute(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() self.create_generated_certificate( download_url=self.absolute_certificate_download_url ) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - response_url = response.data['course_runs'][0]['certificate_download_url'] + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + response_url = response_course_runs[0]['certificate_download_url'] self.assertEqual(response_url, self.absolute_certificate_download_url) def test_no_due_dates(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - assert [] == response.data['course_runs'][0]['due_dates'] + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + assert [] == response_course_runs[0]['due_dates'] @ddt.data( ('2018-12-01', False), @@ -1856,9 +1892,9 @@ class ProgramCourseEnrollmentOverviewGetTests( (unit_1.location, 'due'): unit_1.due, } - self.client.login(username=self.student.username, password=self.password) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) + self.log_in() + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) block_data = [ { @@ -1886,7 +1922,7 @@ class ProgramCourseEnrollmentOverviewGetTests( 'date': '2019-01-04T00:00:00Z', }, ] - due_dates = response.data['course_runs'][0]['due_dates'] + due_dates = response_course_runs[0]['due_dates'] if course_in_progress: for block in block_data: @@ -1896,7 +1932,7 @@ class ProgramCourseEnrollmentOverviewGetTests( @mock.patch.object(CourseOverview, 'has_ended') def test_course_run_status_instructor_paced_completed(self, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as instructor paced self.course_overview.self_paced = False @@ -1904,14 +1940,14 @@ class ProgramCourseEnrollmentOverviewGetTests( mock_has_ended.return_value = True - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.COMPLETED, response_course_runs[0]['course_run_status']) @mock.patch.object(CourseOverview, 'has_ended') @mock.patch.object(CourseOverview, 'has_started') def test_course_run_status_instructor_paced_in_progress(self, mock_has_started, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as instructor paced self.course_overview.self_paced = False @@ -1920,14 +1956,14 @@ class ProgramCourseEnrollmentOverviewGetTests( mock_has_started.return_value = True mock_has_ended.return_value = False - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response_course_runs[0]['course_run_status']) @mock.patch.object(CourseOverview, 'has_ended') @mock.patch.object(CourseOverview, 'has_started') def test_course_run_status_instructor_paced_upcoming(self, mock_has_started, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as instructor paced self.course_overview.self_paced = False @@ -1936,13 +1972,13 @@ class ProgramCourseEnrollmentOverviewGetTests( mock_has_started.return_value = False mock_has_ended.return_value = False - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.UPCOMING, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.UPCOMING, response_course_runs[0]['course_run_status']) @mock.patch.object(CourseOverview, 'has_ended') def test_course_run_status_self_paced_completed(self, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as self paced self.course_overview.self_paced = True @@ -1951,9 +1987,9 @@ class ProgramCourseEnrollmentOverviewGetTests( # course run has ended mock_has_ended.return_value = True - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.COMPLETED, response_course_runs[0]['course_run_status']) # course run has not ended and user has earned a passing certificate more than 30 days ago certificate = self.create_generated_certificate() @@ -1961,21 +1997,21 @@ class ProgramCourseEnrollmentOverviewGetTests( certificate.save() mock_has_ended.return_value = False - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.COMPLETED, response_course_runs[0]['course_run_status']) # course run has ended and user has earned a passing certificate mock_has_ended.return_value = True - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.COMPLETED, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.COMPLETED, response_course_runs[0]['course_run_status']) @mock.patch.object(CourseOverview, 'has_ended') @mock.patch.object(CourseOverview, 'has_started') def test_course_run_status_self_paced_in_progress(self, mock_has_started, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as self paced self.course_overview.self_paced = True @@ -1985,23 +2021,23 @@ class ProgramCourseEnrollmentOverviewGetTests( mock_has_started.return_value = True mock_has_ended.return_value = False - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response_course_runs[0]['course_run_status']) # course run has not ended and user has earned a passing certificate fewer than 30 days ago certificate = self.create_generated_certificate() certificate.created_date = timezone.now() - timedelta(5) certificate.save() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.IN_PROGRESS, response_course_runs[0]['course_run_status']) @mock.patch.object(CourseOverview, 'has_ended') @mock.patch.object(CourseOverview, 'has_started') def test_course_run_status_self_paced_upcoming(self, mock_has_started, mock_has_ended): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # set as self paced self.course_overview.self_paced = True @@ -2011,26 +2047,26 @@ class ProgramCourseEnrollmentOverviewGetTests( mock_has_started.return_value = False mock_has_ended.return_value = False - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(CourseRunProgressStatuses.UPCOMING, response.data['course_runs'][0]['course_run_status']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(CourseRunProgressStatuses.UPCOMING, response_course_runs[0]['course_run_status']) def test_course_run_url(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() course_run_url = 'http://testserver/courses/{}/course/'.format(text_type(self.course_id)) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(course_run_url, response.data['course_runs'][0]['course_run_url']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(course_run_url, response_course_runs[0]['course_run_url']) def test_course_run_dates(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) - course_run_overview = response.data['course_runs'][0] + course_run_overview = response_course_runs[0] self.assertEqual(course_run_overview['start_date'], '2018-12-31T00:00:00Z') self.assertEqual(course_run_overview['end_date'], '2019-01-02T00:00:00Z') @@ -2039,56 +2075,237 @@ class ProgramCourseEnrollmentOverviewGetTests( self.course_overview.end = None self.course_overview.save() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(response.data['course_runs'][0]['end_date'], None) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertEqual(response_course_runs[0]['end_date'], None) def test_course_run_id_and_display_name(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) - course_run_overview = response.data['course_runs'][0] + course_run_overview = response_course_runs[0] self.assertEqual(course_run_overview['course_run_id'], text_type(self.course_id)) self.assertEqual(course_run_overview['display_name'], "{} Course".format(text_type(self.course_id))) def test_emails_enabled(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() # by default, BulkEmailFlag is not enabled, so 'emails_enabled' won't be in the response - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertNotIn('emails_enabled', response.data['course_runs'][0]) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertNotIn('emails_enabled', response_course_runs[0]) with mock.patch.object(BulkEmailFlag, 'feature_enabled', return_value=True): - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertTrue(response.data['course_runs'][0]['emails_enabled']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertTrue(response_course_runs[0]['emails_enabled']) Optout.objects.create( user=self.student, course_id=self.course_id ) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertFalse(response.data['course_runs'][0]['emails_enabled']) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertFalse(response_course_runs[0]['emails_enabled']) def test_micromasters_title(self): - self.client.login(username=self.student.username, password=self.password) + self.log_in() - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertNotIn('micromasters_title', response.data['course_runs'][0]) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertNotIn('micromasters_title', response_course_runs[0]) self.program['type'] = 'MicroMasters' # update the program in the catalog cache self.set_program_in_catalog_cache(self.program_uuid, self.program) - response = self.client.get(self.get_url(self.program_uuid)) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertIn('micromasters_title', response.data['course_runs'][0]) + response_status_code, response_course_runs = self.get_status_and_course_runs() + self.assertEqual(status.HTTP_200_OK, response_status_code) + self.assertIn('micromasters_title', response_course_runs[0]) + + +@ddt.ddt +class UserProgramCourseEnrollmentViewGetTests(ProgramCourseEnrollmentOverviewGetTests): + """ + Tests for UserProgramCourseEnrollmentViewGetTests. + + For now, we just subclass ProgramCourseEnrollmentOverviewGetTests + because there are so many shared test cases. + + TODO: When the old, non-paginated ProgramCourseEnrollmentOverview endpoint + is removed, these two test cases should be collapsed into one test case. + """ + + # pylint: disable=test-inherits-tests + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.student_many_enrollments = UserFactory( + username='student_many_enrollments', + password=cls.password, + ) + cls.program_enrollment = ProgramEnrollmentFactory( + program_uuid=cls.program_uuid, + curriculum_uuid=cls.curriculum_uuid, + user=cls.student_many_enrollments, + ) + for _ in range(40): + CourseEnrollmentFactory(user=cls.student_many_enrollments) + + def get_url(self, program_uuid=None, username=None, page_size_string=None): + """ + Returns the primary URL requested by the test case. + + May be overriden by subclasses of this test case. + """ + # pylint: disable=arguments-differ + url = reverse( + 'programs_api:v1:user_program_course_enrollments', + kwargs={ + 'username': username or self.username, + 'program_uuid': program_uuid or self.program_uuid, + }, + ) + return ( + url + '?page_size=' + page_size_string + if page_size_string + else url + ) + + def get_status_and_course_runs(self): + """ + GETs the endpoint at `self.get_url`. + + Unlike the superclass's implementation of this, + this method takes into account the endpoint being paginated. + returning the concatentsaed results of repeated calls to the `next` URL. + + If any GET call returns non-200, immediately return that HTTP status code + along with the results collected so far. + + Returns: (status, course_runs) + * status (int): HTTP status code. + * course_runs (list[dict]|None): List of dicts if 200 OK; else, None. + """ + results = [] + next_url = self.get_url(self.program_uuid) + while next_url: + response = self.client.get(next_url) + if response.status_code != 200: + break + results += response.data['results'] + next_url = response.data.get('next') + return response.status_code, results + + def test_requester_must_match_username(self): + """ + Test that the username in the URL must match the username of the requester. + + (The plan is that we will eventually allow masquerading, which will change + require changing this test to be more permissive). + """ + self.log_in() + url = self.get_url(username='other_student') + response = self.client.get(url) + assert response.status_code == 403 + + def test_no_enrollments(self): + """ + Test that a user with no enrollments will get a 200 from this endpoint + with an empty list of results. + """ + self.log_in() + no_enrollments = CourseEnrollment.objects.none() + with mock.patch.object( + views, + 'get_enrollments_for_courses_in_program', + lambda _user, _program: no_enrollments, + ): + response_status, response_course_runs = self.get_status_and_course_runs() + assert response_status == 200 + assert response_course_runs == [] + + @ddt.data( + # If not provided, the page size is defaults to 10. + (None, [10, 10, 10, 10]), + # We can set the page size below the default. + ('5', [5, 5, 5, 5, 5, 5, 5, 5]), + # We can set the page size above the default. + ('19', [19, 19, 2]), + # Invalid parameter values fall back to page size of 10. + ('covid-19', [10, 10, 10, 10]), + # The max page size is 25. Numbers above this will be interpreted as 25. + ('30', [25, 15]), + ) + @ddt.unpack + def test_pagination(self, page_size_string, expected_page_sizes): + """ + Test the interactions between the `page_size` parameter + and the sizes of the each request. + """ + + def mock_get_enrollment_overviews(user, program, enrollments, request): + """ + Mock implementation of `utils.get_enrollments_overviews` + that returns a dict with the correct `course_run_id` + but fake values for all the rest. + + This function should never get an enrollment queryset greater than the + max page size. + """ + assert len(enrollments) <= 25 + return [ + { + 'course_run_id': enrollment.course.id, + 'display_name': 'Fake Display Name for {enrollment.course.id}'.format( + enrollment=enrollment, + ), + 'course_run_url': 'http://fake.url.example.com/course-run', + 'start_date': '2112-02-20', + 'end_date': '2112-12-21', + 'course_run_status': '', + 'due_dates': [], + } + for enrollment in enrollments + ] + + self.log_in(user=self.student_many_enrollments) + many_enrollments = CourseEnrollment.objects.filter(user=self.student_many_enrollments) + + with mock.patch.object( + views, + 'get_enrollments_for_courses_in_program', + lambda _user, _program: many_enrollments, + ): + with mock.patch.object( + views, + 'get_enrollment_overviews', + mock_get_enrollment_overviews, + ): + actual_page_sizes = [] + all_results = [] + next_url = self.get_url( + program_uuid=self.program_uuid, + username=self.student_many_enrollments.username, + page_size_string=page_size_string, + ) + while next_url: + response = self.client.get(next_url) + assert response.status_code == 200 + actual_page_sizes.append(len(response.data['results'])) + all_results += response.data['results'] + next_url = response.data.get('next') + + assert actual_page_sizes == expected_page_sizes + all_course_run_ids = {result['course_run_id'] for result in all_results} + assert len(all_course_run_ids) == 40, ( + "Expected 40 unique course run IDs to be processed " + "across all pages." + ) class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase): diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/urls.py b/lms/djangoapps/program_enrollments/rest_api/v1/urls.py index 9899ecbaa9..145a614bbc 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/urls.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/urls.py @@ -1,6 +1,7 @@ """ Program Enrollments API v1 URLs. """ +from django.conf import settings from django.conf.urls import url from openedx.core.constants import COURSE_ID_PATTERN @@ -12,6 +13,7 @@ from .views import ( ProgramCourseEnrollmentsView, ProgramCourseGradesView, ProgramEnrollmentsView, + UserProgramCourseEnrollmentView, UserProgramReadOnlyAccessView ) @@ -56,6 +58,14 @@ urlpatterns = [ ProgramCourseEnrollmentOverviewView.as_view(), name="program_course_enrollments_overview" ), + url( + r'^users/{username}/programs/{program_uuid}/courses'.format( + username=settings.USERNAME_PATTERN, + program_uuid=PROGRAM_UUID_PATTERN, + ), + UserProgramCourseEnrollmentView.as_view(), + name="user_program_course_enrollments" + ), url( r'^integration-reset', EnrollmentDataResetView.as_view(), diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py index a7331a50b3..5aacc94a28 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py @@ -2,28 +2,58 @@ """ ProgramEnrollment V1 API internal utilities. """ - - from datetime import datetime, timedelta from functools import wraps +from django.core.exceptions import PermissionDenied from django.utils.functional import cached_property from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status +from rest_framework.pagination import CursorPagination -from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination -from openedx.core.djangoapps.catalog.utils import get_programs, is_course_run_in_program +from course_modes.models import CourseMode +from lms.djangoapps.bulk_email.api import get_emails_enabled +from lms.djangoapps.certificates.api import get_certificates_for_user_by_course_keys +from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates +from lms.djangoapps.program_enrollments.api import fetch_program_enrollments +from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses +from openedx.core.djangoapps.catalog.utils import course_run_keys_for_program, get_programs, is_course_run_in_program from openedx.core.lib.api.view_utils import verify_course_exists +from student.helpers import get_resume_urls_for_enrollments +from student.models import CourseEnrollment from .constants import CourseRunProgressStatuses -class ProgramEnrollmentPagination(CourseEnrollmentPagination): +class ProgramEnrollmentPagination(CursorPagination): """ Pagination class for views in the Program Enrollments app. """ + ordering = 'id' page_size = 100 + page_size_query_param = 'page_size' + + def get_paginated_response(self, data, status_code=200, **kwargs): # pylint: disable=arguments-differ + """ + Return a response given serialized page data, optional status_code (defaults to 200), + and kwargs. Each key-value pair of kwargs is added to the response data. + """ + resp = super().get_paginated_response(data) + for (key, value) in kwargs.items(): + resp.data[key] = value + resp.status_code = status_code + return resp + + +class UserProgramCourseEnrollmentPagination(CursorPagination): + """ + Pagination parameters for UserProgramCourseEnrollmentView. + """ + page_size = 10 + max_page_size = 25 + page_size_query_param = 'page_size' + ordering = 'id' class ProgramSpecificViewMixin(object): @@ -37,6 +67,8 @@ class ProgramSpecificViewMixin(object): def program(self): """ The program specified by the `program_uuid` URL parameter. + + Returns: dict """ return get_programs(uuid=self.program_uuid) @@ -44,10 +76,35 @@ class ProgramSpecificViewMixin(object): def program_uuid(self): """ The program specified by the `program_uuid` URL parameter. + + Returns: str """ return self.kwargs['program_uuid'] +class UserProgramSpecificViewMixin(ProgramSpecificViewMixin): + """ + A mixin for views that operate on a specific program in the context of a user. + + Requires `program_uuid` to be one of the kwargs to the view. + + The property `target_user` returns the user that that we should operate with. + """ + @property + def target_user(self): + """ + The user that this view's operations should operate in the context of. + + By default, this is the requesting user. + + This can be overriden in order to implement "user-parameterized" views, + which, for example, a global staff member could use to see API responses + in the context of a specific learner. This could be used to help implement + masquerading. + """ + return self.request.user + + class ProgramCourseSpecificViewMixin(ProgramSpecificViewMixin): """ A mixin for views that operate on or within a specific course run in a program @@ -72,7 +129,7 @@ def verify_program_exists(view_func): Expects to be used within a ProgramSpecificViewMixin subclass. """ @wraps(view_func) - def wrapped_function(self, request, **kwargs): + def wrapped_function(self, *args, **kwargs): """ Wraps the given view_function. """ @@ -82,7 +139,7 @@ def verify_program_exists(view_func): developer_message='no program exists with given key', error_code='program_does_not_exist' ) - return view_func(self, request, **kwargs) + return view_func(self, *args, **kwargs) return wrapped_function @@ -101,7 +158,7 @@ def verify_course_exists_and_in_program(view_func): @wraps(view_func) @verify_program_exists @verify_course_exists - def wrapped_function(self, request, **kwargs): + def wrapped_function(self, *args, **kwargs): """ Wraps view function """ @@ -111,10 +168,143 @@ def verify_course_exists_and_in_program(view_func): developer_message="the program's curriculum does not contain the given course", error_code='course_not_in_program' ) - return view_func(self, request, **kwargs) + return view_func(self, *args, **kwargs) return wrapped_function +def verify_user_enrolled_in_program(view_func): + """ + Raised PermissionDenied if the `target_user` is not enrolled in the program. + + Expects to be used within a UserProgramViewMixin subclass. + """ + @wraps(view_func) + def wrapped_function(self, *args, **kwargs): + """ + Wraps the given view_function. + """ + user_enrollment_qs = fetch_program_enrollments( + program_uuid=self.program_uuid, + users={self.target_user}, + program_enrollment_statuses={ProgramEnrollmentStatuses.ENROLLED}, + ) + if not user_enrollment_qs.exists(): + raise PermissionDenied + return view_func(self, *args, **kwargs) + return wrapped_function + + +def get_enrollments_for_courses_in_program(user, program): + """ + Get a user's active enrollments for course runs with the given program. + + Note that this is distinct from the user's *program course enrollments*, + which refers to courses that were enrollmed in *through* a program. + + In the case of this function, the course runs themselves must be part of the + program, but the enrollments do not need to be associated with a program enrollment. + + Arguments: + user (User) + program (dict) + + Returns QuerySet[CourseEnrollment] + """ + course_keys = [ + CourseKey.from_string(key) + for key in course_run_keys_for_program(program) + ] + return CourseEnrollment.objects.filter( + user=user, + course_id__in=course_keys, + mode__in=[CourseMode.VERIFIED, CourseMode.MASTERS], + is_active=True, + ) + + +def get_enrollment_overviews(user, program, enrollments, request): + """ + Get a list of overviews for a user's course run enrollments within a program. + + Arguments: + user (User) + program (dict) + enrollments (iterable[CourseEnrollment]) + request (HttpRequest): Source HTTP request. Needed for URL generation. + + Returns list[dict] + """ + overviews_by_course_key = { + enrollment.course.id: enrollment.course for enrollment in enrollments + } + course_keys = list(overviews_by_course_key.keys()) + certficates_by_course_key = get_certificates_for_user_by_course_keys(user, course_keys) + resume_urls_by_course_key = get_resume_urls_for_enrollments(user, enrollments) + return [ + get_single_enrollment_overview( + user=user, + program=program, + course_overview=overviews_by_course_key[enrollment.course_id], + certificate_info=certficates_by_course_key.get(enrollment.course_id, {}), + relative_resume_url=resume_urls_by_course_key.get(enrollment.course_id), + request=request, + ) + for enrollment in enrollments + ] + + +def get_single_enrollment_overview( + user, + program, + course_overview, + certificate_info, + relative_resume_url, + request, +): + """ + Get an overview of a user's enrollment in a course run within a program. + + Arguments: + user (User) + program (Program) + course_overview (CourseOverview) + certificate_info (dict): Info about a user's certificate in this course run. + relative_resume_url (str): URL to resume course. Relative to LMS root. + request (HttpRequest): Source HTTP request. Needed for URL generation. + + Returns: dict + """ + course_key = course_overview.id + course_run_status = get_course_run_status(course_overview, certificate_info) + due_dates = ( + get_due_dates(request, course_key, user) + if course_run_status == CourseRunProgressStatuses.IN_PROGRESS + else [] + ) + result = { + 'course_run_id': str(course_key), + 'display_name': course_overview.display_name_with_default, + 'course_run_status': course_run_status, + 'course_run_url': get_course_run_url(request, course_key), + 'start_date': course_overview.start, + 'end_date': course_overview.end, + 'due_dates': due_dates, + } + emails_enabled = get_emails_enabled(user, course_key) + if emails_enabled is not None: + result['emails_enabled'] = emails_enabled + download_url = certificate_info.get('download_url') + if download_url: + result['certificate_download_url'] = request.build_absolute_uri( + certificate_info['download_url'] + ) + if program['type'] == 'MicroMasters': + result['micromasters_title'] = program['title'] + if relative_resume_url: + result['resume_course_run_url'] = request.build_absolute_uri(relative_resume_url) + return result + + def get_enrollment_http_code(result_statuses, ok_statuses): """ Given a set of enrollment create/update statuses, diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/views.py b/lms/djangoapps/program_enrollments/rest_api/v1/views.py index d2cf11543d..6036563ac2 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/views.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/views.py @@ -2,27 +2,22 @@ """ ProgramEnrollment Views """ - - from ccx_keys.locator import CCXLocator from django.conf import settings -from django.core.exceptions import PermissionDenied from django.core.management import call_command from django.db import transaction +from edx_api_doc_tools import path_parameter, query_parameter, schema from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from opaque_keys.edx.keys import CourseKey from organizations.models import Organization from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from course_modes.models import CourseMode -from lms.djangoapps.bulk_email.api import get_emails_enabled -from lms.djangoapps.certificates.api import get_certificate_for_user -from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates from lms.djangoapps.program_enrollments.api import ( fetch_program_course_enrollments, fetch_program_enrollments, @@ -39,24 +34,22 @@ from lms.djangoapps.program_enrollments.constants import ( ProgramOperationStatuses ) from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException +from openedx.core.apidocs import cursor_paginate_serializer from openedx.core.djangoapps.catalog.utils import ( - course_run_keys_for_program, get_programs, get_programs_by_type, get_programs_for_organization, normalize_program_type ) -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView -from student.helpers import get_resume_urls_for_enrollments -from student.models import CourseEnrollment from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole from util.query import read_replica_or_default -from .constants import CourseRunProgressStatuses, ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS +from .constants import ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS from .serializers import ( CourseRunOverviewListSerializer, + CourseRunOverviewSerializer, ProgramCourseEnrollmentRequestSerializer, ProgramCourseEnrollmentSerializer, ProgramCourseGradeSerializer, @@ -68,10 +61,14 @@ from .utils import ( ProgramCourseSpecificViewMixin, ProgramEnrollmentPagination, ProgramSpecificViewMixin, - get_course_run_status, + UserProgramCourseEnrollmentPagination, + UserProgramSpecificViewMixin, get_enrollment_http_code, + get_enrollment_overviews, + get_enrollments_for_courses_in_program, verify_course_exists_and_in_program, - verify_program_exists + verify_program_exists, + verify_user_enrolled_in_program ) @@ -771,64 +768,76 @@ class UserProgramReadOnlyAccessView(DeveloperErrorViewMixin, PaginatedAPIView): return program_dict.values() -class ProgramCourseEnrollmentOverviewView( +class UserProgramCourseEnrollmentView( DeveloperErrorViewMixin, - ProgramSpecificViewMixin, - APIView, + UserProgramSpecificViewMixin, + PaginatedAPIView, ): """ A view for getting data associated with a user's course enrollments as part of a program enrollment. - Path: ``/api/program_enrollments/v1/programs/{program_uuid}/overview/`` + For full documentation, see the `program_enrollments` section of + http://$LMS_BASE_URL/api-docs/. + """ + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated,) + serializer_class = CourseRunOverviewSerializer + pagination_class = UserProgramCourseEnrollmentPagination - Accepts: [GET] + @schema( + parameters=[ + path_parameter('username', str, description=( + 'The username of the user for which enrollment overviews will be fetched. ' + 'For now, this must be the requesting user; otherwise, 403 will be returned. ' + 'In the future, global staff users may be able to supply other usernames.' + )), + path_parameter('program_uuid', str, description=( + 'UUID of a program. ' + 'Enrollments will be returned for course runs in this program.' + )), + query_parameter('page_size', int, description=( + 'Number of results to return per page. ' + 'Defaults to 10. Maximum is 25.' + )), + ], + responses={ + 200: cursor_paginate_serializer(CourseRunOverviewSerializer), + 401: 'The requester is not authenticated.', + 403: ( + 'The requester cannot access the specified program and/or ' + 'the requester may not retrieve this data for the specified user.' + ), + 404: 'The requested program does not exist.' + }, + ) + @verify_program_exists + @verify_user_enrolled_in_program + def get(self, request, username, program_uuid): + """ + Get an overview of each of a user's course enrollments associated with a program. - ------------------------------------------------------------------------------------ - GET - ------------------------------------------------------------------------------------ + This endpoint exists to get an overview of each course-run enrollment + that a user has for course-runs within a given program. + Fields included are the title, upcoming due dates, etc. + This API endpoint is intended for use with the + [Program Learner Portal MFE](https://github.com/edx/frontend-app-learner-portal-programs). - **Returns** - - * 200: OK - Contains an object of user program course enrollment data. - * 401: Unauthorized - The requesting user is not authenticated. - * 403: Forbidden -The requesting user lacks access for the given program. - * 404: Not Found - The requested program does not exist. - - **Response** - - In the case of a 200 response code, the response will include a - data set. The `course_runs` section of the response consists of a list of - program course enrollment overview, where each overview contains the following keys: - * course_run_id: the id for the course run - * display_name: display name of the course run - * resume_course_run_url: the absolute url that takes the user back to - their position in the course run; - if absent, user has not made progress in the course - * course_run_url: the absolute url for the course run - * start_date: the start date for the course run; null if no start date - * end_date: the end date for the course run' null if no end date - * course_run_status: the status of the course; one of "in_progress", - "upcoming", and "completed" - * emails_enabled: boolean representing whether emails are enabled for the course; - if absent, the bulk email feature is either not enable at the platform - level or is not enabled for the course; if True or False, bulk email - feature is enabled, and value represents whether or not user wants - to receive emails - * due_dates: a list of subsection due dates for the - course run. Due dates are only returned if the course run is in progress. - ** name: name of the subsection - ** url: deep link to the subsection - ** date: due date for the subsection - * micromasters_title: title of the MicroMasters program that the course run is a part of; - if absent, the course run is not a part of a MicroMasters program - * certificate_download_url: url to download a certificate, if available; - if absent, certificate is not downloadable - - **Example** + It is important to note that the set of enrollments that this endpoint returns + is different than a user's set of *program-course-run enrollments*. + Specifically, this endpoint may include course runs that are *within* + the specified program but were not *enrolled in* via the specified program. + **Example Response:** + ```json { - "course_runs": [ + "next": null, + "previous": null, + "results": [ { "course_run_id": "edX+AnimalsX+Aardvarks", "display_name": "Astonishing Aardvarks", @@ -870,6 +879,41 @@ class ProgramCourseEnrollmentOverviewView( } ] } + ``` + """ + if request.user.username != username: + # TODO: Should this be case-insensitive? + raise PermissionDenied() + enrollments = get_enrollments_for_courses_in_program( + self.request.user, self.program + ) + paginated_enrollments = self.paginate_queryset(enrollments) + paginated_enrollment_overviews = get_enrollment_overviews( + user=self.request.user, + program=self.program, + enrollments=paginated_enrollments, + request=self.request, + ) + serializer = CourseRunOverviewSerializer(paginated_enrollment_overviews, many=True) + return self.get_paginated_response(serializer.data) + + +class ProgramCourseEnrollmentOverviewView( + DeveloperErrorViewMixin, + UserProgramSpecificViewMixin, + RetrieveAPIView, +): + """ + A view for getting data associated with a user's course enrollments + as part of a program enrollment. + + Path: ``/api/program_enrollments/v1/programs/{program_uuid}/overview/`` + + DEPRECATED: + This is deprecated in favor of the new UserProgramCourseEnrollmentView, + which is paginated. + It will be removed in a follow-up to MST-126 after the Programs Learner Portal + has been updated to use UserProgramCourseEnrollmentView. """ authentication_classes = ( JwtAuthentication, @@ -877,92 +921,25 @@ class ProgramCourseEnrollmentOverviewView( SessionAuthenticationAllowInactiveUser, ) permission_classes = (IsAuthenticated,) + serializer_class = CourseRunOverviewListSerializer @verify_program_exists - def get(self, request, program_uuid=None): + @verify_user_enrolled_in_program + def get_object(self): """ Defines the GET endpoint for overviews of course enrollments for a user as part of a program. """ - user = request.user - self._check_program_enrollment_exists(user, program_uuid) - - course_run_keys = [ - CourseKey.from_string(key) - for key in course_run_keys_for_program(self.program) - ] - - course_enrollments = CourseEnrollment.objects.filter( - user=user, - course_id__in=course_run_keys, - mode__in=[CourseMode.VERIFIED, CourseMode.MASTERS], - is_active=True, + enrollments = get_enrollments_for_courses_in_program( + self.request.user, self.program ) - - overviews = CourseOverview.get_from_ids(course_run_keys) - - course_run_resume_urls = get_resume_urls_for_enrollments(user, course_enrollments) - - course_runs = [] - - for enrollment in course_enrollments: - overview = overviews[enrollment.course_id] - - certificate_info = get_certificate_for_user(user.username, enrollment.course_id) or {} - - course_run_status = get_course_run_status(overview, certificate_info) - if course_run_status == CourseRunProgressStatuses.IN_PROGRESS: - due_dates = get_due_dates(request, enrollment.course_id, user) - else: - due_dates = [] - - course_run_dict = { - 'course_run_id': enrollment.course_id, - 'display_name': overview.display_name_with_default, - 'course_run_status': course_run_status, - 'course_run_url': get_course_run_url(request, enrollment.course_id), - 'start_date': overview.start, - 'end_date': overview.end, - 'due_dates': due_dates, - } - - emails_enabled = get_emails_enabled(user, enrollment.course_id) - if emails_enabled is not None: - course_run_dict['emails_enabled'] = emails_enabled - - if certificate_info.get('download_url'): - course_run_dict['certificate_download_url'] = request.build_absolute_uri( - certificate_info['download_url'] - ) - - if self.program['type'] == 'MicroMasters': - course_run_dict['micromasters_title'] = self.program['title'] - - if course_run_resume_urls.get(enrollment.course_id): - relative_resume_course_run_url = course_run_resume_urls.get( - enrollment.course_id - ) - course_run_dict['resume_course_run_url'] = request.build_absolute_uri( - relative_resume_course_run_url - ) - - course_runs.append(course_run_dict) - - serializer = CourseRunOverviewListSerializer({'course_runs': course_runs}) - return Response(serializer.data) - - @staticmethod - def _check_program_enrollment_exists(user, program_uuid): - """ - Raises ``PermissionDenied`` if the user is not enrolled in the program with the given UUID. - """ - user_enrollment_qs = fetch_program_enrollments( - program_uuid=program_uuid, - users={user}, - program_enrollment_statuses={ProgramEnrollmentStatuses.ENROLLED}, + enrollment_overviews = get_enrollment_overviews( + user=self.request.user, + program=self.program, + enrollments=enrollments, + request=self.request, ) - if not user_enrollment_qs.exists(): - raise PermissionDenied + return {'course_runs': enrollment_overviews} class EnrollmentDataResetView(APIView): diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 9f81cfa591..3651a2162c 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -3,6 +3,7 @@ Open API support. """ from edx_api_doc_tools import make_api_info +from rest_framework import serializers api_info = make_api_info( title="Open edX API", @@ -12,3 +13,42 @@ api_info = make_api_info( email="oscm@edx.org", #license=openapi.License(name="BSD License"), # TODO: What does this mean? ) + + +def cursor_paginate_serializer(inner_serializer_class): + """ + Create a cursor-paginated version of a serializer. + + This is hacky workaround for an edx-api-doc-tools issue described here: + https://github.com/edx/api-doc-tools/issues/32 + + It assumes we are using cursor-style pagination and assumes a specific + schema for the pages. It should be removed once we address the underlying issue. + + Arguments: + inner_serializer_class (type): A subclass of ``Serializer``. + + Returns: type + A subclass of ``Serializer`` to model the schema of a page of a cursor-paginated + endpoint. + """ + class PageOfInnerSerializer(serializers.Serializer): + """ + A serializer for a page of a cursor-paginated list of ``inner_serializer_class``. + """ + # pylint: disable=abstract-method + previous = serializers.URLField( + required=False, + help_text="Link to the previous page or results, or null if this is the first.", + ) + next = serializers.URLField( + required=False, + help_text="Link to the next page of results, or null if this is the last.", + ) + results = serializers.ListField( + child=inner_serializer_class(), + help_text="The list of result objects on this page.", + ) + + PageOfInnerSerializer.__name__ = 'PageOf{}'.format(inner_serializer_class.__name__) + return PageOfInnerSerializer