Create Python API for program_enrollments: Part I (#21511)

This is the first in a series of commits to create
a Python API for the LMS program_enrollments app.
We do some general refactoring, renaming, and clean-up
in order to move toward the creation of that API.

EDUCATOR-4321
This commit is contained in:
Kyle McCormick
2019-09-04 14:06:00 -04:00
committed by GitHub
parent b229e9749b
commit ea5652010c
24 changed files with 737 additions and 714 deletions

View File

@@ -1,133 +0,0 @@
# -*- coding: utf-8 -*-
"""
ProgramEnrollment internal api
"""
from __future__ import absolute_import, unicode_literals
from datetime import datetime, timedelta
from pytz import UTC
from django.urls import reverse
from six import iteritems
from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
from edx_when.api import get_dates_for_course
from xmodule.modulestore.django import modulestore
from lms.djangoapps.program_enrollments.api.v1.constants import (
CourseRunProgressStatuses,
)
def get_due_dates(request, course_key, user):
"""
Get due date information for a user for blocks in a course.
Arguments:
request: the request object
course_key (CourseKey): the CourseKey for the course
user: the user object for which we want due date information
Returns:
due_dates (list): a list of dictionaries containing due date information
keys:
name: the display name of the block
url: the deep link to the block
date: the due date for the block
"""
dates = get_dates_for_course(
course_key,
user,
)
store = modulestore()
due_dates = []
for (block_key, date_type), date in iteritems(dates):
if date_type == 'due':
block = store.get_item(block_key)
# get url to the block in the course
block_url = reverse('jump_to', args=[course_key, block_key])
block_url = request.build_absolute_uri(block_url)
due_dates.append({
'name': block.display_name,
'url': block_url,
'date': date,
})
return due_dates
def get_course_run_url(request, course_id):
"""
Get the URL to a course run.
Arguments:
request: the request object
course_id (string): the course id of the course
Returns:
(string): the URL to the course run associated with course_id
"""
course_run_url = reverse('openedx.course_experience.course_home', args=[course_id])
return request.build_absolute_uri(course_run_url)
def get_emails_enabled(user, course_id):
"""
Get whether or not emails are enabled in the context of a course.
Arguments:
user: the user object for which we want to check whether emails are enabled
course_id (string): the course id of the course
Returns:
(bool): True if emails are enabled for the course associated with course_id for the user;
False otherwise
"""
if is_bulk_email_feature_enabled(course_id=course_id):
return not is_user_opted_out_for_course(user=user, course_id=course_id)
return None
def get_course_run_status(course_overview, certificate_info):
"""
Get the progress status of a course run, given the state of a user's certificate in the course.
In the case of self-paced course runs, the run is considered completed when either the course run has ended
OR the user has earned a passing certificate 30 days ago or longer.
Arguments:
course_overview (CourseOverview): the overview for the course run
certificate_info: A dict containing the following keys:
``is_passing``: whether the user has a passing certificate in the course run
``created``: the date the certificate was created
Returns:
status: one of (
CourseRunProgressStatuses.COMPLETE,
CourseRunProgressStatuses.IN_PROGRESS,
CourseRunProgressStatuses.UPCOMING,
)
"""
is_certificate_passing = certificate_info.get('is_passing', False)
certificate_creation_date = certificate_info.get('created', datetime.max)
if course_overview.pacing == 'instructor':
if course_overview.has_ended():
return CourseRunProgressStatuses.COMPLETED
elif course_overview.has_started():
return CourseRunProgressStatuses.IN_PROGRESS
else:
return CourseRunProgressStatuses.UPCOMING
elif course_overview.pacing == 'self':
thirty_days_ago = datetime.now(UTC) - timedelta(30)
certificate_completed = is_certificate_passing and (certificate_creation_date <= thirty_days_ago)
if course_overview.has_ended() or certificate_completed:
return CourseRunProgressStatuses.COMPLETED
elif course_overview.has_started():
return CourseRunProgressStatuses.IN_PROGRESS
else:
return CourseRunProgressStatuses.UPCOMING
return None

View File

@@ -1,13 +0,0 @@
"""
Program Enrollment API URLs.
"""
from __future__ import absolute_import
from django.conf.urls import include, url
app_name = 'lms.djangoapps.program_enrollments'
urlpatterns = [
url(r'^v1/', include('program_enrollments.api.v1.urls', namespace='v1'))
]

View File

@@ -1,65 +0,0 @@
"""
Constants and strings for the course-enrollment app
"""
# Captures strings composed of alphanumeric characters a-f and dashes.
PROGRAM_UUID_PATTERN = r'(?P<program_uuid>[A-Fa-f0-9-]+)'
MAX_ENROLLMENT_RECORDS = 25
# The name of the key that identifies students for POST/PATCH requests
REQUEST_STUDENT_KEY = 'student_key'
ENABLE_ENROLLMENT_RESET_FLAG = 'ENABLE_ENROLLMENT_RESET'
class BaseEnrollmentResponseStatuses(object):
"""
Class to group common response statuses
"""
DUPLICATED = 'duplicated'
INVALID_STATUS = "invalid-status"
CONFLICT = "conflict"
ILLEGAL_OPERATION = "illegal-operation"
NOT_IN_PROGRAM = "not-in-program"
INTERNAL_ERROR = "internal-error"
ERROR_STATUSES = {
DUPLICATED,
INVALID_STATUS,
CONFLICT,
ILLEGAL_OPERATION,
NOT_IN_PROGRAM,
INTERNAL_ERROR,
}
class CourseEnrollmentResponseStatuses(BaseEnrollmentResponseStatuses):
"""
Class to group response statuses returned by the course enrollment endpoint
"""
ACTIVE = "active"
INACTIVE = "inactive"
NOT_FOUND = "not-found"
ERROR_STATUSES = BaseEnrollmentResponseStatuses.ERROR_STATUSES | {NOT_FOUND}
class ProgramEnrollmentResponseStatuses(BaseEnrollmentResponseStatuses):
"""
Class to group response statuses returned by the program enrollment endpoint
"""
ENROLLED = 'enrolled'
PENDING = 'pending'
SUSPENDED = 'suspended'
CANCELED = 'canceled'
VALID_STATUSES = [ENROLLED, PENDING, SUSPENDED, CANCELED]
class CourseRunProgressStatuses(object):
"""
Class to group statuses that a course run can be in with respect to user progress.
"""
IN_PROGRESS = 'in_progress'
UPCOMING = 'upcoming'
COMPLETED = 'completed'

View File

@@ -1,227 +0,0 @@
"""
API Serializers
"""
from __future__ import absolute_import
from rest_framework import serializers
from six import text_type
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.api.v1.constants import (
CourseRunProgressStatuses,
ProgramEnrollmentResponseStatuses
)
class InvalidStatusMixin(object):
"""
Mixin to provide has_invalid_status method
"""
def has_invalid_status(self):
"""
Returns whether or not this serializer has an invalid error choice on the "status" field
"""
try:
for status_error in self.errors['status']:
if status_error.code == 'invalid_choice':
return True
except KeyError:
pass
return False
# pylint: disable=abstract-method
class ProgramEnrollmentSerializer(serializers.ModelSerializer, InvalidStatusMixin):
"""
Serializer for Program Enrollments
"""
class Meta(object):
model = ProgramEnrollment
fields = ('user', 'external_user_key', 'program_uuid', 'curriculum_uuid', 'status')
validators = []
def validate(self, attrs):
""" This modifies self.instance in the case of updates """
if not self.instance:
enrollment = ProgramEnrollment(**attrs)
enrollment.full_clean()
else:
for key, value in attrs.items():
setattr(self.instance, key, value)
self.instance.full_clean()
return attrs
def create(self, validated_data):
return ProgramEnrollment.objects.create(**validated_data)
class BaseProgramEnrollmentRequestMixin(serializers.Serializer, InvalidStatusMixin):
"""
Base fields for all program enrollment related serializers
"""
student_key = serializers.CharField()
status = serializers.ChoiceField(
allow_blank=False,
choices=ProgramEnrollmentResponseStatuses.VALID_STATUSES
)
class ProgramEnrollmentCreateRequestSerializer(BaseProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment creation requests
"""
curriculum_uuid = serializers.UUIDField()
class ProgramEnrollmentModifyRequestSerializer(BaseProgramEnrollmentRequestMixin):
"""
Serializer for program enrollment modification requests
"""
pass
class ProgramEnrollmentListSerializer(serializers.Serializer):
"""
Serializer for listing enrollments in a program.
"""
student_key = serializers.CharField(source='external_user_key')
status = serializers.CharField()
account_exists = serializers.SerializerMethodField()
curriculum_uuid = serializers.UUIDField()
class Meta(object):
model = ProgramEnrollment
def get_account_exists(self, obj):
return bool(obj.user)
# pylint: disable=abstract-method
class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidStatusMixin):
"""
Serializer for request to create a ProgramCourseEnrollment
"""
STATUS_CHOICES = ['active', 'inactive']
student_key = serializers.CharField(allow_blank=False)
status = serializers.ChoiceField(allow_blank=False, choices=STATUS_CHOICES)
class ProgramCourseEnrollmentListSerializer(serializers.Serializer):
"""
Serializer for listing course enrollments in a program.
"""
student_key = serializers.SerializerMethodField()
status = serializers.CharField()
account_exists = serializers.SerializerMethodField()
curriculum_uuid = serializers.SerializerMethodField()
class Meta(object):
model = ProgramCourseEnrollment
def get_student_key(self, obj):
return obj.program_enrollment.external_user_key
def get_account_exists(self, obj):
return bool(obj.program_enrollment.user)
def get_curriculum_uuid(self, obj):
return text_type(obj.program_enrollment.curriculum_uuid)
class ProgramCourseGradeResult(object):
"""
Represents a courserun grade for a user enrolled through a program.
Can be passed to ProgramCourseGradeResultSerializer.
"""
is_error = False
def __init__(self, program_course_enrollment, course_grade):
"""
Creates a new grade result given a ProgramCourseEnrollment object
and a course grade object.
"""
self.student_key = program_course_enrollment.program_enrollment.external_user_key
self.passed = course_grade.passed
self.percent = course_grade.percent
self.letter_grade = course_grade.letter_grade
class ProgramCourseGradeErrorResult(object):
"""
Represents a failure to load a courserun grade for a user enrolled through
a program.
Can be passed to ProgramCourseGradeResultSerializer.
"""
is_error = True
def __init__(self, program_course_enrollment, exception=None):
"""
Creates a new course grade error object given a
ProgramCourseEnrollment and an exception.
"""
self.student_key = program_course_enrollment.program_enrollment.external_user_key
self.error = text_type(exception) if exception else u"Unknown error"
class ProgramCourseGradeResultSerializer(serializers.Serializer):
"""
Serializer for a user's grade in a program courserun.
Meant to be used with ProgramCourseGradeResult
or ProgramCourseGradeErrorResult as input.
Absence of fields other than `student_key` will be ignored.
"""
# Required
student_key = serializers.CharField()
# From ProgramCourseGradeResult only
passed = serializers.BooleanField(required=False)
percent = serializers.FloatField(required=False)
letter_grade = serializers.CharField(required=False)
# From ProgramCourseGradeErrorResult only
error = serializers.CharField(required=False)
class DueDateSerializer(serializers.Serializer):
"""
Serializer for a due date.
"""
name = serializers.CharField()
url = serializers.CharField()
date = serializers.DateTimeField()
class CourseRunOverviewSerializer(serializers.Serializer):
"""
Serializer for a course run overview.
"""
STATUS_CHOICES = [
CourseRunProgressStatuses.IN_PROGRESS,
CourseRunProgressStatuses.UPCOMING,
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)
class CourseRunOverviewListSerializer(serializers.Serializer):
"""
Serializer for a list of course run overviews.
"""
course_runs = serializers.ListField(child=CourseRunOverviewSerializer())

View File

@@ -1,46 +0,0 @@
"""
Unit tests for ProgramEnrollment serializers.
"""
from __future__ import absolute_import, unicode_literals
from uuid import uuid4
from django.test import TestCase
from lms.djangoapps.program_enrollments.api.v1.serializers import ProgramEnrollmentSerializer
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from student.tests.factories import UserFactory
class ProgramEnrollmentSerializerTests(TestCase):
"""
Tests for the ProgramEnrollment serializer.
"""
def setUp(self):
"""
Set up the test data used in the specific tests
"""
super(ProgramEnrollmentSerializerTests, self).setUp()
self.user = UserFactory.create()
self.enrollment = ProgramEnrollment.objects.create(
user=self.user,
external_user_key='abc',
program_uuid=uuid4(),
curriculum_uuid=uuid4(),
status='enrolled'
)
self.serializer = ProgramEnrollmentSerializer(instance=self.enrollment)
def test_serializer_contains_expected_fields(self):
data = self.serializer.data
self.assertEqual(
set(data.keys()),
set([
'user',
'external_user_key',
'program_uuid',
'curriculum_uuid',
'status'
])
)

View File

@@ -1,2116 +0,0 @@
"""
Unit tests for ProgramEnrollment views.
"""
from __future__ import absolute_import, unicode_literals
import json
from datetime import datetime, timedelta
from uuid import UUID, uuid4
import ddt
import mock
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
from freezegun import freeze_time
from opaque_keys.edx.keys import CourseKey
from organizations.tests.factories import OrganizationFactory
from pytz import UTC
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from six.moves import range, zip
from bulk_email.models import BulkEmailFlag, Optout
from course_modes.models import CourseMode
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory
from lms.djangoapps.program_enrollments.api.v1.constants import (
ENABLE_ENROLLMENT_RESET_FLAG,
MAX_ENROLLMENT_RECORDS,
REQUEST_STUDENT_KEY
)
from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses
from lms.djangoapps.program_enrollments.api.v1.constants import CourseRunProgressStatuses
from lms.djangoapps.program_enrollments.api.v1.constants import ProgramEnrollmentResponseStatuses as ProgramStatuses
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from lms.djangoapps.program_enrollments.utils import ProviderDoesNotExistException
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
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.roles import CourseStaffRole
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from third_party_auth.tests.factories import SAMLProviderConfigFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCourseFactory
from xmodule.modulestore.tests.factories import ItemFactory
class ProgramCacheTestCaseMixin(CacheIsolationMixin):
"""
Mixin for using program cache in tests
"""
ENABLED_CACHES = ['default']
@staticmethod
def setup_catalog_cache(program_uuid, organization_key):
"""
helper function to initialize a cached program with an single authoring_organization
"""
catalog_org = CatalogOrganizationFactory.create(key=organization_key)
program = ProgramFactory.create(
uuid=program_uuid,
authoring_organizations=[catalog_org]
)
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
return program
@staticmethod
def set_program_in_catalog_cache(program_uuid, program):
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
@staticmethod
def set_org_in_catalog_cache(organization, program_uuids):
cache.set(PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL.format(org_key=organization.short_name), program_uuids)
class ListViewTestMixin(ProgramCacheTestCaseMixin):
"""
Mixin to define some shared test data objects for program/course enrollment
list view tests.
"""
view_name = None
@classmethod
def setUpClass(cls):
super(ListViewTestMixin, cls).setUpClass()
cls.start_cache_isolation()
cls.program_uuid = '00000000-1111-2222-3333-444444444444'
cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}'
cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444'
cls.organization_key = "orgkey"
cls.program = cls.setup_catalog_cache(cls.program_uuid, cls.organization_key)
cls.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course')
_ = CourseOverviewFactory.create(id=cls.course_id)
cls.password = 'password'
cls.student = UserFactory.create(username='student', password=cls.password)
cls.global_staff = GlobalStaffFactory.create(username='global-staff', password=cls.password)
@classmethod
def tearDownClass(cls):
super(ListViewTestMixin, cls).tearDownClass()
cls.end_cache_isolation()
def setUp(self):
super(ListViewTestMixin, self).setUp()
self.set_program_in_catalog_cache(self.program_uuid, self.program)
self.curriculum = next(c for c in self.program['curricula'] if c['is_active'])
self.course = self.curriculum['courses'][0]
self.course_run = self.course["course_runs"][0]
self.course_key = CourseKey.from_string(self.course_run["key"])
CourseOverviewFactory(id=self.course_key)
self.course_not_in_program = CourseFactory()
self.course_not_in_program_key = CourseKey.from_string(
self.course_not_in_program["course_runs"][0]["key"]
)
CourseOverviewFactory(id=self.course_not_in_program_key)
def get_url(self, program_uuid=None, course_id=None):
""" Returns the primary URL requested by the test case. """
kwargs = {'program_uuid': program_uuid or self.program_uuid}
if course_id:
kwargs['course_id'] = course_id
return reverse(self.view_name, kwargs=kwargs)
def log_in_non_staff(self):
self.client.login(username=self.student.username, password=self.password)
def log_in_staff(self):
self.client.login(username=self.global_staff.username, password=self.password)
@ddt.ddt
class UserProgramReadOnlyAccessViewTest(ListViewTestMixin, APITestCase):
"""
Tests for the UserProgramReadonlyAccess view class
"""
view_name = 'programs_api:v1:user_program_readonly_access'
@classmethod
def setUpClass(cls):
super(UserProgramReadOnlyAccessViewTest, cls).setUpClass()
cls.mock_program_data = [
{'uuid': cls.program_uuid_tmpl.format(11), 'marketing_slug': 'garbage-program', 'type': 'masters'},
{'uuid': cls.program_uuid_tmpl.format(22), 'marketing_slug': 'garbage-study', 'type': 'micromaster'},
{'uuid': cls.program_uuid_tmpl.format(33), 'marketing_slug': 'garbage-life', 'type': 'masters'},
]
cls.course_staff = InstructorFactory.create(password=cls.password, course_key=cls.course_id)
cls.date = datetime(2013, 1, 22, tzinfo=UTC)
CourseEnrollmentFactory(
course_id=cls.course_id,
user=cls.course_staff,
created=cls.date,
)
def test_401_if_anonymous(self):
response = self.client.get(reverse(self.view_name))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
@ddt.data(
('masters', 2),
('micromaster', 1)
)
@ddt.unpack
def test_global_staff(self, program_type, expected_data_size):
self.client.login(username=self.global_staff.username, password=self.password)
mock_return_value = [program for program in self.mock_program_data if program['type'] == program_type]
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs_by_type',
autospec=True,
return_value=mock_return_value
) as mock_get_programs_by_type:
response = self.client.get(reverse(self.view_name) + '?type=' + program_type)
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == expected_data_size
mock_get_programs_by_type.assert_called_once_with(response.wsgi_request.site, program_type)
def test_course_staff(self):
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
return_value=[self.mock_program_data[0]]
) as mock_get_programs:
response = self.client.get(reverse(self.view_name) + '?type=masters')
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 1
mock_get_programs.assert_called_once_with(course=self.course_id)
def test_course_staff_of_multiple_courses(self):
other_course_key = CourseKey.from_string('course-v1:edX+ToyX+Other_Course')
CourseEnrollmentFactory.create(course_id=other_course_key, user=self.course_staff)
CourseStaffRole(other_course_key).add_users(self.course_staff)
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
) as mock_get_programs:
response = self.client.get(reverse(self.view_name) + '?type=masters')
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 2
mock_get_programs.assert_has_calls([
mock.call(course=self.course_id),
mock.call(course=other_course_key),
], any_order=True)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
def test_learner_200_if_no_programs_enrolled(self, mock_get_programs):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(reverse(self.view_name))
assert status.HTTP_200_OK == response.status_code
assert response.data == []
mock_get_programs.assert_called_once_with(uuids=[])
def test_learner_200_many_programs(self):
for program in self.mock_program_data:
ProgramEnrollmentFactory.create(
program_uuid=program['uuid'],
curriculum_uuid=self.curriculum_uuid,
user=self.student,
status='pending',
external_user_key='user-{}'.format(self.student.id),
)
self.client.login(username=self.student.username, password=self.password)
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
return_value=self.mock_program_data
) as mock_get_programs:
response = self.client.get(reverse(self.view_name))
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 3
mock_get_programs.assert_called_once_with(uuids=[UUID(item['uuid']) for item in self.mock_program_data])
class ProgramEnrollmentListTest(ListViewTestMixin, APITestCase):
"""
Tests for GET calls to the Program Enrollments API.
"""
view_name = 'programs_api:v1:program_enrollments'
def create_program_enrollments(self):
"""
Helper method for creating program enrollment records.
"""
for i in range(2):
user_key = 'user-{}'.format(i)
ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=user_key,
)
for i in range(2, 4):
user_key = 'user-{}'.format(i)
ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key=user_key,
)
self.addCleanup(self.destroy_program_enrollments)
def destroy_program_enrollments(self):
"""
Deletes program enrollments associated with this test case's program_uuid.
"""
ProgramEnrollment.objects.filter(program_uuid=self.program_uuid).delete()
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
def test_404_if_no_program_with_key(self, mock_get_programs):
self.client.login(username=self.global_staff.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_404_NOT_FOUND == response.status_code
mock_get_programs.assert_called_once_with(uuid=self.program_uuid)
def test_403_if_not_staff(self):
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
def test_401_if_anonymous(self):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
def test_200_empty_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [],
}
assert expected == response.data
def test_200_many_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_program_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [
{
'student_key': 'user-0', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-1', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
],
}
assert expected == response.data
def test_200_many_pages(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_program_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
url = self.get_url(self.program_uuid) + '?page_size=2'
response = self.client.get(url)
assert status.HTTP_200_OK == response.status_code
expected_results = [
{
'student_key': 'user-0', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-1', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
]
assert expected_results == response.data['results']
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert response.data['next'] is not None
assert self.get_url(self.program_uuid) in response.data['next']
assert '?cursor=' in response.data['next']
assert response.data['previous'] is None
next_response = self.client.get(response.data['next'])
assert status.HTTP_200_OK == next_response.status_code
next_expected_results = [
{
'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
]
assert next_expected_results == next_response.data['results']
assert next_response.data['next'] is None
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert next_response.data['previous'] is not None
assert self.get_url(self.program_uuid) in next_response.data['previous']
assert '?cursor=' in next_response.data['previous']
class ProgramEnrollmentDataMixin(object):
""" Provides methods for creating ProgramEnrollments and ProgramCourseEnrollments. """
def learner_enrollment(self, student_key, enrollment_status="active"):
"""
Convenience method to create a learner enrollment record
"""
return {"student_key": student_key, "status": enrollment_status}
def request(self, path, data):
pass
def prepare_student(self, key):
pass
def create_program_enrollment(self, external_user_key, user=False):
"""
Creates and returns a ProgramEnrollment for the given external_user_key and
user if specified.
"""
program_enrollment = ProgramEnrollmentFactory.create(
external_user_key=external_user_key,
program_uuid=self.program_uuid,
)
if user is not False:
program_enrollment.user = user
program_enrollment.save()
return program_enrollment
def create_program_course_enrollment(self, program_enrollment, course_status='active'):
"""
Creates and returns a ProgramCourseEnrollment for the given program_enrollment and
self.course_key, creating a CourseEnrollment if the program enrollment has a user
"""
course_enrollment = None
if program_enrollment.user:
course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_key,
user=program_enrollment.user,
mode=CourseMode.MASTERS
)
course_enrollment.is_active = course_status == "active"
course_enrollment.save()
return ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment,
course_key=self.course_key,
course_enrollment=course_enrollment,
status=course_status,
)
def create_program_and_course_enrollments(self, external_user_key, user=False, course_status='active'):
program_enrollment = self.create_program_enrollment(external_user_key, user)
return self.create_program_course_enrollment(program_enrollment, course_status=course_status)
@ddt.ddt
class BaseCourseEnrollmentTestsMixin(ProgramEnrollmentDataMixin, ListViewTestMixin, ProgramCacheTestCaseMixin):
"""
A base for tests for course enrollment.
Children should override self.request()
"""
view_name = 'programs_api:v1:program_course_enrollments'
@classmethod
def setUpClass(cls):
super(BaseCourseEnrollmentTestsMixin, cls).setUpClass()
cls.start_cache_isolation()
@classmethod
def tearDownClass(cls):
cls.end_cache_isolation()
super(BaseCourseEnrollmentTestsMixin, cls).tearDownClass()
def setUp(self):
super(BaseCourseEnrollmentTestsMixin, self).setUp()
self.default_url = self.get_url(self.program_uuid, self.course_key)
self.log_in_staff()
def assert_program_course_enrollment(self, external_user_key, expected_status, has_user, mode=CourseMode.MASTERS):
"""
Convenience method to assert that a ProgramCourseEnrollment exists,
and potentially that a CourseEnrollment also exists
"""
enrollment = ProgramCourseEnrollment.objects.get(
program_enrollment__external_user_key=external_user_key,
program_enrollment__program_uuid=self.program_uuid
)
self.assertEqual(expected_status, enrollment.status)
self.assertEqual(self.course_key, enrollment.course_key)
course_enrollment = enrollment.course_enrollment
if has_user:
self.assertIsNotNone(course_enrollment)
self.assertEqual(expected_status == "active", course_enrollment.is_active)
self.assertEqual(self.course_key, course_enrollment.course_id)
self.assertEqual(mode, course_enrollment.mode)
else:
self.assertIsNone(course_enrollment)
def test_401_not_logged_in(self):
self.client.logout()
request_data = [self.learner_enrollment("learner-1")]
response = self.request(self.default_url, request_data)
self.assertEqual(401, response.status_code)
def test_403_forbidden(self):
self.client.logout()
self.log_in_non_staff()
request_data = [self.learner_enrollment("learner-1")]
response = self.request(self.default_url, request_data)
self.assertEqual(403, response.status_code)
def test_413_payload_too_large(self):
request_data = [self.learner_enrollment(str(i)) for i in range(30)]
response = self.request(self.default_url, request_data)
self.assertEqual(413, response.status_code)
def test_404_not_found(self):
nonexistant_course_key = CourseKey.from_string("course-v1:fake+fake+fake")
paths = [
self.get_url(uuid4(), self.course_key), # program not found
self.get_url(self.program_uuid, nonexistant_course_key), # course not found
self.get_url(self.program_uuid, self.course_not_in_program_key), # course not in program
]
request_data = [self.learner_enrollment("learner-1")]
for path_404 in paths:
response = self.request(path_404, request_data)
self.assertEqual(404, response.status_code)
def test_404_no_curriculum(self):
with mock.patch.dict(self.program, curricula=[]):
self.set_program_in_catalog_cache(self.program_uuid, self.program)
request_data = [self.learner_enrollment("learner-1")]
response = self.request(self.default_url, request_data)
self.assertEqual(404, response.status_code)
def test_duplicate_learner(self):
request_data = [
self.learner_enrollment("learner-1", "active"),
self.learner_enrollment("learner-1", "active"),
]
response = self.request(self.default_url, request_data)
self.assertEqual(422, response.status_code)
self.assertDictEqual(
{
"learner-1": CourseStatuses.DUPLICATED
},
response.data
)
def test_user_not_in_program(self):
request_data = [
self.learner_enrollment("learner-1"),
]
response = self.request(self.default_url, request_data)
self.assertEqual(422, response.status_code)
self.assertDictEqual(
{
"learner-1": CourseStatuses.NOT_IN_PROGRAM,
},
response.data
)
def test_invalid_status(self):
request_data = [self.learner_enrollment('learner-1', 'this-is-not-a-status')]
response = self.request(self.default_url, request_data)
self.assertEqual(422, response.status_code)
self.assertDictEqual({'learner-1': CourseStatuses.INVALID_STATUS}, response.data)
@ddt.data(
[{'status': 'active'}],
[{'student_key': '000'}],
["this isn't even a dict!"],
[{'student_key': '000', 'status': 'active'}, "bad_data"],
"not a list",
)
def test_422_unprocessable_entity_bad_data(self, request_data):
response = self.request(self.default_url, request_data)
self.assertEqual(response.status_code, 400)
self.assertIn('invalid enrollment record', response.data)
@ddt.data(
[{'status': 'pending'}],
[{'status': 'not-a-status'}],
[{'status': 'pending'}, {'status': 'pending'}],
)
def test_no_student_key(self, bad_records):
request_data = [self.learner_enrollment('learner-1')]
request_data.extend(bad_records)
response = self.request(self.default_url, request_data)
self.assertEqual(response.status_code, 400)
self.assertIn('invalid enrollment record', response.data)
def test_extra_field(self):
self.prepare_student('learner-1')
enrollment = self.learner_enrollment('learner-1', 'inactive')
enrollment['favorite_author'] = 'Hemingway'
request_data = [enrollment]
response = self.request(self.default_url, request_data)
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
response.data,
{'learner-1': 'inactive'}
)
class CourseEnrollmentPostTests(BaseCourseEnrollmentTestsMixin, APITestCase):
""" Tests for course enrollment POST """
def request(self, path, data):
return self.client.post(path, data, format='json')
def prepare_student(self, key):
self.create_program_enrollment(key)
def test_create_enrollments(self):
self.create_program_enrollment('learner-1')
self.create_program_enrollment('learner-2')
self.create_program_enrollment('learner-3', user=None)
self.create_program_enrollment('learner-4', user=None)
post_data = [
self.learner_enrollment("learner-1", "active"),
self.learner_enrollment("learner-2", "inactive"),
self.learner_enrollment("learner-3", "active"),
self.learner_enrollment("learner-4", "inactive"),
]
response = self.request(self.default_url, post_data)
self.assertEqual(200, response.status_code)
self.assertDictEqual(
{
"learner-1": "active",
"learner-2": "inactive",
"learner-3": "active",
"learner-4": "inactive",
},
response.data
)
self.assert_program_course_enrollment("learner-1", "active", True)
self.assert_program_course_enrollment("learner-2", "inactive", True)
self.assert_program_course_enrollment("learner-3", "active", False)
self.assert_program_course_enrollment("learner-4", "inactive", False)
def test_program_course_enrollment_exists(self):
"""
The program enrollments application already has a program_course_enrollment
record for this user and course
"""
self.create_program_and_course_enrollments('learner-1')
post_data = [self.learner_enrollment("learner-1")]
response = self.request(self.default_url, post_data)
self.assertEqual(422, response.status_code)
self.assertDictEqual({'learner-1': CourseStatuses.CONFLICT}, response.data)
def test_user_currently_enrolled_in_course(self):
"""
If a user is already enrolled in a course through a different method
that enrollment should be linked but not overwritten as masters.
"""
CourseEnrollmentFactory.create(
course_id=self.course_key,
user=self.student,
mode=CourseMode.VERIFIED
)
self.create_program_enrollment('learner-1', user=self.student)
post_data = [
self.learner_enrollment("learner-1", "active")
]
response = self.request(self.default_url, post_data)
self.assertEqual(200, response.status_code)
self.assertDictEqual(
{
"learner-1": "active"
},
response.data
)
self.assert_program_course_enrollment("learner-1", "active", True, mode=CourseMode.VERIFIED)
def test_207_multistatus(self):
self.create_program_enrollment('learner-1')
post_data = [self.learner_enrollment("learner-1"), self.learner_enrollment("learner-2")]
response = self.request(self.default_url, post_data)
self.assertEqual(207, response.status_code)
self.assertDictEqual(
{'learner-1': CourseStatuses.ACTIVE, 'learner-2': CourseStatuses.NOT_IN_PROGRAM},
response.data
)
@ddt.ddt
class CourseEnrollmentModificationTestMixin(BaseCourseEnrollmentTestsMixin):
"""
Base class for both the PATCH and PUT endpoints for Course Enrollment API
Children needs to implement assert_user_not_enrolled_test_result and
setup_change_test_data
"""
def prepare_student(self, key):
self.create_program_and_course_enrollments(key)
def test_207_multistatus(self):
self.create_program_and_course_enrollments('learner-1')
mod_data = [self.learner_enrollment("learner-1"), self.learner_enrollment("learner-2")]
response = self.request(self.default_url, mod_data)
self.assertEqual(207, response.status_code)
self.assertDictEqual(
{'learner-1': CourseStatuses.ACTIVE, 'learner-2': CourseStatuses.NOT_IN_PROGRAM},
response.data
)
def test_user_not_enrolled_in_course(self):
self.create_program_enrollment('learner-1')
patch_data = [self.learner_enrollment('learner-1')]
response = self.request(self.default_url, patch_data)
self.assert_user_not_enrolled_test_result(response)
def assert_user_not_enrolled_test_result(self, response):
pass
def setup_change_test_data(self, initial_statuses):
pass
@ddt.data(
('active', 'inactive', 'active', 'inactive'),
('inactive', 'active', 'inactive', 'active'),
('active', 'active', 'active', 'active'),
('inactive', 'inactive', 'inactive', 'inactive'),
)
def test_change_status(self, initial_statuses):
self.setup_change_test_data(initial_statuses)
mod_data = [
self.learner_enrollment('learner-1', 'inactive'),
self.learner_enrollment('learner-2', 'active'),
self.learner_enrollment('learner-3', 'inactive'),
self.learner_enrollment('learner-4', 'active'),
]
response = self.request(self.default_url, mod_data)
self.assertEqual(200, response.status_code)
self.assertDictEqual(
{
'learner-1': 'inactive',
'learner-2': 'active',
'learner-3': 'inactive',
'learner-4': 'active',
},
response.data
)
self.assert_program_course_enrollment('learner-1', 'inactive', True)
self.assert_program_course_enrollment('learner-2', 'active', True)
self.assert_program_course_enrollment('learner-3', 'inactive', False)
self.assert_program_course_enrollment('learner-4', 'active', False)
class CourseEnrollmentPatchTests(CourseEnrollmentModificationTestMixin, APITestCase):
""" Tests for course enrollment PATCH """
def request(self, path, data):
return self.client.patch(path, data, format='json')
def assert_user_not_enrolled_test_result(self, response):
self.assertEqual(422, response.status_code)
self.assertDictEqual({'learner-1': CourseStatuses.NOT_FOUND}, response.data)
def setup_change_test_data(self, initial_statuses):
self.create_program_and_course_enrollments('learner-1', course_status=initial_statuses[0])
self.create_program_and_course_enrollments('learner-2', course_status=initial_statuses[1])
self.create_program_and_course_enrollments('learner-3', course_status=initial_statuses[2], user=None)
self.create_program_and_course_enrollments('learner-4', course_status=initial_statuses[3], user=None)
class CourseEnrollmentPutTests(CourseEnrollmentModificationTestMixin, APITestCase):
""" Tests for course enrollment PUT """
def request(self, path, data):
return self.client.put(path, data, format='json')
def assert_user_not_enrolled_test_result(self, response):
self.assertEqual(200, response.status_code)
self.assertDictEqual({'learner-1': CourseStatuses.ACTIVE}, response.data)
def setup_change_test_data(self, initial_statuses):
self.create_program_and_course_enrollments('learner-1', course_status=initial_statuses[0])
self.create_program_enrollment('learner-2')
self.create_program_enrollment('learner-3', user=None)
self.create_program_and_course_enrollments('learner-4', course_status=initial_statuses[3], user=None)
class ProgramCourseEnrollmentListTest(ListViewTestMixin, APITestCase):
"""
Tests for GET calls to the Program Course Enrollments API.
"""
view_name = 'programs_api:v1:program_course_enrollments'
def create_course_enrollments(self):
""" Helper method for creating ProgramCourseEnrollments. """
program_enrollment_1 = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key='user-0',
)
program_enrollment_2 = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid, curriculum_uuid=self.other_curriculum_uuid, external_user_key='user-0',
)
ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment_1,
course_key=self.course_id,
status='active',
)
ProgramCourseEnrollmentFactory.create(
program_enrollment=program_enrollment_2,
course_key=self.course_id,
status='inactive',
)
self.addCleanup(self.destroy_course_enrollments)
def destroy_course_enrollments(self):
""" Helper method for tearing down ProgramCourseEnrollments. """
ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=self.program_uuid,
course_key=self.course_id
).delete()
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
def test_404_if_no_program_with_key(self, mock_get_programs):
self.client.login(username=self.global_staff.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid, self.course_id))
assert status.HTTP_404_NOT_FOUND == response.status_code
mock_get_programs.assert_called_once_with(uuid=self.program_uuid)
def test_404_if_course_does_not_exist(self):
other_course_key = CourseKey.from_string('course-v1:edX+ToyX+Other_Course')
self.client.login(username=self.global_staff.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid, other_course_key))
assert status.HTTP_404_NOT_FOUND == response.status_code
def test_403_if_not_staff(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid, self.course_id))
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_401_if_anonymous(self):
response = self.client.get(self.get_url(self.program_uuid, self.course_id))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
def test_200_empty_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid, self.course_id))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [],
}
assert expected == response.data
def test_200_many_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_course_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid, self.course_id))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [
{
'student_key': 'user-0', 'status': 'active', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-0', 'status': 'inactive', 'account_exists': True,
'curriculum_uuid': text_type(self.other_curriculum_uuid),
},
],
}
assert expected == response.data
def test_200_many_pages(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_course_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
url = self.get_url(self.program_uuid, self.course_id) + '?page_size=1'
response = self.client.get(url)
assert status.HTTP_200_OK == response.status_code
expected_results = [
{
'student_key': 'user-0', 'status': 'active', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
]
assert expected_results == response.data['results']
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert response.data['next'] is not None
assert self.get_url(self.program_uuid, self.course_id) in response.data['next']
assert '?cursor=' in response.data['next']
assert response.data['previous'] is None
next_response = self.client.get(response.data['next'])
assert status.HTTP_200_OK == next_response.status_code
next_expected_results = [
{
'student_key': 'user-0', 'status': 'inactive', 'account_exists': True,
'curriculum_uuid': text_type(self.other_curriculum_uuid),
},
]
assert next_expected_results == next_response.data['results']
assert next_response.data['next'] is None
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert next_response.data['previous'] is not None
assert self.get_url(self.program_uuid, self.course_id) in next_response.data['previous']
assert '?cursor=' in next_response.data['previous']
@ddt.ddt
class BaseProgramEnrollmentWriteTestsMixin(object):
""" Mixin class that defines common tests for program enrollment write endpoints """
add_uuid = False
program_uuid = '00000000-1111-2222-3333-444444444444'
success_status = 200
def student_enrollment(self, enrollment_status, external_user_key=None, prepare_student=False):
""" Convenience method to create a student enrollment record """
enrollment = {
REQUEST_STUDENT_KEY: external_user_key or str(uuid4().hex[0:10]),
'status': enrollment_status,
}
if self.add_uuid:
enrollment['curriculum_uuid'] = str(uuid4())
if prepare_student:
self.prepare_student(enrollment)
return enrollment
def prepare_student(self, enrollment):
pass
def get_url(self, program_uuid=None):
if program_uuid is None:
program_uuid = uuid4()
return reverse('programs_api:v1:program_enrollments', args=[program_uuid])
def test_unauthenticated(self):
self.client.logout()
request_data = [self.student_enrollment('enrolled')]
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_enrollment_payload_limit(self):
request_data = [self.student_enrollment('enrolled') for _ in range(MAX_ENROLLMENT_RECORDS + 1)]
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
def test_duplicate_enrollment(self):
request_data = [
self.student_enrollment('enrolled', '001'),
self.student_enrollment('enrolled', '001'),
]
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
self.assertEqual(response.data, {'001': 'duplicated'})
def test_unprocessable_enrollment(self):
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.request(
self.get_url(),
json.dumps([{'status': 'enrolled'}]),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
self.assertEqual(response.data, 'invalid enrollment record')
def test_program_unauthorized(self):
student = UserFactory.create(password='password')
self.client.login(username=student.username, password='password')
request_data = [self.student_enrollment('enrolled')]
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.request(self.get_url(), json.dumps(request_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_program_not_found(self):
post_data = [self.student_enrollment('enrolled')]
nonexistant_uuid = uuid4()
response = self.request(
self.get_url(program_uuid=nonexistant_uuid),
json.dumps(post_data),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@ddt.data(
[{'status': 'pending'}],
[{'status': 'not-a-status'}],
[{'status': 'pending'}, {'status': 'pending'}],
)
def test_no_student_key(self, bad_records):
program_uuid = uuid4()
url = reverse('programs_api:v1:program_enrollments', args=[program_uuid])
enrollments = [self.student_enrollment('enrolled', '001', True)]
enrollments.extend(bad_records)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.request(url, json.dumps(enrollments), content_type='application/json')
self.assertEqual(422, response.status_code)
self.assertEqual('invalid enrollment record', response.data)
def test_extra_field(self):
self.student_enrollment('pending', 'learner-01', prepare_student=True)
enrollment = self.student_enrollment('enrolled', 'learner-01')
enrollment['favorite_pokemon'] = 'bulbasaur'
enrollments = [enrollment]
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
autospec=True,
return_value=None
):
url = self.get_url(program_uuid=self.program_uuid)
response = self.request(url, json.dumps(enrollments), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
self.assertDictEqual(
response.data,
{'learner-01': 'enrolled'}
)
@ddt.ddt
class ProgramEnrollmentViewPostTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
"""
Tests for the ProgramEnrollment view POST method.
"""
add_uuid = True
success_status = status.HTTP_201_CREATED
success_status = 201
def setUp(self):
super(ProgramEnrollmentViewPostTests, self).setUp()
self.request = self.client.post
global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
self.client.login(username=global_staff.username, password='password')
def tearDown(self):
super(ProgramEnrollmentViewPostTests, self).tearDown()
ProgramEnrollment.objects.all().delete()
def test_successful_program_enrollments_no_existing_user(self):
program_key = uuid4()
statuses = ['pending', 'enrolled', 'pending']
external_user_keys = ['abc1', 'efg2', 'hij3']
curriculum_uuid = uuid4()
curriculum_uuids = [curriculum_uuid, curriculum_uuid, uuid4()]
post_data = [
{
REQUEST_STUDENT_KEY: e,
'status': s,
'curriculum_uuid': str(c)
}
for e, s, c in zip(external_user_keys, statuses, curriculum_uuids)
]
url = reverse('programs_api:v1:program_enrollments', args=[program_key])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
autospec=True,
return_value=None
):
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
for i in range(3):
enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i])
self.assertEqual(enrollment.external_user_key, external_user_keys[i])
self.assertEqual(enrollment.program_uuid, program_key)
self.assertEqual(enrollment.status, statuses[i])
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuids[i])
self.assertEqual(enrollment.user, None)
def test_successful_program_enrollments_existing_user(self):
program_key = uuid4()
curriculum_uuid = uuid4()
post_data = [
{
'status': 'enrolled',
REQUEST_STUDENT_KEY: 'abc1',
'curriculum_uuid': str(curriculum_uuid)
}
]
user = User.objects.create_user('test_user', 'test@example.com', 'password')
url = reverse('programs_api:v1:program_enrollments', args=[program_key])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
autospec=True,
return_value=user
):
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
enrollment = ProgramEnrollment.objects.get(external_user_key='abc1')
self.assertEqual(enrollment.external_user_key, 'abc1')
self.assertEqual(enrollment.program_uuid, program_key)
self.assertEqual(enrollment.status, 'enrolled')
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuid)
self.assertEqual(enrollment.user, user)
def test_program_enrollments_no_idp(self):
program_key = uuid4()
curriculum_uuid = uuid4()
post_data = [
{
'status': 'enrolled',
REQUEST_STUDENT_KEY: 'abc{}'.format(i),
'curriculum_uuid': str(curriculum_uuid)
} for i in range(3)
]
url = reverse('programs_api:v1:program_enrollments', args=[program_key])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
autospec=True,
side_effect=ProviderDoesNotExistException()
):
response = self.client.post(url, json.dumps(post_data), content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
for i in range(3):
enrollment = ProgramEnrollment.objects.get(external_user_key='abc{}'.format(i))
self.assertEqual(enrollment.program_uuid, program_key)
self.assertEqual(enrollment.status, 'enrolled')
self.assertEqual(enrollment.curriculum_uuid, curriculum_uuid)
self.assertIsNone(enrollment.user)
@ddt.ddt
class ProgramEnrollmentViewPatchTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
"""
Tests for the ProgramEnrollment view PATCH method.
"""
add_uuid = False
success_status = status.HTTP_200_OK
def setUp(self):
super(ProgramEnrollmentViewPatchTests, self).setUp()
self.request = self.client.patch
self.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
self.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444'
self.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course')
_ = CourseOverviewFactory.create(id=self.course_id)
self.password = 'password'
self.student = UserFactory.create(username='student', password=self.password)
self.global_staff = GlobalStaffFactory.create(username='global-staff', password=self.password)
self.client.login(username=self.global_staff.username, password=self.password)
def prepare_student(self, enrollment):
ProgramEnrollment.objects.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=enrollment[REQUEST_STUDENT_KEY],
)
def test_successfully_patched_program_enrollment(self):
enrollments = {}
for i in range(4):
user_key = 'user-{}'.format(i)
instance = ProgramEnrollment.objects.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=user_key,
)
enrollments[user_key] = instance
post_data = [
{REQUEST_STUDENT_KEY: 'user-1', 'status': 'canceled'},
{REQUEST_STUDENT_KEY: 'user-2', 'status': 'suspended'},
{REQUEST_STUDENT_KEY: 'user-3', 'status': 'enrolled'},
]
url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.patch(url, json.dumps(post_data), content_type='application/json')
for enrollment in enrollments.values():
enrollment.refresh_from_db()
expected_statuses = {
'user-0': 'pending',
'user-1': 'canceled',
'user-2': 'suspended',
'user-3': 'enrolled',
}
for user_key, enrollment in enrollments.items():
assert expected_statuses[user_key] == enrollment.status
expected_response = {
'user-1': 'canceled',
'user-2': 'suspended',
'user-3': 'enrolled',
}
assert status.HTTP_200_OK == response.status_code
assert expected_response == response.data
def test_duplicate_enrollment_record_changed(self):
enrollments = {}
for i in range(4):
user_key = 'user-{}'.format(i)
instance = ProgramEnrollment.objects.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=user_key,
)
enrollments[user_key] = instance
patch_data = [
self.student_enrollment('enrolled', 'user-1'),
self.student_enrollment('enrolled', 'user-2'),
self.student_enrollment('enrolled', 'user-1'),
]
url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.patch(url, json.dumps(patch_data), content_type='application/json')
for enrollment in enrollments.values():
enrollment.refresh_from_db()
expected_statuses = {
'user-0': 'pending',
'user-1': 'pending',
'user-2': 'enrolled',
'user-3': 'pending',
}
for user_key, enrollment in enrollments.items():
assert expected_statuses[user_key] == enrollment.status
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
self.assertEqual(response.data, {
'user-1': 'duplicated',
'user-2': 'enrolled',
})
def test_partially_valid_enrollment_record_changed(self):
enrollments = {}
for i in range(4):
user_key = 'user-{}'.format(i)
instance = ProgramEnrollment.objects.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=user_key,
)
enrollments[user_key] = instance
patch_data = [
self.student_enrollment('new', 'user-1'),
self.student_enrollment('canceled', 'user-3'),
self.student_enrollment('enrolled', 'user-who-is-not-in-program'),
]
url = reverse('programs_api:v1:program_enrollments', args=[self.program_uuid])
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.patch(url, json.dumps(patch_data), content_type='application/json')
for enrollment in enrollments.values():
enrollment.refresh_from_db()
expected_statuses = {
'user-0': 'pending',
'user-1': 'pending',
'user-2': 'pending',
'user-3': 'canceled',
}
for user_key, enrollment in enrollments.items():
assert expected_statuses[user_key] == enrollment.status
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
self.assertEqual(response.data, {
'user-1': 'invalid-status',
'user-3': 'canceled',
'user-who-is-not-in-program': 'not-in-program',
})
@ddt.ddt
class ProgramEnrollmentViewPutTests(BaseProgramEnrollmentWriteTestsMixin, APITestCase):
"""
Tests for the ProgramEnrollment view PATCH method.
"""
add_uuid = True
success_status = status.HTTP_200_OK
def setUp(self):
super(ProgramEnrollmentViewPutTests, self).setUp()
self.request = self.client.put
self.program_uuid = '00000000-1111-2222-3333-444444444444'
self.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
self.global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
self.client.login(username=self.global_staff.username, password='password')
patch_get_user = mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_user_by_program_id',
autospec=True,
return_value=None
)
self.mock_get_user = patch_get_user.start()
self.addCleanup(patch_get_user.stop)
def prepare_student(self, enrollment):
ProgramEnrollment.objects.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=enrollment[REQUEST_STUDENT_KEY],
)
@ddt.data(True, False)
def test_all_create_or_modify(self, create_users):
request_data = [
self.student_enrollment(ProgramStatuses.ENROLLED)
for _ in range(5)
]
if create_users:
for enrollment in request_data:
ProgramEnrollmentFactory(
program_uuid=self.program_uuid,
status=ProgramStatuses.PENDING,
external_user_key=enrollment[REQUEST_STUDENT_KEY],
)
url = self.get_url(program_uuid=self.program_uuid)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
self.assertEqual(5, len(response.data))
for response_status in response.data.values():
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
def test_half_create_modify(self):
request_data = [
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-01'),
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-02'),
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-03'),
self.student_enrollment(ProgramStatuses.ENROLLED, 'learner-04'),
]
ProgramEnrollmentFactory(
program_uuid=self.program_uuid,
status=ProgramStatuses.PENDING,
external_user_key='learner-03',
)
ProgramEnrollmentFactory(
program_uuid=self.program_uuid,
status=ProgramStatuses.PENDING,
external_user_key='learner-04',
)
url = self.get_url(program_uuid=self.program_uuid)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.put(url, json.dumps(request_data), content_type='application/json')
self.assertEqual(self.success_status, response.status_code)
self.assertEqual(4, len(response.data))
for response_status in response.data.values():
self.assertEqual(response_status, ProgramStatuses.ENROLLED)
@ddt.ddt
class ProgramCourseEnrollmentOverviewViewTests(ProgramCacheTestCaseMixin, SharedModuleStoreTestCase, APITestCase):
"""
Tests for the ProgramCourseEnrollmentOverview view GET method.
"""
@classmethod
def setUpClass(cls):
super(ProgramCourseEnrollmentOverviewViewTests, cls).setUpClass()
cls.program_uuid = '00000000-1111-2222-3333-444444444444'
cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444'
cls.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course')
cls.course_run = CourseRunFactory.create(key=text_type(cls.course_id))
cls.course = CourseFactory.create(course_runs=[cls.course_run])
cls.password = 'password'
cls.student = UserFactory.create(username='student', 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
with freeze_time('2019-01-01'):
cls.yesterday = datetime.utcnow() - timedelta(1)
cls.tomorrow = datetime.utcnow() + timedelta(1)
cls.relative_certificate_download_url = '/download-the-certificates'
cls.absolute_certificate_download_url = 'http://www.certificates.com/'
def setUp(self):
super(ProgramCourseEnrollmentOverviewViewTests, self).setUp()
# create program enrollment
self.program_enrollment = ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=self.student,
)
# create course enrollment
self.course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_id,
user=self.student,
mode=CourseMode.MASTERS,
)
# create course overview
self.course_overview = CourseOverviewFactory.create(
id=self.course_id,
start=self.yesterday,
end=self.tomorrow,
)
# create program course enrollment
self.program_course_enrollment = ProgramCourseEnrollmentFactory.create(
program_enrollment=self.program_enrollment,
course_enrollment=self.course_enrollment,
course_key=self.course_id,
status='active',
)
# create program
self.program = self.setup_catalog_cache(self.program_uuid, 'organization_key')
self.program['curricula'][0]['courses'].append(self.course)
self.set_program_in_catalog_cache(self.program_uuid, self.program)
def create_generated_certificate(self, download_url=None):
return GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course_id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url=(download_url or self.relative_certificate_download_url),
grade="0.88",
verify_uuid=uuid4(),
)
def get_url(self, program_uuid=None):
""" Returns the primary URL requested by the test case. """
kwargs = {'program_uuid': program_uuid or self.program_uuid}
return reverse('programs_api:v1:program_course_enrollments_overview', kwargs=kwargs)
def test_401_if_anonymous(self):
response = self.client.get(self.get_url(self.program_uuid))
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.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
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
def _add_new_course_to_program(self, course_run_key, program):
"""
Helper method to create another course, an overview for it,
add it to the program, and re-load the cache.
"""
other_course_run = CourseRunFactory.create(key=text_type(course_run_key))
other_course = CourseFactory.create(course_runs=[other_course_run])
program['courses'].append(other_course)
self.set_program_in_catalog_cache(program['uuid'], program)
CourseOverviewFactory.create(
id=course_run_key,
start=self.yesterday,
)
@ddt.data(False, True)
def test_multiple_enrollments_all_enrolled(self, other_enrollment_active):
other_course_key = CourseKey.from_string('course-v1:edX+ToyX+Other_Course')
self._add_new_course_to_program(other_course_key, self.program)
# add a second course enrollment, which doesn't need a ProgramCourseEnrollment
# to be returned.
other_enrollment = CourseEnrollmentFactory.create(
course_id=other_course_key,
user=self.student,
mode=CourseMode.VERIFIED,
)
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.assertEqual(status.HTTP_200_OK, response.status_code)
actual_course_run_ids = {run['course_run_id'] for run in response.data['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))
self.assertEqual(expected_course_run_ids, actual_course_run_ids)
_GET_RESUME_URL = 'lms.djangoapps.program_enrollments.api.v1.views.get_resume_urls_for_enrollments'
@mock.patch(_GET_RESUME_URL)
def test_blank_resume_url_omitted(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
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])
@mock.patch(_GET_RESUME_URL)
def test_relative_resume_url_becomes_absolute(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
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']
self.assertTrue(response_resume_url.startswith("http://testserver"))
self.assertTrue(response_resume_url.endswith(resume_url))
@mock.patch(_GET_RESUME_URL)
def test_absolute_resume_url_stays_absolute(self, mock_get_resume_urls):
self.client.login(username=self.student.username, password=self.password)
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']
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])
def test_relative_certificate_url_becomes_absolute(self):
self.client.login(username=self.student.username, password=self.password)
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']
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.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']
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)
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']
def test_due_dates(self):
course = ModulestoreCourseFactory.create(
org="edX",
course="ToyX",
run="Toy_Course",
)
section_1 = ItemFactory.create(
category='chapter',
start=self.yesterday,
due=self.tomorrow,
parent=course,
display_name='section 1'
)
subsection_1 = ItemFactory.create(
category='sequential',
due=self.tomorrow,
parent=section_1,
display_name='subsection 1'
)
subsection_2 = ItemFactory.create(
category='sequential',
due=self.tomorrow - timedelta(1),
parent=section_1,
display_name='subsection 2'
)
subsection_3 = ItemFactory.create(
category='sequential',
parent=section_1,
display_name='subsection 3'
)
unit_1 = ItemFactory.create(
category='vertical',
due=self.tomorrow + timedelta(2),
parent=subsection_3,
display_name='unit_1'
)
with mock.patch('lms.djangoapps.program_enrollments.api.api.get_dates_for_course') as mock_get_dates:
mock_get_dates.return_value = {
(section_1.location, 'due'): section_1.due,
(section_1.location, 'start'): section_1.start,
(subsection_1.location, 'due'): subsection_1.due,
(subsection_2.location, 'due'): subsection_2.due,
(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)
block_data = [
{
'name': section_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/chapter/section_1'),
'date': '2019-01-02T00:00:00Z',
},
{
'name': subsection_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/sequential/subsection_1'),
'date': '2019-01-02T00:00:00Z',
},
{
'name': subsection_2.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/sequential/subsection_2'),
'date': '2019-01-01T00:00:00Z',
},
{
'name': unit_1.display_name,
'url': ('http://testserver/courses/course-v1:edX+ToyX+Toy_Course/'
'jump_to/i4x://edX/ToyX/vertical/unit_1'),
'date': '2019-01-04T00:00:00Z',
},
]
due_dates = response.data['course_runs'][0]['due_dates']
for block in block_data:
self.assertIn(block, due_dates)
@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)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
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'])
@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)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
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'])
@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)
# set as instructor paced
self.course_overview.self_paced = False
self.course_overview.save()
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'])
@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)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# 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'])
# course run has not ended and user has earned a passing certificate more than 30 days ago
certificate = self.create_generated_certificate()
certificate.created_date = datetime.utcnow() - timedelta(30)
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'])
# 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'])
@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)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# course run has started and has not ended
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'])
# 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 = datetime.utcnow() - 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'])
@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)
# set as self paced
self.course_overview.self_paced = True
self.course_overview.save()
# course run has not started and has not ended
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'])
def test_course_run_url(self):
self.client.login(username=self.student.username, password=self.password)
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'])
def test_course_run_dates(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)
course_run_overview = response.data['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')
# course run end date may not exist
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)
def test_course_run_id_and_display_name(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)
course_run_overview = response.data['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)
# 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])
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'])
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'])
def test_micromasters_title(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('micromasters_title', response.data['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])
class ProgramCourseGradeListTest(ProgramEnrollmentDataMixin, ListViewTestMixin, APITestCase):
"""
Tests for GET calls to the Program Course Grades API.
"""
view_name = 'programs_api:v1:program_course_grades'
@staticmethod
def mock_course_grade(percent=75.0, passed=True, letter_grade='B'):
return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory')
def test_204_no_grades_to_return(self, mock_course_grade_factory):
mock_course_grade_factory.return_value.iter.return_value = []
self.log_in_staff()
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(response.data['results'], [])
def test_401_if_unauthenticated(self):
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_403_if_not_staff(self):
self.log_in_non_staff()
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_404_not_found(self):
fake_program_uuid = self.program_uuid_tmpl.format(99)
self.log_in_staff()
url = self.get_url(program_uuid=fake_program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory')
def test_200_grades_with_no_exceptions(self, mock_course_grade_factory):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, self.mock_course_grade(), None),
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
self.log_in_staff()
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_results = [
{
'student_key': 'student-key',
'passed': True,
'percent': 75.0,
'letter_grade': 'B',
},
{
'student_key': 'other-student-key',
'passed': False,
'percent': 40.0,
'letter_grade': 'F',
},
]
self.assertEqual(response.data['results'], expected_results)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory')
def test_207_grades_with_some_exceptions(self, mock_course_grade_factory):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, None, Exception('Bad Data')),
(other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
self.log_in_staff()
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
expected_results = [
{
'student_key': 'student-key',
'error': 'Bad Data',
},
{
'student_key': 'other-student-key',
'passed': False,
'percent': 40.0,
'letter_grade': 'F',
},
]
self.assertEqual(response.data['results'], expected_results)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory')
def test_422_grades_with_only_exceptions(self, mock_course_grade_factory):
other_student = UserFactory.create(username='other_student')
self.create_program_and_course_enrollments('student-key', user=self.student)
self.create_program_and_course_enrollments('other-student-key', user=other_student)
mock_course_grades = [
(self.student, None, Exception('Bad Data')),
(other_student, None, Exception('Timeout')),
]
mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
self.log_in_staff()
url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
expected_results = [
{
'student_key': 'student-key',
'error': 'Bad Data',
},
{
'student_key': 'other-student-key',
'error': 'Timeout',
},
]
self.assertEqual(response.data['results'], expected_results)
class EnrollmentDataResetViewTests(ProgramCacheTestCaseMixin, APITestCase):
""" Tests endpoint for resetting enrollments in integration environments """
FEATURES_WITH_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_ENABLED[ENABLE_ENROLLMENT_RESET_FLAG] = True
reset_enrollments_cmd = 'reset_enrollment_data'
reset_users_cmd = 'remove_social_auth_users'
def setUp(self):
super(EnrollmentDataResetViewTests, self).setUp()
self.start_cache_isolation()
self.organization = OrganizationFactory(short_name='uox')
self.provider = SAMLProviderConfigFactory(organization=self.organization)
self.global_staff = GlobalStaffFactory.create(username='global-staff', password='password')
self.client.login(username=self.global_staff.username, password='password')
def request(self, organization):
return self.client.post(
reverse('programs_api:v1:reset_enrollment_data'),
{'organization': organization},
format='json',
)
def tearDown(self):
self.end_cache_isolation()
super(EnrollmentDataResetViewTests, self).tearDown()
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_feature_disabled_by_default(self, mock_call_command):
response = self.request(self.organization.short_name)
self.assertEqual(response.status_code, status.HTTP_501_NOT_IMPLEMENTED)
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_403_for_non_staff(self, mock_call_command):
student = UserFactory.create(username='student', password='password')
self.client.login(username=student.username, password='password')
response = self.request(self.organization.short_name)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_reset(self, mock_call_command):
programs = [str(uuid4()), str(uuid4())]
self.set_org_in_catalog_cache(self.organization, programs)
response = self.request(self.organization.short_name)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_call_command.assert_has_calls([
mock.call(self.reset_users_cmd, self.provider.slug, force=True),
mock.call(self.reset_enrollments_cmd, ','.join(programs), force=True),
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_reset_without_idp(self, mock_call_command):
organization = OrganizationFactory()
programs = [str(uuid4()), str(uuid4())]
self.set_org_in_catalog_cache(organization, programs)
response = self.request(organization.short_name)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_call_command.assert_has_calls([
mock.call(self.reset_enrollments_cmd, ','.join(programs), force=True),
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_organization_not_found(self, mock_call_command):
response = self.request('yyz')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
mock_call_command.assert_has_calls([])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_no_programs_doesnt_break(self, mock_call_command):
programs = []
self.set_org_in_catalog_cache(self.organization, programs)
response = self.request(self.organization.short_name)
self.assertEqual(response.status_code, status.HTTP_200_OK)
mock_call_command.assert_has_calls([
mock.call(self.reset_users_cmd, self.provider.slug, force=True),
])
@override_settings(FEATURES=FEATURES_WITH_ENABLED)
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.call_command', autospec=True)
def test_missing_body_content(self, mock_call_command):
response = self.client.post(
reverse('programs_api:v1:reset_enrollment_data'),
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
mock_call_command.assert_has_calls([])

View File

@@ -1,63 +0,0 @@
""" Program Enrollments API v1 URLs. """
from __future__ import absolute_import
from django.conf.urls import url
from lms.djangoapps.program_enrollments.api.v1.constants import PROGRAM_UUID_PATTERN
from lms.djangoapps.program_enrollments.api.v1.views import (
EnrollmentDataResetView,
ProgramEnrollmentsView,
ProgramCourseEnrollmentsView,
ProgramCourseGradesView,
ProgramCourseEnrollmentOverviewView,
UserProgramReadOnlyAccessView,
)
from openedx.core.constants import COURSE_ID_PATTERN
app_name = 'lms.djangoapps.program_enrollments'
urlpatterns = [
url(
r'^programs/enrollments/$',
UserProgramReadOnlyAccessView.as_view(),
name='learner_program_enrollments'
),
url(
r'^programs/readonly_access/$',
UserProgramReadOnlyAccessView.as_view(),
name='user_program_readonly_access'
),
url(
r'^programs/{program_uuid}/enrollments/$'.format(program_uuid=PROGRAM_UUID_PATTERN),
ProgramEnrollmentsView.as_view(),
name='program_enrollments'
),
url(
r'^programs/{program_uuid}/courses/{course_id}/enrollments/'.format(
program_uuid=PROGRAM_UUID_PATTERN,
course_id=COURSE_ID_PATTERN
),
ProgramCourseEnrollmentsView.as_view(),
name="program_course_enrollments"
),
url(
r'^programs/{program_uuid}/courses/{course_id}/grades/'.format(
program_uuid=PROGRAM_UUID_PATTERN,
course_id=COURSE_ID_PATTERN
),
ProgramCourseGradesView.as_view(),
name="program_course_grades"
),
url(
r'^programs/{program_uuid}/overview/'.format(
program_uuid=PROGRAM_UUID_PATTERN,
),
ProgramCourseEnrollmentOverviewView.as_view(),
name="program_course_enrollments_overview"
),
url(
r'^integration-reset',
EnrollmentDataResetView.as_view(),
name="reset_enrollment_data",
)
]

View File

@@ -1,1346 +0,0 @@
# -*- coding: utf-8 -*-
"""
ProgramEnrollment Views
"""
from __future__ import absolute_import, unicode_literals
import logging
from functools import wraps
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 django.http import Http404
from django.utils.functional import cached_property
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 ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from six import text_type
from course_modes.models import CourseMode
from lms.djangoapps.certificates.api import get_certificate_for_user
from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination
from lms.djangoapps.program_enrollments.api.api import (
get_course_run_status,
get_course_run_url,
get_due_dates,
get_emails_enabled
)
from lms.djangoapps.program_enrollments.api.v1.constants import (
ENABLE_ENROLLMENT_RESET_FLAG,
MAX_ENROLLMENT_RECORDS,
CourseEnrollmentResponseStatuses,
ProgramEnrollmentResponseStatuses,
)
from lms.djangoapps.program_enrollments.api.v1.serializers import (
CourseRunOverviewListSerializer,
ProgramCourseEnrollmentListSerializer,
ProgramCourseEnrollmentRequestSerializer,
ProgramCourseGradeErrorResult,
ProgramCourseGradeResult,
ProgramCourseGradeResultSerializer,
ProgramEnrollmentCreateRequestSerializer,
ProgramEnrollmentListSerializer,
ProgramEnrollmentModifyRequestSerializer
)
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.utils import (
ProviderDoesNotExistException,
get_provider_slug,
get_user_by_program_id
)
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 OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, verify_course_exists
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 use_read_replica_if_available
logger = logging.getLogger(__name__)
def verify_program_exists(view_func):
"""
Raises:
An API error if the `program_uuid` kwarg in the wrapped function
does not exist in the catalog programs cache.
"""
@wraps(view_func)
def wrapped_function(self, request, **kwargs):
"""
Wraps the given view_function.
"""
program_uuid = kwargs['program_uuid']
program = get_programs(uuid=program_uuid)
if not program:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='no program exists with given key',
error_code='program_does_not_exist'
)
return view_func(self, request, **kwargs)
return wrapped_function
def verify_course_exists_and_in_program(view_func):
"""
Raises:
An api error if the course run specified by the `course_key` kwarg
in the wrapped function is not part of the curriculum of the program
specified by the `program_uuid` kwarg
Assumes that the program exists and that a program has exactly one active curriculum
"""
@wraps(view_func)
@verify_course_exists
def wrapped_function(self, request, **kwargs):
"""
Wraps view function
"""
course_key = CourseKey.from_string(kwargs['course_id'])
program_uuid = kwargs['program_uuid']
program = get_programs(uuid=program_uuid)
active_curricula = [c for c in program['curricula'] if c['is_active']]
if not active_curricula:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message="the program does not have an active curriculum",
error_code='no_active_curriculum'
)
curriculum = active_curricula[0]
if not is_course_in_curriculum(curriculum, course_key):
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message="the program's curriculum does not contain the given course",
error_code='course_not_in_program'
)
return view_func(self, request, **kwargs)
def is_course_in_curriculum(curriculum, course_key):
for course in curriculum['courses']:
for course_run in course['course_runs']:
if CourseKey.from_string(course_run["key"]) == course_key:
return True
return wrapped_function
class ProgramEnrollmentPagination(CourseEnrollmentPagination):
"""
Pagination class for views in the Program Enrollments app.
"""
page_size = 100
class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
"""
A view for Create/Read/Update methods on Program Enrollment data.
Path: `/api/program_enrollments/v1/programs/{program_uuid}/enrollments/`
The path can contain an optional `page_size?=N` query parameter. The default page size is 100.
Returns:
* 200: OK - Contains a paginated set of program enrollment data.
* 401: The requesting user is not authenticated.
* 403: The requesting user lacks access for the given program.
* 404: The requested program does not exist.
Response:
In the case of a 200 response code, the response will include a paginated
data set. The `results` section of the response consists of a list of
program enrollment records, where each record contains the following keys:
* student_key: The identifier of the student enrolled in the program.
* status: The student's enrollment status.
* account_exists: A boolean indicating if the student has created an edx-platform user account.
* curriculum_uuid: The curriculum UUID of the enrollment record for the (student, program).
Example:
{
"next": null,
"previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_uuid}/enrollments/?curor=abcd",
"results": [
{
"student_key": "user-0", "status": "pending",
"account_exists": False, "curriculum_uuid": "00000000-1111-2222-3333-444444444444"
},
{
"student_key": "user-1", "status": "pending",
"account_exists": False, "curriculum_uuid": "00000001-1111-2222-3333-444444444444"
},
{
"student_key": "user-2", "status": "enrolled",
"account_exists": True, "curriculum_uuid": "00000002-1111-2222-3333-444444444444"
},
{
"student_key": "user-3", "status": "enrolled",
"account_exists": True, "curriculum_uuid": "00000003-1111-2222-3333-444444444444"
},
],
}
Create
==========
Path: `/api/program_enrollments/v1/programs/{program_uuid}/enrollments/`
Where the program_uuid will be the uuid for a program.
Request body:
* The request body will be a list of one or more students to enroll with the following schema:
{
'status': A choice of the following statuses: ['enrolled', 'pending', 'canceled', 'suspended'],
student_key: string representation of a learner in partner systems,
'curriculum_uuid': string representation of a curriculum
}
Example:
[
{
"status": "enrolled",
"external_user_key": "123",
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
},{
"status": "canceled",
"external_user_key": "456",
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
},{
"status": "pending",
"external_user_key": "789",
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
},{
"status": "suspended",
"external_user_key": "abc",
"curriculum_uuid": "2d7de549-b09e-4e50-835d-4c5c5080c566"
},
]
Returns:
* Response Body: {<external_user_key>: <status>} with as many keys as there were in the request body
* external_user_key - string representation of a learner in partner systems
* status - the learner's registration status
* success statuses:
* 'enrolled'
* 'pending'
* 'canceled'
* 'suspended'
* failure statuses:
* 'duplicated' - the request body listed the same learner twice
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
* 201: CREATED - All students were successfully enrolled.
* Example json response:
{
'123': 'enrolled',
'456': 'pending',
'789': 'canceled,
'abc': 'suspended'
}
* 207: MULTI-STATUS - Some students were successfully enrolled while others were not.
Details are included in the JSON response data.
* Example json response:
{
'123': 'duplicated',
'456': 'conflict',
'789': 'invalid-status,
'abc': 'suspended'
}
* 403: FORBIDDEN - The requesting user lacks access to enroll students in the given program.
* 404: NOT FOUND - The requested program does not exist.
* 413: PAYLOAD TOO LARGE - Over 25 students supplied
* 422: Unprocesable Entity - None of the students were successfully listed.
Update
==========
Path: `/api/program_enrollments/v1/programs/{program_uuid}/enrollments/`
Where the program_uuid will be the uuid for a program.
Request body:
* The request body will be a list of one or more students with their updated enrollment status:
{
'status': A choice of the following statuses: ['enrolled', 'pending', 'canceled', 'suspended'],
student_key: string representation of a learner in partner systems
}
Example:
[
{
"status": "enrolled",
"external_user_key": "123",
},{
"status": "canceled",
"external_user_key": "456",
},{
"status": "pending",
"external_user_key": "789",
},{
"status": "suspended",
"external_user_key": "abc",
},
]
Returns:
* Response Body: {<external_user_key>: <status>} with as many keys as there were in the request body
* external_user_key - string representation of a learner in partner systems
* status - the learner's registration status
* success statuses:
* 'enrolled'
* 'pending'
* 'canceled'
* 'suspended'
* failure statuses:
* 'duplicated' - the request body listed the same learner twice
* 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
* 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
* 'not-in-program' - the user is not in the program and cannot be updated
* 201: CREATED - All students were successfully enrolled.
* Example json response:
{
'123': 'enrolled',
'456': 'pending',
'789': 'canceled,
'abc': 'suspended'
}
* 207: MULTI-STATUS - Some students were successfully enrolled while others were not.
Details are included in the JSON response data.
* Example json response:
{
'123': 'duplicated',
'456': 'not-in-program',
'789': 'invalid-status,
'abc': 'suspended'
}
* 403: FORBIDDEN - The requesting user lacks access to enroll students in the given program.
* 404: NOT FOUND - The requested program does not exist.
* 413: PAYLOAD TOO LARGE - Over 25 students supplied
* 422: Unprocesable Entity - None of the students were successfully updated.
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
@verify_program_exists
def get(self, request, program_uuid=None):
""" Defines the GET list endpoint for ProgramEnrollment objects. """
enrollments = use_read_replica_if_available(
ProgramEnrollment.objects.filter(program_uuid=program_uuid)
)
paginated_enrollments = self.paginate_queryset(enrollments)
serializer = ProgramEnrollmentListSerializer(paginated_enrollments, many=True)
return self.get_paginated_response(serializer.data)
@verify_program_exists
def post(self, request, *args, **kwargs):
"""
Create program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentCreateRequestSerializer,
self.create_program_enrollment,
status.HTTP_201_CREATED,
)
@verify_program_exists
def patch(self, request, **kwargs):
"""
Modify program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentModifyRequestSerializer,
self.modify_program_enrollment,
status.HTTP_200_OK,
)
@verify_program_exists
def put(self, request, **kwargs):
"""
Create/modify program enrollments for a list of learners
"""
return self.create_or_modify_enrollments(
request,
kwargs['program_uuid'],
ProgramEnrollmentCreateRequestSerializer,
self.create_or_modify_program_enrollment,
status.HTTP_200_OK,
)
def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
"""
Validates the given enrollment record and checks that it isn't a duplicate
"""
student_key = enrollment['student_key']
if student_key in seen_student_keys:
return CourseEnrollmentResponseStatuses.DUPLICATED
seen_student_keys.add(student_key)
enrollment_serializer = serializer_class(data=enrollment)
try:
enrollment_serializer.is_valid(raise_exception=True)
except ValidationError as e:
if enrollment_serializer.has_invalid_status():
return CourseEnrollmentResponseStatuses.INVALID_STATUS
else:
raise e
def create_or_modify_enrollments(self, request, program_uuid, serializer_class, operation, success_status):
"""
Process a list of program course enrollment request objects
and create or modify enrollments based on method
"""
results = {}
seen_student_keys = set()
enrollments = []
if not isinstance(request.data, list):
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
if len(request.data) > MAX_ENROLLMENT_RECORDS:
return Response(
'enrollment limit {}'.format(MAX_ENROLLMENT_RECORDS),
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
try:
for enrollment_request in request.data:
error_status = self.validate_enrollment_request(enrollment_request, seen_student_keys, serializer_class)
if error_status:
results[enrollment_request["student_key"]] = error_status
else:
enrollments.append(enrollment_request)
except KeyError: # student_key is not in enrollment_request
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
except TypeError: # enrollment_request isn't a dict
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
except ValidationError: # there was some other error raised by the serializer
return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
for enrollment in enrollments:
student_key = enrollment["student_key"]
if student_key in results and results[student_key] == ProgramEnrollmentResponseStatuses.DUPLICATED:
continue
try:
program_enrollment = program_enrollments[student_key]
except KeyError:
program_enrollment = None
results[student_key] = operation(enrollment, program_uuid, program_enrollment)
return self._get_created_or_updated_response(results, success_status)
def create_program_enrollment(self, request_data, program_uuid, program_enrollment):
"""
Create new ProgramEnrollment, unless the learner is already enrolled in the program
"""
if program_enrollment:
return ProgramEnrollmentResponseStatuses.CONFLICT
student_key = request_data.get('student_key')
try:
user = get_user_by_program_id(student_key, program_uuid)
except ProviderDoesNotExistException:
# IDP has not yet been set up, just create waiting enrollments
user = None
enrollment = ProgramEnrollment.objects.create(
user=user,
external_user_key=student_key,
program_uuid=program_uuid,
curriculum_uuid=request_data.get('curriculum_uuid'),
status=request_data.get('status')
)
return enrollment.status
# pylint: disable=unused-argument
def modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
"""
Change the status of an existing program enrollment
"""
if not program_enrollment:
return ProgramEnrollmentResponseStatuses.NOT_IN_PROGRAM
program_enrollment.status = request_data.get('status')
program_enrollment.save()
return program_enrollment.status
def create_or_modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
if program_enrollment:
return self.modify_program_enrollment(request_data, program_uuid, program_enrollment)
else:
return self.create_program_enrollment(request_data, program_uuid, program_enrollment)
def get_existing_program_enrollments(self, program_uuid, student_data):
""" Returns the existing program enrollments for the given students and program """
student_keys = [data['student_key'] for data in student_data]
return {
e.external_user_key: e
for e in ProgramEnrollment.bulk_read_by_student_key(program_uuid, student_keys)
}
def _get_created_or_updated_response(self, response_data, default_status=status.HTTP_201_CREATED):
"""
Helper method to determine an appropirate HTTP response status code.
"""
response_status = default_status
good_count = len([
v for v in response_data.values()
if v not in CourseEnrollmentResponseStatuses.ERROR_STATUSES
])
if not good_count:
response_status = status.HTTP_422_UNPROCESSABLE_ENTITY
elif good_count != len(response_data):
response_status = status.HTTP_207_MULTI_STATUS
return Response(
status=response_status,
data=response_data,
content_type='application/json',
)
class UserProgramReadOnlyAccessView(DeveloperErrorViewMixin, PaginatedAPIView):
"""
A view for checking the currently logged-in user's program read only access
There are three major categories of users this API is differentiating. See the table below.
--------------------------------------------------------------------------------------------
| User Type | API Returns |
--------------------------------------------------------------------------------------------
| edX staff | All programs |
--------------------------------------------------------------------------------------------
| course staff | All programs containing the courses of which the user is course staff |
--------------------------------------------------------------------------------------------
| learner | All programs the learner is enrolled in |
--------------------------------------------------------------------------------------------
Path: `/api/program_enrollments/v1/programs/enrollments/`
Returns:
* 200: OK - Contains a list of all programs in which the user has read only acccess to.
* 401: The requesting user is not authenticated.
The list will be a list of objects with the following keys:
* `uuid` - the identifier of the program in which the user has read only access to.
* `slug` - the string from which a link to the corresponding program page can be constructed.
Example:
[
{
'uuid': '00000000-1111-2222-3333-444444444444',
'slug': 'deadbeef'
},
{
'uuid': '00000000-1111-2222-3333-444444444445',
'slug': 'undead-cattle'
}
]
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated,)
DEFAULT_PROGRAM_TYPE = 'masters'
def get(self, request):
"""
How to respond to a GET request to this endpoint
"""
request_user = request.user
programs = []
requested_program_type = normalize_program_type(request.GET.get('type', self.DEFAULT_PROGRAM_TYPE))
if request_user.is_staff:
programs = get_programs_by_type(request.site, requested_program_type)
elif self.is_course_staff(request_user):
programs = self.get_programs_user_is_course_staff_for(request_user, requested_program_type)
else:
program_enrollments = ProgramEnrollment.objects.filter(
user=request.user,
status__in=('enrolled', 'pending')
)
uuids = [enrollment.program_uuid for enrollment in program_enrollments]
programs = get_programs(uuids=uuids) or []
programs_in_which_user_has_access = [
{'uuid': program['uuid'], 'slug': program['marketing_slug']}
for program in programs
]
return Response(programs_in_which_user_has_access, status.HTTP_200_OK)
def is_course_staff(self, user):
"""
Returns true if the user is a course_staff member of any course within a program
"""
staff_course_keys = self.get_course_keys_user_is_staff_for(user)
return len(staff_course_keys)
def get_course_keys_user_is_staff_for(self, user):
"""
Return all the course keys the user is course instructor or course staff role for
"""
# Get all the courses of which the user is course staff for. If None, return false
def filter_ccx(course_access):
""" CCXs cannot be edited in Studio and should not be filtered """
return not isinstance(course_access.course_id, CCXLocator)
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
all_courses = list(filter(filter_ccx, instructor_courses | staff_courses))
course_keys = {}
for course_access in all_courses:
if course_access.course_id is not None:
course_keys[course_access.course_id] = course_access.course_id
return list(course_keys.values())
def get_programs_user_is_course_staff_for(self, user, program_type_filter):
"""
Return a list of programs the user is course staff for.
This function would take a list of course runs the user is staff of, and then
try to get the Masters program associated with each course_runs.
"""
program_list = []
for course_key in self.get_course_keys_user_is_staff_for(user):
course_run_programs = get_programs(course=course_key)
for course_run_program in course_run_programs:
if course_run_program and course_run_program.get('type').lower() == program_type_filter:
program_list.append(course_run_program)
return program_list
class ProgramSpecificViewMixin(object):
"""
A mixin for views that operate on or within a specific program.
"""
@cached_property
def program(self):
"""
The program specified by the `program_uuid` URL parameter.
"""
program = get_programs(uuid=self.kwargs['program_uuid'])
if program is None:
raise Http404()
return program
class ProgramCourseRunSpecificViewMixin(ProgramSpecificViewMixin):
"""
A mixin for views that operate on or within a specific course run in a program
"""
@property
def course_key(self):
"""
The course key for the course run specified by the `course_id` URL parameter.
"""
return CourseKey.from_string(self.kwargs['course_id'])
# pylint: disable=line-too-long
class ProgramCourseEnrollmentsView(DeveloperErrorViewMixin, ProgramCourseRunSpecificViewMixin, PaginatedAPIView):
"""
A view for enrolling students in a course through a program,
modifying program course enrollments, and listing program course
enrollments.
Path: ``/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/enrollments/``
Accepts: [GET, POST, PATCH, PUT]
For GET requests, the path can contain an optional `page_size?=N` query parameter.
The default page size is 100.
------------------------------------------------------------------------------------
POST, PATCH, PUT
------------------------------------------------------------------------------------
**Returns**
* 200: Returns a map of students and their enrollment status.
* 207: Not all students enrolled. Returns resulting enrollment status.
* 401: User is not authenticated
* 403: User lacks read access organization of specified program.
* 404: Program does not exist, or course does not exist in program
* 422: Invalid request, unable to enroll students.
------------------------------------------------------------------------------------
GET
------------------------------------------------------------------------------------
**Returns**
* 200: OK - Contains a paginated set of program course enrollment data.
* 401: The requesting user is not authenticated.
* 403: The requesting user lacks access for the given program/course.
* 404: The requested program or course does not exist.
**Response**
In the case of a 200 response code, the response will include a paginated
data set. The `results` section of the response consists of a list of
program course enrollment records, where each record contains the following keys:
* student_key: The identifier of the student enrolled in the program and course.
* status: The student's course enrollment status.
* account_exists: A boolean indicating if the student has created an edx-platform user account.
* curriculum_uuid: The curriculum UUID of the enrollment record for the (student, program).
**Example**
{
"next": null,
"previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/enrollments/?curor=abcd",
"results": [
{
"student_key": "user-0", "status": "inactive",
"account_exists": False, "curriculum_uuid": "00000000-1111-2222-3333-444444444444"
},
{
"student_key": "user-1", "status": "inactive",
"account_exists": False, "curriculum_uuid": "00000001-1111-2222-3333-444444444444"
},
{
"student_key": "user-2", "status": "active",
"account_exists": True, "curriculum_uuid": "00000002-1111-2222-3333-444444444444"
},
{
"student_key": "user-3", "status": "active",
"account_exists": True, "curriculum_uuid": "00000003-1111-2222-3333-444444444444"
},
],
}
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
@verify_course_exists
@verify_program_exists
def get(self, request, program_uuid=None, course_id=None):
""" Defines the GET list endpoint for ProgramCourseEnrollment objects. """
course_key = CourseKey.from_string(course_id)
enrollments = use_read_replica_if_available(
ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=program_uuid, course_key=course_key
).select_related(
'program_enrollment'
)
)
paginated_enrollments = self.paginate_queryset(enrollments)
serializer = ProgramCourseEnrollmentListSerializer(paginated_enrollments, many=True)
return self.get_paginated_response(serializer.data)
@verify_program_exists
@verify_course_exists_and_in_program
def post(self, request, program_uuid=None, course_id=None):
"""
Enroll a list of students in a course in a program
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.enroll_learner_in_course
)
@verify_program_exists
@verify_course_exists_and_in_program
# pylint: disable=unused-argument
def patch(self, request, program_uuid=None, course_id=None):
"""
Modify the program course enrollments of a list of learners
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.modify_learner_enrollment_status
)
@verify_program_exists
@verify_course_exists_and_in_program
# pylint: disable=unused-argument
def put(self, request, program_uuid=None, course_id=None):
"""
Create or Update the program course enrollments of a list of learners
"""
return self.create_or_modify_enrollments(
request,
program_uuid,
self.create_or_update_learner_enrollment
)
def create_or_modify_enrollments(self, request, program_uuid, operation):
"""
Process a list of program course enrollment request objects
and create or modify enrollments based on method
"""
results = {}
seen_student_keys = set()
enrollments = []
if not isinstance(request.data, list):
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
if len(request.data) > MAX_ENROLLMENT_RECORDS:
return Response(
'enrollment limit 25', status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
try:
for enrollment_request in request.data:
error_status = self.check_enrollment_request(enrollment_request, seen_student_keys)
if error_status:
results[enrollment_request["student_key"]] = error_status
else:
enrollments.append(enrollment_request)
except KeyError: # student_key is not in enrollment_request
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
except TypeError: # enrollment_request isn't a dict
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
except ValidationError: # there was some other error raised by the serializer
return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
for enrollment in enrollments:
student_key = enrollment["student_key"]
if student_key in results and results[student_key] == CourseEnrollmentResponseStatuses.DUPLICATED:
continue
try:
program_enrollment = program_enrollments[student_key]
except KeyError:
results[student_key] = CourseEnrollmentResponseStatuses.NOT_IN_PROGRAM
else:
program_course_enrollment = program_enrollment.get_program_course_enrollment(self.course_key)
results[student_key] = operation(enrollment, program_enrollment, program_course_enrollment)
good_count = sum(1 for _, v in results.items() if v not in CourseEnrollmentResponseStatuses.ERROR_STATUSES)
if not good_count:
return Response(results, status.HTTP_422_UNPROCESSABLE_ENTITY)
if good_count != len(results):
return Response(results, status.HTTP_207_MULTI_STATUS)
else:
return Response(results)
def check_enrollment_request(self, enrollment, seen_student_keys):
"""
Checks that the given enrollment record is valid and hasn't been duplicated
"""
student_key = enrollment['student_key']
if student_key in seen_student_keys:
return CourseEnrollmentResponseStatuses.DUPLICATED
seen_student_keys.add(student_key)
enrollment_serializer = ProgramCourseEnrollmentRequestSerializer(data=enrollment)
try:
enrollment_serializer.is_valid(raise_exception=True)
except ValidationError as e:
if enrollment_serializer.has_invalid_status():
return CourseEnrollmentResponseStatuses.INVALID_STATUS
else:
raise e
def get_existing_program_enrollments(self, program_uuid, enrollments):
"""
Parameters:
- enrollments: A list of enrollment requests
Returns:
- Dictionary mapping all student keys in the enrollment requests
to that user's existing program enrollment in <self.program>
"""
external_user_keys = [e["student_key"] for e in enrollments]
existing_enrollments = ProgramEnrollment.objects.filter(
external_user_key__in=external_user_keys,
program_uuid=program_uuid,
)
existing_enrollments = existing_enrollments.prefetch_related('program_course_enrollments')
return {enrollment.external_user_key: enrollment for enrollment in existing_enrollments}
def enroll_learner_in_course(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to enroll the specified user into the course as a part of the
given program enrollment with the given status
Returns the actual status
"""
if program_course_enrollment:
return CourseEnrollmentResponseStatuses.CONFLICT
return ProgramCourseEnrollment.create_program_course_enrollment(
program_enrollment,
self.course_key,
enrollment_request['status']
)
# pylint: disable=unused-argument
def modify_learner_enrollment_status(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to modify the specified user's enrollment in the given course
in the given program
"""
if program_course_enrollment is None:
return CourseEnrollmentResponseStatuses.NOT_FOUND
return program_course_enrollment.change_status(enrollment_request['status'])
def create_or_update_learner_enrollment(self, enrollment_request, program_enrollment, program_course_enrollment):
"""
Attempts to create or update the specified user's enrollment in the given course
in the given program
"""
if program_course_enrollment is None:
# create the course enrollment
return ProgramCourseEnrollment.create_program_course_enrollment(
program_enrollment,
self.course_key,
enrollment_request['status']
)
else:
# Update course enrollment
return program_course_enrollment.change_status(enrollment_request['status'])
class ProgramCourseEnrollmentOverviewView(DeveloperErrorViewMixin, ProgramSpecificViewMixin, APIView):
"""
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/``
Accepts: [GET]
------------------------------------------------------------------------------------
GET
------------------------------------------------------------------------------------
**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:
** 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**
{
"course_runs": [
{
"course_run_id": "edX+AnimalsX+Aardvarks",
"display_name": "Astonishing Aardvarks",
"course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/course/",
"start_date": "2017-02-05T05:00:00Z",
"end_date": "2018-02-05T05:00:00Z",
"course_run_status": "completed"
"emails_enabled": true,
"due_dates": [
{
"name": "Introduction: What even is an aardvark?",
"url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/block-v1:edX+AnimalsX+Aardvarks+type@chapter+block@1414ffd5143b4b508f739b563ab468b7",
"date": "2017-05-01T05:00:00Z"
},
{
"name": "Quiz: Aardvark or Anteater?",
"url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/block-v1:edX+AnimalsX+Aardvarks+type@sequential+block@edx_introduction",
"date": "2017-03-05T00:00:00Z"
}
],
"micromasters_title": "Animals",
"certificate_download_url": "https://courses.edx.org/certificates/123"
},
{
"course_run_id": "edX+AnimalsX+Baboons",
"display_name": "Breathtaking Baboons",
"course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/course/",
"start_date": "2018-02-05T05:00:00Z",
"end_date": null,
"course_run_status": "in_progress"
"emails_enabled": false,
"due_dates": [],
"micromasters_title": "Animals",
"certificate_download_url": "https://courses.edx.org/certificates/123",
"resume_course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/jump_to/block-v1:edX+AnimalsX+Baboons+type@sequential+block@edx_introduction"
}
]
}
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated,)
@verify_program_exists
def get(self, request, program_uuid=None):
"""
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)
program = get_programs(uuid=program_uuid)
course_run_keys = [CourseKey.from_string(key) for key in course_run_keys_for_program(program)]
course_enrollments = CourseEnrollment.objects.filter(
user=user,
course_id__in=course_run_keys,
mode__in=[CourseMode.VERIFIED, CourseMode.MASTERS],
is_active=True,
)
overviews = CourseOverview.get_from_ids_if_exists(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_dict = {
'course_run_id': enrollment.course_id,
'display_name': overview.display_name_with_default,
'course_run_status': get_course_run_status(overview, certificate_info),
'course_run_url': get_course_run_url(request, enrollment.course_id),
'start_date': overview.start,
'end_date': overview.end,
'due_dates': get_due_dates(request, enrollment.course_id, user),
}
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.
"""
program_enrollments = ProgramEnrollment.objects.filter(
program_uuid=program_uuid,
user=user,
status='enrolled',
)
if not program_enrollments:
raise PermissionDenied
class ProgramCourseGradesView(
DeveloperErrorViewMixin,
ProgramCourseRunSpecificViewMixin,
PaginatedAPIView,
):
"""
A view for retrieving a paginated list of grades for all students enrolled
in a given courserun through a given program.
Path: ``/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/grades/``
Accepts: [GET]
For GET requests, the path can contain an optional `page_size?=N` query parameter.
The default page size is 100.
------------------------------------------------------------------------------------
GETs
------------------------------------------------------------------------------------
**Returns**
* 200: OK - Contains a paginated set of program courserun grades.
* 204: No Content - No grades to return
* 207: Mixed result - Contains mixed list of program courserun grades
and grade-fetching errors
* 422: All failed - Contains list of grade-fetching errors
* 401: The requesting user is not authenticated.
* 403: The requesting user lacks access for the given program/course.
* 404: The requested program or course does not exist.
**Response**
In the case of a 200/207/422 response code, the response will include a
paginated data set. The `results` section of the response consists of a
list of grade records, where each successfully loaded record contains:
* student_key: The identifier of the student enrolled in the program and course.
* letter_grade: A letter grade as defined in grading policy
(e.g. 'A' 'B' 'C' for 6.002x) or None.
* passed: Boolean representing whether the course has been
passed according to the course's grading policy.
* percent: A float representing the overall grade for the course.
and failed-to-load records contain:
* student_key
* error: error message from grades Exception
**Example**
207 Multi-Status
{
"next": null,
"previous": "http://example.com/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/grades/?cursor=abcd",
"results": [;
{
"student_key": "01709bffeae2807b6a7317",
"letter_grade": "Pass",
"percent": 0.95,
"passed": true
},
{
"student_key": "2cfe15e3380a52e7198237",
"error": "Timeout while calculating grade"
},
...
],
}
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
@verify_course_exists
@verify_program_exists
def get(self, request, program_uuid=None, course_id=None):
"""
Defines the GET list endpoint for ProgramCourseGrade objects.
"""
course_key = CourseKey.from_string(course_id)
grade_results = self._load_grade_results(program_uuid, course_key)
serializer = ProgramCourseGradeResultSerializer(grade_results, many=True)
response_code = self._calc_response_code(grade_results)
return self.get_paginated_response(serializer.data, status_code=response_code)
def _load_grade_results(self, program_uuid, course_key):
"""
Load grades (or grading errors) for a given program courserun.
Arguments:
program_uuid (str)
course_key (CourseKey)
Returns: list[ProgramCourseGradeResult|ProgramCourseGradeErrorResult]
"""
enrollments_qs = use_read_replica_if_available(
ProgramCourseEnrollment.objects.filter(
program_enrollment__program_uuid=program_uuid,
program_enrollment__user__isnull=False,
course_key=course_key,
).select_related(
'program_enrollment',
'program_enrollment__user',
)
)
paginated_enrollments = self.paginate_queryset(enrollments_qs)
if not paginated_enrollments:
return []
# Hint: `zip(*(list))` can be read as "unzip(list)"
enrollments, users = zip(*(
(enrollment, enrollment.program_enrollment.user)
for enrollment in paginated_enrollments
))
enrollment_grade_pairs = zip(
enrollments, self._iter_grades(course_key, list(users))
)
grade_results = [
(
ProgramCourseGradeResult(enrollment, grade)
if grade
else ProgramCourseGradeErrorResult(enrollment, exception)
)
for enrollment, (grade, exception) in enrollment_grade_pairs
]
return grade_results
@staticmethod
def _iter_grades(course_key, users):
"""
Load a user grades for a course, using bulk fetching for efficiency.
Arguments:
course_key (CourseKey)
users (list[User])
Returns: iterable[( CourseGradeBase|NoneType, Exception|NoneType )]
Iterable of pairs, in same order as `users`.
The first item in the pair is the grade, or None if loading the
grade failed.
The second item in the pair is an exception or None.
"""
prefetch_course_grades(course_key, users)
try:
grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
for user, course_grade, exception in grades_iter:
if not course_grade:
fmt = 'Failed to load course grade for user ID {} in {}: {}'
err_str = fmt.format(
user.id,
course_key,
text_type(exception) if exception else 'Unknown error'
)
logger.error(err_str)
yield course_grade, exception
finally:
clear_prefetched_course_grades(course_key)
@staticmethod
def _calc_response_code(grade_results):
"""
Returns HTTP status code appropriate for list of results,
which may be grades or errors.
Arguments:
enrollment_grade_results: list[ProgramCourseGradeResult]
Returns: int
* 200 for all success
* 207 for mixed result
* 422 for all failure
* 204 for empty
"""
if not grade_results:
return status.HTTP_204_NO_CONTENT
if all(result.is_error for result in grade_results):
return status.HTTP_422_UNPROCESSABLE_ENTITY
if any(result.is_error for result in grade_results):
return status.HTTP_207_MULTI_STATUS
return status.HTTP_200_OK
class EnrollmentDataResetView(APIView):
"""
Resets enrollments and users for a given organization and set of programs.
Note, this will remove ALL users from the input organization.
Path: ``/api/program_enrollments/v1/integration-reset/``
Accepts: [POST]
------------------------------------------------------------------------------------
POST
------------------------------------------------------------------------------------
**Returns**
* 200: OK - Enrollments and users sucessfully deleted
* 400: Bad Requeset - Program does not match the requested organization
* 401: Unauthorized - The requesting user is not authenticated.
* 404: Not Found - A requested program does not exist.
**Response**
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
@transaction.atomic
def post(self, request):
"""
Reset enrollment and user data for organization
"""
if not settings.FEATURES.get(ENABLE_ENROLLMENT_RESET_FLAG):
return Response('reset not enabled on this environment', status.HTTP_501_NOT_IMPLEMENTED)
try:
org_key = request.data['organization']
except KeyError:
return Response("missing required body content 'organization'", status.HTTP_400_BAD_REQUEST)
try:
organization = Organization.objects.get(short_name=org_key)
except Organization.DoesNotExist:
return Response('organization {} not found'.format(org_key), status.HTTP_404_NOT_FOUND)
try:
idp_slug = get_provider_slug(organization)
call_command('remove_social_auth_users', idp_slug, force=True)
except ProviderDoesNotExistException:
pass
programs = get_programs_for_organization(organization=organization.short_name)
if programs:
call_command('reset_enrollment_data', ','.join(programs), force=True)
return Response('success')