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:
committed by
Kyle McCormick
parent
2983fb0af6
commit
ec9ac34886
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user