views.py 20.3 KB
Newer Older
1 2 3 4 5
"""
The Enrollment API Views should be simple, lean HTTP endpoints for API access. This should
consist primarily of authentication, request validation, and serialization.

"""
6
from ipware.ip import get_ip
7
from django.core.exceptions import ObjectDoesNotExist
Will Daly committed
8
from django.utils.decorators import method_decorator
9
from opaque_keys import InvalidKeyError
10
from course_modes.models import CourseMode
11
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
12
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated
13
from rest_framework import status
14 15
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
16
from rest_framework.views import APIView
17 18
from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api
19
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
Will Daly committed
20
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
21 22 23 24
from openedx.core.lib.api.authentication import (
    SessionAuthenticationAllowInactiveUser,
    OAuth2AuthenticationAllowInactiveUser,
)
25
from util.disable_rate_limit import can_disable_rate_limit
Will Daly committed
26 27 28 29 30
from enrollment import api
from enrollment.errors import (
    CourseNotFoundError, CourseEnrollmentError,
    CourseModeNotFoundError, CourseEnrollmentExistsError
)
31
from student.models import User
32 33


34 35 36 37 38
class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf):
    """Session authentication that allows inactive users and cross-domain requests. """
    pass


39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
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)


58 59 60 61 62 63 64 65
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)


66
@can_disable_rate_limit
67
class EnrollmentView(APIView, ApiKeyPermissionMixIn):
Mark Hoeber committed
68 69 70 71 72 73 74
    """
        **Use Cases**

            Get the user's enrollment status for a course.

        **Example Requests**:

75
            GET /api/enrollment/v1/enrollment/{username},{course_id}
Mark Hoeber committed
76 77 78 79 80 81 82 83 84 85 86 87 88

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

89 90 91 92 93 94 95
                * 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.
Mark Hoeber committed
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110

                * 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.
    """
111

112
    authentication_classes = OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser
113
    permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
114 115
    throttle_classes = EnrollmentUserThrottle,

116 117
    # Since the course about page on the marketing site uses this API to auto-enroll users,
    # we need to support cross-domain CSRF.
Will Daly committed
118
    @method_decorator(ensure_csrf_cookie_cross_domain)
119
    def get(self, request, course_id=None, username=None):
120 121 122 123 124 125 126 127 128 129
        """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.
130
            username (str): The username associated with this enrollment request.
131 132 133 134 135

        Return:
            A JSON serialized representation of the course enrollment.

        """
136 137
        username = username or request.user.username

138 139 140
        # TODO Implement proper permissions
        if request.user.username != username and not self.has_api_key_permissions(request) \
                and not request.user.is_superuser:
141 142 143
            # 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)
144

145
        try:
146
            return Response(api.get_enrollment(username, course_id))
147 148 149 150 151 152
        except CourseEnrollmentError:
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={
                    "message": (
                        u"An error occurred while retrieving enrollments for user "
153 154
                        u"'{username}' in course '{course_id}'"
                    ).format(username=username, course_id=course_id)
155 156 157 158
                }
            )


159
@can_disable_rate_limit
160
class EnrollmentCourseDetailView(APIView):
Mark Hoeber committed
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
    """
        **Use Cases**

            Get enrollment details for a course.

            **Note:** Getting enrollment details for a course does not require authentication.

        **Example Requests**:

            GET /api/enrollment/v1/course/{course_id}


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

179 180 181 182 183 184 185
                * 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.
Mark Hoeber committed
186 187 188 189 190 191 192 193 194 195 196 197 198

                * 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.
    """
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231

    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))
        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)
                }
            )


232
@can_disable_rate_limit
233
class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
Mark Hoeber committed
234 235
    """
        **Use Cases**
236

Mark Hoeber committed
237
            1. Get a list of all course enrollments for the currently logged in user.
238

Mark Hoeber committed
239
            2. Enroll the currently logged in user in a course.
240

241
               Currently a user can use this command only to enroll the user in "honor" mode.
242

Mark Hoeber committed
243
               If honor mode is not supported for the course, the request fails and returns the available modes.
244

245
               A server-to-server call can be used by this command to enroll a user in other modes, such as "verified"
246 247
               or "professional". If the mode is not supported for the course, the request will fail and return the
               available modes.
248

Mark Hoeber committed
249
        **Example Requests**:
250

Mark Hoeber committed
251 252
            GET /api/enrollment/v1/enrollment

253
            POST /api/enrollment/v1/enrollment{"mode": "honor", "course_details":{"course_id": "edX/DemoX/Demo_Course"}}
Mark Hoeber committed
254 255 256 257 258

        **Post Parameters**

            * user:  The user ID of the currently logged in user. Optional. You cannot use the command to enroll a different user.

259
            * mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from
260 261 262 263
              '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.
264

Mark Hoeber committed
265 266 267 268
            * course details: A collection that contains:

                * course_id: The unique identifier for the course.

269
            * email_opt_in: A Boolean indicating whether the user
270
              wishes to opt into email from the organization running this course. Optional.
271

Mark Hoeber committed
272 273 274 275 276 277 278 279 280 281 282 283 284 285
        **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.

286 287 288 289 290 291 292
                    * 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.
Mark Hoeber committed
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309

                    * 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.
    """

310
    authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
311
    permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
Mark Hoeber committed
312 313
    throttle_classes = EnrollmentUserThrottle,

Will Daly committed
314 315 316 317
    # 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)
Mark Hoeber committed
318
    def get(self, request):
319
        """Gets a list of all course enrollments for the currently logged in user."""
320 321
        username = request.GET.get('user', request.user.username)
        if request.user.username != username and not self.has_api_key_permissions(request):
322 323 324 325
            # 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:
326
            return Response(api.get_enrollments(username))
327 328 329 330 331
        except CourseEnrollmentError:
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={
                    "message": (
332 333
                        u"An error occurred while retrieving enrollments for user '{username}'"
                    ).format(username=username)
334 335 336 337
                }
            )

    def post(self, request):
338 339 340 341
        """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.
342
        """
343
        # Get the User, Course ID, and Mode from the request.
344 345
        username = request.DATA.get('user', request.user.username)
        course_id = request.DATA.get('course_details', {}).get('course_id')
346

347
        if not course_id:
348 349 350 351
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"message": u"Course ID must be specified to create a new enrollment."}
            )
352

353
        try:
354 355 356 357 358 359 360 361 362
            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)
                }
            )

363 364 365 366 367
        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.
368 369 370
        if not username:
            username = request.user.username
        if username != request.user.username and not has_api_key_permissions:
371 372 373 374 375 376 377 378 379 380 381 382 383 384
            # 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
                    )
                }
            )

385 386 387 388 389 390 391 392 393 394 395
        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)
                }
            )

396 397 398 399
        # 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(
400
            course_id, user=user, ip_address=get_ip(request), url=request.path)
401 402 403 404 405 406 407
        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),
408
                    "user_message_url": request.build_absolute_uri(redirect_url)
409 410 411 412
                }
            )

        try:
413 414 415 416 417 418 419 420 421 422
            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)
                    }
                )

423
            enrollment = api.get_enrollment(username, unicode(course_id))
424
            if has_api_key_permissions and enrollment and enrollment['mode'] != mode:
425
                response = api.update_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
426
            else:
427
                # Will reactivate inactive enrollments.
428
                response = api.add_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
429

430 431 432
            email_opt_in = request.DATA.get('email_opt_in', None)
            if email_opt_in is not None:
                org = course_id.org
433
                update_email_opt_in(request.user, org, email_opt_in)
434

435
            return Response(response)
436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
        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)
                }
            )
452 453
        except CourseEnrollmentExistsError as error:
            return Response(data=error.enrollment)
454 455 456 457 458 459
        except CourseEnrollmentError:
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={
                    "message": (
                        u"An error occurred while creating the new course enrollment for user "
460 461
                        u"'{username}' in course '{course_id}'"
                    ).format(username=username, course_id=course_id)
462 463
                }
            )