view_utils.py 9.31 KB
Newer Older
Greg Price committed
1 2 3
"""
Utilities related to API views
"""
4
import functools
5
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
Greg Price committed
6
from django.http import Http404
7
from django.utils.translation import ugettext as _
Greg Price committed
8

9
from rest_framework import status, response
Greg Price committed
10
from rest_framework.exceptions import APIException
11
from rest_framework.permissions import IsAuthenticated
12
from rest_framework.request import clone_request
Greg Price committed
13
from rest_framework.response import Response
14 15
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
from rest_framework.generics import GenericAPIView
Greg Price committed
16

17
from lms.djangoapps.courseware.courses import get_course_with_access
18
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
19 20 21 22 23 24 25
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore

from openedx.core.lib.api.authentication import (
    SessionAuthenticationAllowInactiveUser,
    OAuth2AuthenticationAllowInactiveUser,
)
26
from openedx.core.lib.api.permissions import IsUserInUrl
27

Greg Price committed
28 29 30 31 32 33 34 35 36 37 38 39 40

class DeveloperErrorViewMixin(object):
    """
    A view mixin to handle common error cases other than validation failure
    (auth failure, method not allowed, etc.) by generating an error response
    conforming to our API conventions with a developer message.
    """
    def make_error_response(self, status_code, developer_message):
        """
        Build an error response with the given status code and developer_message
        """
        return Response({"developer_message": developer_message}, status=status_code)

41 42 43 44 45 46 47
    def make_validation_error_response(self, validation_error):
        """
        Build a 400 error response from the given ValidationError
        """
        if hasattr(validation_error, "message_dict"):
            response_obj = {}
            message_dict = dict(validation_error.message_dict)
48 49 50 51 52
            # Extract both Django form and DRF serializer non-field errors
            non_field_error_list = (
                message_dict.pop(NON_FIELD_ERRORS, []) +
                message_dict.pop("non_field_errors", [])
            )
53 54 55 56
            if non_field_error_list:
                response_obj["developer_message"] = non_field_error_list[0]
            if message_dict:
                response_obj["field_errors"] = {
57
                    field: {"developer_message": message_dict[field][0]}
58 59 60 61 62 63
                    for field in message_dict
                }
            return Response(response_obj, status=400)
        else:
            return self.make_error_response(400, validation_error.messages[0])

Greg Price committed
64 65 66 67
    def handle_exception(self, exc):
        if isinstance(exc, APIException):
            return self.make_error_response(exc.status_code, exc.detail)
        elif isinstance(exc, Http404):
68
            return self.make_error_response(404, exc.message or "Not found.")
69 70
        elif isinstance(exc, ValidationError):
            return self.make_validation_error_response(exc)
Greg Price committed
71 72
        else:
            raise
73 74


75 76 77 78 79 80
class ExpandableFieldViewMixin(object):
    """A view mixin to add expansion information to the serializer context for later use by an ExpandableField."""

    def get_serializer_context(self):
        """Adds expand information from query parameters to the serializer context to support expandable fields."""
        result = super(ExpandableFieldViewMixin, self).get_serializer_context()
81
        result['expand'] = [x for x in self.request.query_params.get('expand', '').split(',') if x]
82 83 84
        return result


85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
def view_course_access(depth=0, access_action='load', check_for_milestones=False):
    """
    Method decorator for an API endpoint that verifies the user has access to the course.
    """
    def _decorator(func):
        """Outer method decorator."""
        @functools.wraps(func)
        def _wrapper(self, request, *args, **kwargs):
            """
            Expects kwargs to contain 'course_id'.
            Passes the course descriptor to the given decorated function.
            Raises 404 if access to course is disallowed.
            """
            course_id = CourseKey.from_string(kwargs.pop('course_id'))
            with modulestore().bulk_operations(course_id):
                try:
                    course = get_course_with_access(
                        request.user,
                        access_action,
                        course_id,
105 106
                        depth=depth,
                        check_if_enrolled=True,
107
                    )
108 109
                except CoursewareAccessException as error:
                    return response.Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND)
110 111 112 113 114
                return func(self, request, course=course, *args, **kwargs)
        return _wrapper
    return _decorator


115 116 117 118 119 120 121 122 123 124 125 126 127
class IsAuthenticatedAndNotAnonymous(IsAuthenticated):
    """
    Allows access only to authenticated and non-anonymous users.
    """
    def has_permission(self, request, view):
        return (
            # verify the user is authenticated and
            super(IsAuthenticatedAndNotAnonymous, self).has_permission(request, view) and
            # not anonymous
            not request.user.is_anonymous()
        )


128 129 130 131 132 133 134 135 136 137 138 139 140
def view_auth_classes(is_user=False):
    """
    Function and class decorator that abstracts the authentication and permission checks for api views.
    """
    def _decorator(func_or_class):
        """
        Requires either OAuth2 or Session-based authentication.
        If is_user is True, also requires username in URL matches the request user.
        """
        func_or_class.authentication_classes = (
            OAuth2AuthenticationAllowInactiveUser,
            SessionAuthenticationAllowInactiveUser
        )
141
        func_or_class.permission_classes = (IsAuthenticatedAndNotAnonymous,)
142 143 144 145
        if is_user:
            func_or_class.permission_classes += (IsUserInUrl,)
        return func_or_class
    return _decorator
146 147 148 149


def add_serializer_errors(serializer, data, field_errors):
    """Adds errors from serializer validation to field_errors. data is the original data to deserialize."""
150 151
    if not serializer.is_valid():
        errors = serializer.errors
152 153 154 155 156 157 158 159 160 161
        for key, error in errors.iteritems():
            field_errors[key] = {
                'developer_message': u"Value '{field_value}' is not valid for field '{field_name}': {error}".format(
                    field_value=data.get(key, ''), field_name=key, error=error
                ),
                'user_message': _(u"This value is invalid."),
            }
    return field_errors


162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
def build_api_error(message, **kwargs):
    """Build an error dict corresponding to edX API conventions.

    Args:
        message (string): The string to use for developer and user messages.
            The user message will be translated, but for this to work message
            must have already been scraped. ugettext_noop is useful for this.
        **kwargs: format parameters for message
    """
    return {
        'developer_message': message.format(**kwargs),
        'user_message': _(message).format(**kwargs),  # pylint: disable=translation-of-non-string
    }


177 178 179 180 181 182 183 184 185 186 187
class RetrievePatchAPIView(RetrieveModelMixin, UpdateModelMixin, GenericAPIView):
    """Concrete view for retrieving and updating a model instance.

    Like DRF's RetrieveUpdateAPIView, but without PUT and with automatic validation errors in the edX format.
    """
    def get(self, request, *args, **kwargs):
        """Retrieves the specified resource using the RetrieveModelMixin."""
        return self.retrieve(request, *args, **kwargs)

    def patch(self, request, *args, **kwargs):
        """Checks for validation errors, then updates the model using the UpdateModelMixin."""
188
        field_errors = self._validate_patch(request.data)
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
        if field_errors:
            return Response({'field_errors': field_errors}, status=status.HTTP_400_BAD_REQUEST)
        return self.partial_update(request, *args, **kwargs)

    def _validate_patch(self, patch):
        """Validates a JSON merge patch. Captures DRF serializer errors and converts them to edX's standard format."""
        field_errors = {}
        serializer = self.get_serializer(self.get_object_or_none(), data=patch, partial=True)
        fields = self.get_serializer().get_fields()  # pylint: disable=maybe-no-member

        for key in patch:
            if key in fields and fields[key].read_only:
                field_errors[key] = {
                    'developer_message': "This field is not editable",
                    'user_message': _("This field is not editable"),
                }

        add_serializer_errors(serializer, patch, field_errors)

        return field_errors
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228

    def get_object_or_none(self):
        """
        Retrieve an object or return None if the object can't be found.

        NOTE: This replaces functionality that was removed in Django Rest Framework v3.1.
        """
        try:
            return self.get_object()
        except Http404:
            if self.request.method == 'PUT':
                # For PUT-as-create operation, we need to ensure that we have
                # relevant permissions, as if this was a POST request.  This
                # will either raise a PermissionDenied exception, or simply
                # return None.
                self.check_permissions(clone_request(self.request, 'POST'))
            else:
                # PATCH requests where the object does not exist should still
                # return a 404 response.
                raise