Files
edx-platform/lms/djangoapps/ccx/api/v0/views.py
2016-10-18 16:20:57 +05:00

792 lines
31 KiB
Python

""" API v0 views. """
import datetime
import json
import logging
import pytz
from django.contrib.auth.models import User
from django.db import transaction
from django.http import Http404
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ccx_keys.locator import CCXLocator
from courseware import courses
from xmodule.modulestore.django import SignalHandler
from edx_rest_framework_extensions.authentication import JwtAuthentication
from lms.djangoapps.instructor.enrollment import (
enroll_email,
get_email_params,
)
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.api import (
authentication,
permissions,
)
from student.models import CourseEnrollment
from student.roles import CourseCcxCoachRole
from lms.djangoapps.ccx.models import CcxFieldOverride, CustomCourseForEdX
from lms.djangoapps.ccx.overrides import (
override_field_for_ccx,
)
from lms.djangoapps.ccx.utils import (
add_master_course_staff_to_ccx,
assign_staff_role_to_ccx,
is_email,
get_course_chapters,
)
from .paginators import CCXAPIPagination
from .serializers import CCXCourseSerializer
log = logging.getLogger(__name__)
TODAY = datetime.datetime.today # for patching in tests
def get_valid_course(course_id, is_ccx=False, advanced_course_check=False):
"""
Helper function used to validate and get a course from a course_id string.
It works with both master and ccx course id.
Args:
course_id (str): A string representation of a Master or CCX Course ID.
is_ccx (bool): Flag to perform the right validation
advanced_course_check (bool): Flag to perform extra validations for the master course
Returns:
tuple: a tuple of course_object, course_key, error_code, http_status_code
"""
if course_id is None:
# the ccx detail view cannot call this function with a "None" value
# so the following `error_code` should be never used, but putting it
# to avoid a `NameError` exception in case this function will be used
# elsewhere in the future
error_code = 'course_id_not_provided'
if not is_ccx:
log.info('Master course ID not provided')
error_code = 'master_course_id_not_provided'
return None, None, error_code, status.HTTP_400_BAD_REQUEST
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
log.info('Course ID string "%s" is not valid', course_id)
return None, None, 'course_id_not_valid', status.HTTP_400_BAD_REQUEST
if not is_ccx:
try:
course_object = courses.get_course_by_id(course_key)
except Http404:
log.info('Master Course with ID "%s" not found', course_id)
return None, None, 'course_id_does_not_exist', status.HTTP_404_NOT_FOUND
if advanced_course_check:
if course_object.id.deprecated:
return None, None, 'deprecated_master_course_id', status.HTTP_400_BAD_REQUEST
if not course_object.enable_ccx:
return None, None, 'ccx_not_enabled_for_master_course', status.HTTP_403_FORBIDDEN
return course_object, course_key, None, None
else:
try:
ccx_id = course_key.ccx
except AttributeError:
log.info('Course ID string "%s" is not a valid CCX ID', course_id)
return None, None, 'course_id_not_valid_ccx_id', status.HTTP_400_BAD_REQUEST
# get the master_course key
master_course_key = course_key.to_course_locator()
try:
ccx_course = CustomCourseForEdX.objects.get(id=ccx_id, course_id=master_course_key)
return ccx_course, course_key, None, None
except CustomCourseForEdX.DoesNotExist:
log.info('CCX Course with ID "%s" not found', course_id)
return None, None, 'ccx_course_id_does_not_exist', status.HTTP_404_NOT_FOUND
def get_valid_input(request_data, ignore_missing=False):
"""
Helper function to validate the data sent as input and to
build field based errors.
Args:
request_data (OrderedDict): the request data object
ignore_missing (bool): whether or not to ignore fields
missing from the input data
Returns:
tuple: a tuple of two dictionaries for valid input and field errors
"""
valid_input = {}
field_errors = {}
mandatory_fields = ('coach_email', 'display_name', 'max_students_allowed',)
# checking first if all the fields are present and they are not null
if not ignore_missing:
for field in mandatory_fields:
if field not in request_data:
field_errors[field] = {'error_code': 'missing_field_{0}'.format(field)}
if field_errors:
return valid_input, field_errors
# at this point I can assume that if the fields are present,
# they must be validated, otherwise they can be skipped
coach_email = request_data.get('coach_email')
if coach_email is not None:
if is_email(coach_email):
valid_input['coach_email'] = coach_email
else:
field_errors['coach_email'] = {'error_code': 'invalid_coach_email'}
elif 'coach_email' in request_data:
field_errors['coach_email'] = {'error_code': 'null_field_coach_email'}
display_name = request_data.get('display_name')
if display_name is not None:
if not display_name:
field_errors['display_name'] = {'error_code': 'invalid_display_name'}
else:
valid_input['display_name'] = display_name
elif 'display_name' in request_data:
field_errors['display_name'] = {'error_code': 'null_field_display_name'}
max_students_allowed = request_data.get('max_students_allowed')
if max_students_allowed is not None:
try:
max_students_allowed = int(max_students_allowed)
valid_input['max_students_allowed'] = max_students_allowed
except (TypeError, ValueError):
field_errors['max_students_allowed'] = {'error_code': 'invalid_max_students_allowed'}
elif 'max_students_allowed' in request_data:
field_errors['max_students_allowed'] = {'error_code': 'null_field_max_students_allowed'}
course_modules = request_data.get('course_modules')
if course_modules is not None:
if isinstance(course_modules, list):
# de-duplicate list of modules
course_modules = list(set(course_modules))
for course_module_id in course_modules:
try:
UsageKey.from_string(course_module_id)
except InvalidKeyError:
field_errors['course_modules'] = {'error_code': 'invalid_course_module_keys'}
break
else:
valid_input['course_modules'] = course_modules
else:
field_errors['course_modules'] = {'error_code': 'invalid_course_module_list'}
elif 'course_modules' in request_data:
# case if the user actually passed null as input
valid_input['course_modules'] = None
return valid_input, field_errors
def valid_course_modules(course_module_list, master_course_key):
"""
Function to validate that each element in the course_module_list belongs
to the master course structure.
Args:
course_module_list (list): A list of strings representing Block Usage Keys
master_course_key (CourseKey): An object representing the master course key id
Returns:
bool: whether or not all the course module strings belong to the master course
"""
course_chapters = get_course_chapters(master_course_key)
if course_chapters is None:
return False
return set(course_module_list).intersection(set(course_chapters)) == set(course_module_list)
def make_user_coach(user, master_course_key):
"""
Makes an user coach on the master course.
This function is needed because an user cannot become a coach of the CCX if s/he is not
coach on the master course.
Args:
user (User): User object
master_course_key (CourseKey): Key locator object for the course
"""
coach_role_on_master_course = CourseCcxCoachRole(master_course_key)
coach_role_on_master_course.add_users(user)
class CCXListView(GenericAPIView):
"""
**Use Case**
* Get the list of CCX courses for a given master course.
* Creates a new CCX course for a given master course.
**Example Request**
GET /api/ccx/v0/ccx/?master_course_id={master_course_id}
POST /api/ccx/v0/ccx {
"master_course_id": "course-v1:Organization+EX101+RUN-FALL2099",
"display_name": "CCX example title",
"coach_email": "john@example.com",
"max_students_allowed": 123,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
}
**GET Parameters**
A GET request can include the following parameters.
* master_course_id: A string representation of a Master Course ID. Note that this must be properly
encoded by the client.
* page: Optional. An integer representing the pagination instance number.
* order_by: Optional. A string representing the field by which sort the results.
* sort_order: Optional. A string (either "asc" or "desc") indicating the desired order.
**POST Parameters**
A POST request can include the following parameters.
* master_course_id: A string representation of a Master Course ID.
* display_name: A string representing the CCX Course title.
* coach_email: A string representing the CCX owner email.
* max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course.
* course_modules: Optional. A list of course modules id keys.
**GET Response Values**
If the request for information about the course is successful, an HTTP 200 "OK" response
is returned with a collection of CCX courses for the specified master course.
The HTTP 200 response has the following values.
* results: a collection of CCX courses. Each CCX course contains the following values:
* ccx_course_id: A string representation of a CCX Course ID.
* display_name: A string representing the CCX Course title.
* coach_email: A string representing the CCX owner email.
* start: A string representing the start date for the CCX Course.
* due: A string representing the due date for the CCX Course.
* max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
* count: An integer representing the total number of records that matched the request parameters.
* next: A string representing the URL where to retrieve the next page of results. This can be `null`
in case the response contains the complete list of results.
* previous: A string representing the URL where to retrieve the previous page of results. This can be
`null` in case the response contains the first page of results.
**Example GET Response**
{
"count": 99,
"next": "https://openedx-ccx-api-instance.org/api/ccx/v0/ccx/?course_id=<course_id>&page=2",
"previous": null,
"results": {
{
"ccx_course_id": "ccx-v1:Organization+EX101+RUN-FALL2099+ccx@1",
"display_name": "CCX example title",
"coach_email": "john@example.com",
"start": "2019-01-01",
"due": "2019-06-01",
"max_students_allowed": 123,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
},
{ ... }
}
}
**POST Response Values**
If the request for the creation of a CCX Course is successful, an HTTP 201 "Created" response
is returned with the newly created CCX details.
The HTTP 201 response has the following values.
* ccx_course_id: A string representation of a CCX Course ID.
* display_name: A string representing the CCX Course title.
* coach_email: A string representing the CCX owner email.
* start: A string representing the start date for the CCX Course.
* due: A string representing the due date for the CCX Course.
* max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
**Example POST Response**
{
"ccx_course_id": "ccx-v1:Organization+EX101+RUN-FALL2099+ccx@1",
"display_name": "CCX example title",
"coach_email": "john@example.com",
"start": "2019-01-01",
"due": "2019-06-01",
"max_students_allowed": 123,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
}
"""
authentication_classes = (
JwtAuthentication,
authentication.OAuth2AuthenticationAllowInactiveUser,
authentication.SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated, permissions.IsMasterCourseStaffInstructor)
serializer_class = CCXCourseSerializer
pagination_class = CCXAPIPagination
def get(self, request):
"""
Gets a list of CCX Courses for a given Master Course.
Additional parameters are allowed for pagination purposes.
Args:
request (Request): Django request object.
Return:
A JSON serialized representation of a list of CCX courses.
"""
master_course_id = request.GET.get('master_course_id')
master_course_object, master_course_key, error_code, http_status = get_valid_course(master_course_id)
if master_course_object is None:
return Response(
status=http_status,
data={
'error_code': error_code
}
)
queryset = CustomCourseForEdX.objects.filter(course_id=master_course_key)
order_by_input = request.query_params.get('order_by')
sort_order_input = request.query_params.get('sort_order')
if order_by_input in ('id', 'display_name'):
sort_direction = ''
if sort_order_input == 'desc':
sort_direction = '-'
queryset = queryset.order_by('{0}{1}'.format(sort_direction, order_by_input))
page = self.paginate_queryset(queryset)
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
return response
def post(self, request):
"""
Creates a new CCX course for a given Master Course.
Args:
request (Request): Django request object.
Return:
A JSON serialized representation a newly created CCX course.
"""
master_course_id = request.data.get('master_course_id')
master_course_object, master_course_key, error_code, http_status = get_valid_course(
master_course_id,
advanced_course_check=True
)
if master_course_object is None:
return Response(
status=http_status,
data={
'error_code': error_code
}
)
# validating the rest of the input
valid_input, field_errors = get_valid_input(request.data)
if field_errors:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'field_errors': field_errors
}
)
try:
coach = User.objects.get(email=valid_input['coach_email'])
except User.DoesNotExist:
return Response(
status=status.HTTP_404_NOT_FOUND,
data={
'error_code': 'coach_user_does_not_exist'
}
)
if valid_input.get('course_modules'):
if not valid_course_modules(valid_input['course_modules'], master_course_key):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'error_code': 'course_module_list_not_belonging_to_master_course'
}
)
# prepare the course_modules to be stored in a json stringified field
course_modules_json = json.dumps(valid_input.get('course_modules'))
with transaction.atomic():
ccx_course_object = CustomCourseForEdX(
course_id=master_course_object.id,
coach=coach,
display_name=valid_input['display_name'],
structure_json=course_modules_json
)
ccx_course_object.save()
# Make sure start/due are overridden for entire course
start = TODAY().replace(tzinfo=pytz.UTC)
override_field_for_ccx(ccx_course_object, master_course_object, 'start', start)
override_field_for_ccx(ccx_course_object, master_course_object, 'due', None)
# Enforce a static limit for the maximum amount of students that can be enrolled
override_field_for_ccx(
ccx_course_object,
master_course_object,
'max_student_enrollments_allowed',
valid_input['max_students_allowed']
)
# Hide anything that can show up in the schedule
hidden = 'visible_to_staff_only'
for chapter in master_course_object.get_children():
override_field_for_ccx(ccx_course_object, chapter, hidden, True)
for sequential in chapter.get_children():
override_field_for_ccx(ccx_course_object, sequential, hidden, True)
for vertical in sequential.get_children():
override_field_for_ccx(ccx_course_object, vertical, hidden, True)
# make the coach user a coach on the master course
make_user_coach(coach, master_course_key)
# pull the ccx course key
ccx_course_key = CCXLocator.from_course_locator(master_course_object.id, unicode(ccx_course_object.id))
# enroll the coach in the newly created ccx
email_params = get_email_params(
master_course_object,
auto_enroll=True,
course_key=ccx_course_key,
display_name=ccx_course_object.display_name
)
enroll_email(
course_id=ccx_course_key,
student_email=coach.email,
auto_enroll=True,
email_students=True,
email_params=email_params,
)
# assign staff role for the coach to the newly created ccx
assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id)
# assign staff role for all the staff and instructor of the master course to the newly created ccx
add_master_course_staff_to_ccx(
master_course_object,
ccx_course_key,
ccx_course_object.display_name,
send_email=False
)
serializer = self.get_serializer(ccx_course_object)
# using CCX object as sender here.
responses = SignalHandler.course_published.send(
sender=ccx_course_object,
course_key=ccx_course_key
)
for rec, response in responses:
log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response)
return Response(
status=status.HTTP_201_CREATED,
data=serializer.data
)
class CCXDetailView(GenericAPIView):
"""
**Use Case**
* Get the details of CCX course.
* Modify a CCX course.
* Delete a CCX course.
**Example Request**
GET /api/ccx/v0/ccx/{ccx_course_id}
PATCH /api/ccx/v0/ccx/{ccx_course_id} {
"display_name": "CCX example title modified",
"coach_email": "joe@example.com",
"max_students_allowed": 111,
"course_modules" : [
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week1",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week4",
"block-v1:Organization+EX101+RUN-FALL2099+type@chapter+block@week5"
]
}
DELETE /api/ccx/v0/ccx/{ccx_course_id}
**GET and DELETE Parameters**
A GET or DELETE request must include the following parameter.
* ccx_course_id: A string representation of a CCX Course ID.
**PATCH Parameters**
A PATCH request can include the following parameters
* ccx_course_id: A string representation of a CCX Course ID.
* display_name: Optional. A string representing the CCX Course title.
* coach_email: Optional. A string representing the CCX owner email.
* max_students_allowed: Optional. An integer representing he maximum number of students that
can be enrolled in the CCX Course.
* course_modules: Optional. A list of course modules id keys.
**GET Response Values**
If the request for information about the CCX course is successful, an HTTP 200 "OK" response
is returned.
The HTTP 200 response has the following values.
* ccx_course_id: A string representation of a CCX Course ID.
* display_name: A string representing the CCX Course title.
* coach_email: A string representing the CCX owner email.
* start: A string representing the start date for the CCX Course.
* due: A string representing the due date for the CCX Course.
* max_students_allowed: An integer representing he maximum number of students that
can be enrolled in the CCX Course.
* course_modules: A list of course modules id keys.
**PATCH and DELETE Response Values**
If the request for modification or deletion of a CCX course is successful, an HTTP 204 "No Content"
response is returned.
"""
authentication_classes = (
JwtAuthentication,
authentication.OAuth2AuthenticationAllowInactiveUser,
authentication.SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated, permissions.IsCourseStaffInstructor)
serializer_class = CCXCourseSerializer
def get_object(self, course_id, is_ccx=False): # pylint: disable=arguments-differ
"""
Override the default get_object to allow a custom getter for the CCX
"""
course_object, course_key, error_code, http_status = get_valid_course(course_id, is_ccx)
self.check_object_permissions(self.request, course_object)
return course_object, course_key, error_code, http_status
def get(self, request, ccx_course_id=None):
"""
Gets a CCX Course information.
Args:
request (Request): Django request object.
ccx_course_id (string): URI element specifying the CCX course location.
Return:
A JSON serialized representation of the CCX course.
"""
ccx_course_object, _, error_code, http_status = self.get_object(ccx_course_id, is_ccx=True)
if ccx_course_object is None:
return Response(
status=http_status,
data={
'error_code': error_code
}
)
serializer = self.get_serializer(ccx_course_object)
return Response(serializer.data)
def delete(self, request, ccx_course_id=None): # pylint: disable=unused-argument
"""
Deletes a CCX course.
Args:
request (Request): Django request object.
ccx_course_id (string): URI element specifying the CCX course location.
"""
ccx_course_object, ccx_course_key, error_code, http_status = self.get_object(ccx_course_id, is_ccx=True)
if ccx_course_object is None:
return Response(
status=http_status,
data={
'error_code': error_code
}
)
ccx_course_overview = CourseOverview.get_from_id(ccx_course_key)
# clean everything up with a single transaction
with transaction.atomic():
CcxFieldOverride.objects.filter(ccx=ccx_course_object).delete()
# remove all users enrolled in the CCX from the CourseEnrollment model
CourseEnrollment.objects.filter(course_id=ccx_course_key).delete()
ccx_course_overview.delete()
ccx_course_object.delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
)
def patch(self, request, ccx_course_id=None):
"""
Modifies a CCX course.
Args:
request (Request): Django request object.
ccx_course_id (string): URI element specifying the CCX course location.
"""
ccx_course_object, ccx_course_key, error_code, http_status = self.get_object(ccx_course_id, is_ccx=True)
if ccx_course_object is None:
return Response(
status=http_status,
data={
'error_code': error_code
}
)
master_course_id = request.data.get('master_course_id')
if master_course_id is not None and unicode(ccx_course_object.course_id) != master_course_id:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
'error_code': 'master_course_id_change_not_allowed'
}
)
valid_input, field_errors = get_valid_input(request.data, ignore_missing=True)
if field_errors:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'field_errors': field_errors
}
)
# get the master course key and master course object
master_course_object, master_course_key, _, _ = get_valid_course(unicode(ccx_course_object.course_id))
with transaction.atomic():
# update the display name
if 'display_name' in valid_input:
ccx_course_object.display_name = valid_input['display_name']
# check if the coach has changed and in case update it
old_coach = None
if 'coach_email' in valid_input:
try:
coach = User.objects.get(email=valid_input['coach_email'])
except User.DoesNotExist:
return Response(
status=status.HTTP_404_NOT_FOUND,
data={
'error_code': 'coach_user_does_not_exist'
}
)
if ccx_course_object.coach.id != coach.id:
old_coach = ccx_course_object.coach
ccx_course_object.coach = coach
if 'course_modules' in valid_input:
if valid_input.get('course_modules'):
if not valid_course_modules(valid_input['course_modules'], master_course_key):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'error_code': 'course_module_list_not_belonging_to_master_course'
}
)
# course_modules to be stored in a json stringified field
ccx_course_object.structure_json = json.dumps(valid_input.get('course_modules'))
ccx_course_object.save()
# update the overridden field for the maximum amount of students
if 'max_students_allowed' in valid_input:
override_field_for_ccx(
ccx_course_object,
ccx_course_object.course,
'max_student_enrollments_allowed',
valid_input['max_students_allowed']
)
# if the coach has changed, update the permissions
if old_coach is not None:
# make the new ccx coach a coach on the master course
make_user_coach(coach, master_course_key)
# enroll the coach in the ccx
email_params = get_email_params(
master_course_object,
auto_enroll=True,
course_key=ccx_course_key,
display_name=ccx_course_object.display_name
)
enroll_email(
course_id=ccx_course_key,
student_email=coach.email,
auto_enroll=True,
email_students=True,
email_params=email_params,
)
# make the new coach staff on the CCX
assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id)
# using CCX object as sender here.
responses = SignalHandler.course_published.send(
sender=ccx_course_object,
course_key=ccx_course_key
)
for rec, response in responses:
log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response)
return Response(
status=status.HTTP_204_NO_CONTENT,
)