diff options
Diffstat (limited to 'djangorestframework/mixins.py')
| -rw-r--r-- | djangorestframework/mixins.py | 591 |
1 files changed, 591 insertions, 0 deletions
diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py new file mode 100644 index 00000000..11e3bb38 --- /dev/null +++ b/djangorestframework/mixins.py @@ -0,0 +1,591 @@ +""" +The :mod:`mixins` module provides a set of reusable `mixin` +classes that can be added to a `View`. +""" + +from django.contrib.auth.models import AnonymousUser +from django.db.models.query import QuerySet +from django.db.models.fields.related import RelatedField +from django.http import HttpResponse +from django.http.multipartparser import LimitBytes + +from djangorestframework import status +from djangorestframework.parsers import FormParser, MultiPartParser +from djangorestframework.resources import Resource, FormResource, ModelResource +from djangorestframework.response import Response, ErrorResponse +from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX +from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence + +from decimal import Decimal +import re +from StringIO import StringIO + + +__all__ = ( + # Base behavior mixins + 'RequestMixin', + 'ResponseMixin', + 'AuthMixin', + 'ResourceMixin', + # Reverse URL lookup behavior + 'InstanceMixin', + # Model behavior mixins + 'ReadModelMixin', + 'CreateModelMixin', + 'UpdateModelMixin', + 'DeleteModelMixin', + 'ListModelMixin' +) + + +########## Request Mixin ########## + +class RequestMixin(object): + """ + `Mixin` class to provide request parsing behavior. + """ + + _USE_FORM_OVERLOADING = True + _METHOD_PARAM = '_method' + _CONTENTTYPE_PARAM = '_content_type' + _CONTENT_PARAM = '_content' + + """ + The set of request parsers that the view can handle. + + Should be a tuple/list of classes as described in the :mod:`parsers` module. + """ + parsers = () + + @property + def method(self): + """ + Returns the HTTP method. + + This should be used instead of just reading :const:`request.method`, as it allows the `method` + to be overridden by using a hidden `form` field on a form POST request. + """ + if not hasattr(self, '_method'): + self._load_method_and_content_type() + return self._method + + + @property + def content_type(self): + """ + Returns the content type header. + + This should be used instead of ``request.META.get('HTTP_CONTENT_TYPE')``, + as it allows the content type to be overridden by using a hidden form + field on a form POST request. + """ + if not hasattr(self, '_content_type'): + self._load_method_and_content_type() + return self._content_type + + + @property + def DATA(self): + """ + Parses the request body and returns the data. + + Similar to ``request.POST``, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). + """ + if not hasattr(self, '_data'): + self._load_data_and_files() + return self._data + + + @property + def FILES(self): + """ + Parses the request body and returns the files. + Similar to ``request.FILES``, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). + """ + if not hasattr(self, '_files'): + self._load_data_and_files() + return self._files + + + def _load_data_and_files(self): + """ + Parse the request content into self.DATA and self.FILES. + """ + if not hasattr(self, '_content_type'): + self._load_method_and_content_type() + + if not hasattr(self, '_data'): + (self._data, self._files) = self._parse(self._get_stream(), self._content_type) + + + def _load_method_and_content_type(self): + """ + Set the method and content_type, and then check if they've been overridden. + """ + self._method = self.request.method + self._content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) + self._perform_form_overloading() + + + def _get_stream(self): + """ + Returns an object that may be used to stream the request content. + """ + request = self.request + + try: + content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) + except (ValueError, TypeError): + content_length = 0 + + # TODO: Add 1.3's LimitedStream to compat and use that. + # NOTE: Currently only supports parsing request body as a stream with 1.3 + if content_length == 0: + return None + elif hasattr(request, 'read'): + return request + return StringIO(request.raw_post_data) + + + def _perform_form_overloading(self): + """ + If this is a form POST request, then we need to check if the method and content/content_type have been + overridden by setting them in hidden form fields or not. + """ + + # We only need to use form overloading on form POST requests. + if not self._USE_FORM_OVERLOADING or self._method != 'POST' or not is_form_media_type(self._content_type): + return + + # At this point we're committed to parsing the request as form data. + self._data = data = self.request.POST + self._files = self.request.FILES + + # Method overloading - change the method and remove the param from the content. + if self._METHOD_PARAM in data: + self._method = data[self._METHOD_PARAM].upper() + + # Content overloading - modify the content type, and re-parse. + if self._CONTENT_PARAM in data and self._CONTENTTYPE_PARAM in data: + self._content_type = data[self._CONTENTTYPE_PARAM] + stream = StringIO(data[self._CONTENT_PARAM]) + (self._data, self._files) = self._parse(stream, self._content_type) + + + def _parse(self, stream, content_type): + """ + Parse the request content. + + May raise a 415 ErrorResponse (Unsupported Media Type), or a 400 ErrorResponse (Bad Request). + """ + if stream is None or content_type is None: + return (None, None) + + parsers = as_tuple(self.parsers) + + for parser_cls in parsers: + parser = parser_cls(self) + if parser.can_handle_request(content_type): + return parser.parse(stream) + + raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + {'error': 'Unsupported media type in request \'%s\'.' % + content_type}) + + + @property + def _parsed_media_types(self): + """ + Return a list of all the media types that this view can parse. + """ + return [parser.media_type for parser in self.parsers] + + + @property + def _default_parser(self): + """ + Return the view's default parser class. + """ + return self.parsers[0] + + + +########## ResponseMixin ########## + +class ResponseMixin(object): + """ + Adds behavior for pluggable `Renderers` to a :class:`views.View` class. + + Default behavior is to use standard HTTP Accept header content negotiation. + Also supports overriding the content type by specifying an ``_accept=`` parameter in the URL. + Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. + """ + + _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params + _IGNORE_IE_ACCEPT_HEADER = True + + """ + The set of response renderers that the view can handle. + + Should be a tuple/list of classes as described in the :mod:`renderers` module. + """ + renderers = () + + + # TODO: wrap this behavior around dispatch(), ensuring it works + # out of the box with existing Django classes that use render_to_response. + def render(self, response): + """ + Takes a :obj:`Response` object and returns an :obj:`HttpResponse`. + """ + self.response = response + + try: + renderer, media_type = self._determine_renderer(self.request) + except ErrorResponse, exc: + renderer = self._default_renderer(self) + media_type = renderer.media_type + response = exc.response + + # Set the media type of the response + # Note that the renderer *could* override it in .render() if required. + response.media_type = renderer.media_type + + # Serialize the response content + if response.has_content_body: + content = renderer.render(response.cleaned_content, media_type) + else: + content = renderer.render() + + # Build the HTTP Response + resp = HttpResponse(content, mimetype=response.media_type, status=response.status) + for (key, val) in response.headers.items(): + resp[key] = val + + return resp + + + def _determine_renderer(self, request): + """ + Determines the appropriate renderer for the output, given the client's 'Accept' header, + and the :attr:`renderers` set on this class. + + Returns a 2-tuple of `(renderer, media_type)` + + See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + """ + + if self._ACCEPT_QUERY_PARAM and request.GET.get(self._ACCEPT_QUERY_PARAM, None): + # Use _accept parameter override + accept_list = [request.GET.get(self._ACCEPT_QUERY_PARAM)] + elif (self._IGNORE_IE_ACCEPT_HEADER and + request.META.has_key('HTTP_USER_AGENT') and + MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): + # Ignore MSIE's broken accept behavior and do something sensible instead + accept_list = ['text/html', '*/*'] + elif request.META.has_key('HTTP_ACCEPT'): + # Use standard HTTP Accept negotiation + accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] + else: + # No accept header specified + return (self._default_renderer(self), self._default_renderer.media_type) + + # Check the acceptable media types against each renderer, + # attempting more specific media types first + # NB. The inner loop here isn't as bad as it first looks :) + # Worst case is we're looping over len(accept_list) * len(self.renderers) + renderers = [renderer_cls(self) for renderer_cls in self.renderers] + + for media_type_lst in order_by_precedence(accept_list): + for renderer in renderers: + for media_type in media_type_lst: + if renderer.can_handle_response(media_type): + return renderer, media_type + + # No acceptable renderers were found + raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, + {'detail': 'Could not satisfy the client\'s Accept header', + 'available_types': self._rendered_media_types}) + + + @property + def _rendered_media_types(self): + """ + Return an list of all the media types that this view can render. + """ + return [renderer.media_type for renderer in self.renderers] + + @property + def _default_renderer(self): + """ + Return the view's default renderer class. + """ + return self.renderers[0] + + +########## Auth Mixin ########## + +class AuthMixin(object): + """ + Simple :class:`mixin` class to add authentication and permission checking to a :class:`View` class. + """ + + """ + The set of authentication types that this view can handle. + + Should be a tuple/list of classes as described in the :mod:`authentication` module. + """ + authentication = () + + """ + The set of permissions that will be enforced on this view. + + Should be a tuple/list of classes as described in the :mod:`permissions` module. + """ + permissions = () + + + @property + def user(self): + """ + Returns the :obj:`user` for the current request, as determined by the set of + :class:`authentication` classes applied to the :class:`View`. + """ + if not hasattr(self, '_user'): + self._user = self._authenticate() + return self._user + + + def _authenticate(self): + """ + Attempt to authenticate the request using each authentication class in turn. + Returns a ``User`` object, which may be ``AnonymousUser``. + """ + for authentication_cls in self.authentication: + authentication = authentication_cls(self) + user = authentication.authenticate(self.request) + if user: + return user + return AnonymousUser() + + + # TODO: wrap this behavior around dispatch() + def _check_permissions(self): + """ + Check user permissions and either raise an ``ErrorResponse`` or return. + """ + user = self.user + for permission_cls in self.permissions: + permission = permission_cls(self) + permission.check_permission(user) + + +########## Resource Mixin ########## + +class ResourceMixin(object): + """ + Provides request validation and response filtering behavior. + + Should be a class as described in the :mod:`resources` module. + + The :obj:`resource` is an object that maps a view onto it's representation on the server. + + It provides validation on the content of incoming requests, + and filters the object representation into a serializable object for the response. + """ + resource = None + + @property + def CONTENT(self): + """ + Returns the cleaned, validated request content. + + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + """ + if not hasattr(self, '_content'): + self._content = self.validate_request(self.DATA, self.FILES) + return self._content + + @property + def PARAMS(self): + """ + Returns the cleaned, validated query parameters. + + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + """ + return self.validate_request(self.request.GET) + + @property + def _resource(self): + if self.resource: + return self.resource(self) + elif getattr(self, 'model', None): + return ModelResource(self) + elif getattr(self, 'form', None): + return FormResource(self) + elif getattr(self, '%s_form' % self.method.lower(), None): + return FormResource(self) + return Resource(self) + + def validate_request(self, data, files=None): + """ + Given the request *data* and optional *files*, return the cleaned, validated content. + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + """ + return self._resource.validate_request(data, files) + + def filter_response(self, obj): + """ + Given the response content, filter it into a serializable object. + """ + return self._resource.filter_response(obj) + + def get_bound_form(self, content=None, method=None): + return self._resource.get_bound_form(content, method=method) + + + +########## + +class InstanceMixin(object): + """ + `Mixin` class that is used to identify a `View` class as being the canonical identifier + for the resources it is mapped to. + """ + + @classmethod + def as_view(cls, **initkwargs): + """ + Store the callable object on the resource class that has been associated with this view. + """ + view = super(InstanceMixin, cls).as_view(**initkwargs) + resource = getattr(cls(**initkwargs), 'resource', None) + if resource: + # We do a little dance when we store the view callable... + # we need to store it wrapped in a 1-tuple, so that inspect will treat it + # as a function when we later look it up (rather than turning it into a method). + # This makes sure our URL reversing works ok. + resource.view_callable = (view,) + return view + + +########## Model Mixins ########## + +class ReadModelMixin(object): + """ + Behavior to read a `model` instance on GET requests + """ + def get(self, request, *args, **kwargs): + model = self.resource.model + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = model.objects.get(**kwargs) + except model.DoesNotExist: + raise ErrorResponse(status.HTTP_404_NOT_FOUND) + + return instance + + +class CreateModelMixin(object): + """ + Behavior to create a `model` instance on POST requests + """ + def post(self, request, *args, **kwargs): + model = self.resource.model + # translated 'related_field' kwargs into 'related_field_id' + for related_name in [field.name for field in model._meta.fields if isinstance(field, RelatedField)]: + if kwargs.has_key(related_name): + kwargs[related_name + '_id'] = kwargs[related_name] + del kwargs[related_name] + + all_kw_args = dict(self.CONTENT.items() + kwargs.items()) + if args: + instance = model(pk=args[-1], **all_kw_args) + else: + instance = model(**all_kw_args) + instance.save() + headers = {} + if hasattr(instance, 'get_absolute_url'): + headers['Location'] = self.resource(self).url(instance) + return Response(status.HTTP_201_CREATED, instance, headers) + + +class UpdateModelMixin(object): + """ + Behavior to update a `model` instance on PUT requests + """ + def put(self, request, *args, **kwargs): + model = self.resource.model + # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = model.objects.get(**kwargs) + + for (key, val) in self.CONTENT.items(): + setattr(instance, key, val) + except model.DoesNotExist: + instance = model(**self.CONTENT) + instance.save() + + instance.save() + return instance + + +class DeleteModelMixin(object): + """ + Behavior to delete a `model` instance on DELETE requests + """ + def delete(self, request, *args, **kwargs): + model = self.resource.model + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = model.objects.get(**kwargs) + except model.DoesNotExist: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) + + instance.delete() + return + + +class ListModelMixin(object): + """ + Behavior to list a set of `model` instances on GET requests + """ + + # NB. Not obvious to me if it would be better to set this on the resource? + # + # Presumably it's more useful to have on the view, because that way you can + # have multiple views across different querysets mapping to the same resource. + # + # Perhaps it ought to be: + # + # 1) View.queryset + # 2) if None fall back to Resource.queryset + # 3) if None fall back to Resource.model.objects.all() + # + # Any feedback welcomed. + queryset = None + + def get(self, request, *args, **kwargs): + queryset = self.queryset if self.queryset else self.resource.model.objects.all() + ordering = getattr(self.resource, 'ordering', None) + if ordering: + args = as_tuple(ordering) + queryset = queryset.order_by(*args) + return queryset.filter(**kwargs) + + |
