Files
edx-platform/common/djangoapps/enrollment/views.py
2015-06-10 16:56:21 -04:00

488 lines
22 KiB
Python

"""
The Enrollment API Views should be simple, lean HTTP endpoints for API access. This should
consist primarily of authentication, request validation, and serialization.
"""
import logging
from ipware.ip import get_ip
from django.core.exceptions import ObjectDoesNotExist
from django.utils.decorators import method_decorator
from opaque_keys import InvalidKeyError
from course_modes.models import CourseMode
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated
from rest_framework import status
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView
from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from util.disable_rate_limit import can_disable_rate_limit
from enrollment import api
from enrollment.errors import (
CourseNotFoundError, CourseEnrollmentError,
CourseModeNotFoundError, CourseEnrollmentExistsError
)
from student.models import User
log = logging.getLogger(__name__)
class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf):
"""Session authentication that allows inactive users and cross-domain requests. """
pass
class ApiKeyPermissionMixIn(object):
"""
This mixin is used to provide a convenience function for doing individual permission checks
for the presence of API keys.
"""
def has_api_key_permissions(self, request):
"""
Checks to see if the request was made by a server with an API key.
Args:
request (Request): the request being made into the view
Return:
True if the request has been made with a valid API key
False otherwise
"""
return ApiKeyHeaderPermission().has_permission(request, self)
class EnrollmentUserThrottle(UserRateThrottle, ApiKeyPermissionMixIn):
"""Limit the number of requests users can make to the enrollment API."""
rate = '40/minute'
def allow_request(self, request, view):
return self.has_api_key_permissions(request) or super(EnrollmentUserThrottle, self).allow_request(request, view)
@can_disable_rate_limit
class EnrollmentView(APIView, ApiKeyPermissionMixIn):
"""
**Use Cases**
Get the user's enrollment status for a course.
**Example Requests**:
GET /api/enrollment/v1/enrollment/{username},{course_id}
**Response Values**
* created: The date the user account was created.
* mode: The enrollment mode of the user in this course.
* is_active: Whether the enrollment is currently active.
* course_details: A collection that includes:
* course_id: The unique identifier for the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes:
* slug: The short name for the enrollment mode.
* name: The full name of the enrollment mode.
* min_price: The minimum price for which a user can enroll in this mode.
* suggested_prices: A list of suggested prices for this enrollment mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which users cannot enroll in the course in this mode.
* description: A description of this mode.
* invite_only: Whether students must be invited to enroll in the course; true or false.
* user: The ID of the user.
"""
authentication_classes = OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser
permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
throttle_classes = EnrollmentUserThrottle,
# Since the course about page on the marketing site uses this API to auto-enroll users,
# we need to support cross-domain CSRF.
@method_decorator(ensure_csrf_cookie_cross_domain)
def get(self, request, course_id=None, username=None):
"""Create, read, or update enrollment information for a user.
HTTP Endpoint for all CRUD operations for a user course enrollment. Allows creation, reading, and
updates of the current enrollment for a particular course.
Args:
request (Request): To get current course enrollment information, a GET request will return
information for the current user and the specified course.
course_id (str): URI element specifying the course location. Enrollment information will be
returned, created, or updated for this particular course.
username (str): The username associated with this enrollment request.
Return:
A JSON serialized representation of the course enrollment.
"""
username = username or request.user.username
# TODO Implement proper permissions
if request.user.username != username and not self.has_api_key_permissions(request) \
and not request.user.is_superuser:
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
try:
return Response(api.get_enrollment(username, course_id))
except CourseEnrollmentError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": (
u"An error occurred while retrieving enrollments for user "
u"'{username}' in course '{course_id}'"
).format(username=username, course_id=course_id)
}
)
@can_disable_rate_limit
class EnrollmentCourseDetailView(APIView):
"""
**Use Cases**
Get enrollment details for a course.
Response values include the course schedule and enrollment modes supported by the course.
Use the parameter include_expired=1 to include expired enrollment modes in the response.
**Note:** Getting enrollment details for a course does not require authentication.
**Example Requests**:
GET /api/enrollment/v1/course/{course_id}
GET /api/v1/enrollment/course/{course_id}?include_expired=1
**Response Values**
A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains:
* course_id: The unique identifier of the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array containing details about the enrollment modes supported for the course.
If the request uses the parameter include_expired=1, the array also includes expired enrollment modes.
Each enrollment mode collection includes:
* slug: The short name for the enrollment mode.
* name: The full name of the enrollment mode.
* min_price: The minimum price for which a user can enroll in this mode.
* suggested_prices: A list of suggested prices for this enrollment mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which users cannot enroll in the course in this mode.
* description: A description of this mode.
* invite_only: Whether students must be invited to enroll in the course; true or false.
"""
authentication_classes = []
permission_classes = []
throttle_classes = EnrollmentUserThrottle,
def get(self, request, course_id=None):
"""Read enrollment information for a particular course.
HTTP Endpoint for retrieving course level enrollment information.
Args:
request (Request): To get current course enrollment information, a GET request will return
information for the specified course.
course_id (str): URI element specifying the course location. Enrollment information will be
returned.
Return:
A JSON serialized representation of the course enrollment details.
"""
try:
return Response(api.get_course_enrollment_details(course_id, bool(request.GET.get('include_expired', ''))))
except CourseNotFoundError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": (
u"No course found for course ID '{course_id}'"
).format(course_id=course_id)
}
)
@can_disable_rate_limit
class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
"""
**Use Cases**
1. Get a list of all course enrollments for the currently logged in user.
2. Enroll the currently logged in user in a course.
Currently a user can use this command only to enroll the user in "honor" mode.
If honor mode is not supported for the course, the request fails and returns the available modes.
A server-to-server call can be used by this command to enroll a user in other modes, such as "verified"
or "professional". If the mode is not supported for the course, the request will fail and return the
available modes.
**Example Requests**:
GET /api/enrollment/v1/enrollment
POST /api/enrollment/v1/enrollment{"mode": "honor", "course_details":{"course_id": "edX/DemoX/Demo_Course"}}
**Post Parameters**
* user: The user ID of the currently logged in user. Optional. You cannot use the command to enroll a different user.
* mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from
'honor'. Only server-to-server requests can enroll with other modes. Optional.
* is_active: A Boolean indicating whether the enrollment is active. Only server-to-server requests are
allowed to deactivate an enrollment. Optional.
* course details: A collection that contains:
* course_id: The unique identifier for the course.
* email_opt_in: A Boolean indicating whether the user
wishes to opt into email from the organization running this course. Optional.
**Response Values**
A collection of course enrollments for the user, or for the newly created enrollment. Each course enrollment contains:
* created: The date the user account was created.
* mode: The enrollment mode of the user in this course.
* is_active: Whether the enrollment is currently active.
* course_details: A collection that includes:
* course_id: The unique identifier for the course.
* enrollment_start: The date and time that users can begin enrolling in the course. If null, enrollment opens immediately when the course is created.
* enrollment_end: The date and time after which users cannot enroll for the course. If null, the enrollment period never ends.
* course_start: The date and time at which the course opens. If null, the course opens immediately when created.
* course_end: The date and time at which the course closes. If null, the course never ends.
* course_modes: An array of data about the enrollment modes supported for the course. Each enrollment mode collection includes:
* slug: The short name for the enrollment mode.
* name: The full name of the enrollment mode.
* min_price: The minimum price for which a user can enroll in this mode.
* suggested_prices: A list of suggested prices for this enrollment mode.
* currency: The currency of the listed prices.
* expiration_datetime: The date and time after which users cannot enroll in the course in this mode.
* description: A description of this mode.
* invite_only: Whether students must be invited to enroll in the course; true or false.
* user: The ID of the user.
"""
authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
throttle_classes = EnrollmentUserThrottle,
# Since the course about page on the marketing site
# uses this API to auto-enroll users, we need to support
# cross-domain CSRF.
@method_decorator(ensure_csrf_cookie_cross_domain)
def get(self, request):
"""Gets a list of all course enrollments for the currently logged in user."""
username = request.GET.get('user', request.user.username)
if request.user.username != username and not self.has_api_key_permissions(request):
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
try:
return Response(api.get_enrollments(username))
except CourseEnrollmentError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": (
u"An error occurred while retrieving enrollments for user '{username}'"
).format(username=username)
}
)
def post(self, request):
"""Enrolls the currently logged-in user in a course.
Server-to-server calls may deactivate or modify the mode of existing enrollments. All other requests
go through `add_enrollment()`, which allows creation of new and reactivation of old enrollments.
"""
# Get the User, Course ID, and Mode from the request.
username = request.DATA.get('user', request.user.username)
course_id = request.DATA.get('course_details', {}).get('course_id')
if not course_id:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"message": u"Course ID must be specified to create a new enrollment."}
)
try:
course_id = CourseKey.from_string(course_id)
except InvalidKeyError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": u"No course '{course_id}' found for enrollment".format(course_id=course_id)
}
)
mode = request.DATA.get('mode', CourseMode.HONOR)
has_api_key_permissions = self.has_api_key_permissions(request)
# Check that the user specified is either the same user, or this is a server-to-server request.
if not username:
username = request.user.username
if username != request.user.username and not has_api_key_permissions:
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
if mode != CourseMode.HONOR and not has_api_key_permissions:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"message": u"User does not have permission to create enrollment with mode [{mode}].".format(
mode=mode
)
}
)
try:
# Lookup the user, instead of using request.user, since request.user may not match the username POSTed.
user = User.objects.get(username=username)
except ObjectDoesNotExist:
return Response(
status=status.HTTP_406_NOT_ACCEPTABLE,
data={
'message': u'The user {} does not exist.'.format(username)
}
)
# Check whether any country access rules block the user from enrollment
# We do this at the view level (rather than the Python API level)
# because this check requires information about the HTTP request.
redirect_url = embargo_api.redirect_if_blocked(
course_id, user=user, ip_address=get_ip(request), url=request.path)
if redirect_url:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"message": (
u"Users from this location cannot access the course '{course_id}'."
).format(course_id=course_id),
"user_message_url": request.build_absolute_uri(redirect_url)
}
)
try:
is_active = request.DATA.get('is_active')
# Check if the requested activation status is None or a Boolean
if is_active is not None and not isinstance(is_active, bool):
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
'message': (u"'{value}' is an invalid enrollment activation status.").format(value=is_active)
}
)
enrollment = api.get_enrollment(username, unicode(course_id))
mode_changed = enrollment and mode is not None and enrollment['mode'] != mode
active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active
if has_api_key_permissions and (mode_changed or active_changed):
if mode_changed and active_changed and not is_active:
# if the requester wanted to deactivate but specified the wrong mode, fail
# the request (on the assumption that the requester had outdated information
# about the currently active enrollment).
msg = u"Enrollment mode mismatch: active mode={}, requested mode={}. Won't deactivate.".format(
enrollment["mode"], mode
)
log.warning(msg)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg})
response = api.update_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
else:
# Will reactivate inactive enrollments.
response = api.add_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
email_opt_in = request.DATA.get('email_opt_in', None)
if email_opt_in is not None:
org = course_id.org
update_email_opt_in(request.user, org, email_opt_in)
return Response(response)
except CourseModeNotFoundError as error:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": (
u"The course mode '{mode}' is not available for course '{course_id}'."
).format(mode="honor", course_id=course_id),
"course_details": error.data
})
except CourseNotFoundError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": u"No course '{course_id}' found for enrollment".format(course_id=course_id)
}
)
except CourseEnrollmentExistsError as error:
return Response(data=error.enrollment)
except CourseEnrollmentError:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": (
u"An error occurred while creating the new course enrollment for user "
u"'{username}' in course '{course_id}'"
).format(username=username, course_id=course_id)
}
)