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
This commit is contained in:
Kyle McCormick
2020-04-27 11:46:44 -04:00
committed by Kyle McCormick
parent 2983fb0af6
commit ec9ac34886
8 changed files with 793 additions and 270 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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