diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 8317af9e2..48f898dd5 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -1,10 +1,13 @@ """ -The :mod:`authentication` module provides a set of pluggable authentication classes. +The :mod:`authentication` module provides a set of pluggable authentication +classes. -Authentication behavior is provided by mixing the :class:`mixins.AuthMixin` class into a :class:`View` class. +Authentication behavior is provided by mixing the :class:`mixins.AuthMixin` +class into a :class:`View` class. -The set of authentication methods which are used is then specified by setting the -:attr:`authentication` attribute on the :class:`View` class, and listing a set of :class:`authentication` classes. +The set of authentication methods which are used is then specified by setting +the :attr:`authentication` attribute on the :class:`View` class, and listing a +set of :class:`authentication` classes. """ from django.contrib.auth import authenticate @@ -26,23 +29,25 @@ class BaseAuthentication(object): def __init__(self, view): """ - :class:`Authentication` classes are always passed the current view on creation. + :class:`Authentication` classes are always passed the current view on + creation. """ self.view = view def authenticate(self, request): """ - Authenticate the :obj:`request` and return a :obj:`User` or :const:`None`. [*]_ + Authenticate the :obj:`request` and return a :obj:`User` or + :const:`None`. [*]_ .. [*] The authentication context *will* typically be a :obj:`User`, but it need not be. It can be any user-like object so long as the - permissions classes (see the :mod:`permissions` module) on the view can - handle the object and use it to determine if the request has the required - permissions or not. + permissions classes (see the :mod:`permissions` module) on the view + can handle the object and use it to determine if the request has + the required permissions or not. - This can be an important distinction if you're implementing some token - based authentication mechanism, where the authentication context - may be more involved than simply mapping to a :obj:`User`. + This can be an important distinction if you're implementing some + token based authentication mechanism, where the authentication + context may be more involved than simply mapping to a :obj:`User`. """ return None @@ -51,11 +56,17 @@ class BasicAuthentication(BaseAuthentication): """ Use HTTP Basic authentication. """ + def _authenticate_user(self, username, password): + user = authenticate(username=username, password=password) + if user and user.is_active: + return user + return None def authenticate(self, request): """ - Returns a :obj:`User` if a correct username and password have been supplied - using HTTP Basic authentication. Otherwise returns :const:`None`. + Returns a :obj:`User` if a correct username and password have been + supplied using HTTP Basic authentication. + Otherwise returns :const:`None`. """ from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError @@ -68,11 +79,12 @@ class BasicAuthentication(BaseAuthentication): return None try: - uname, passwd = smart_unicode(auth_parts[0]), smart_unicode(auth_parts[2]) + username = smart_unicode(auth_parts[0]) + password = smart_unicode(auth_parts[2]) except DjangoUnicodeDecodeError: return None - user = authenticate(username=uname, password=passwd) + user = authenticate(username=username, password=password) if user is not None and user.is_active: return user return None @@ -85,8 +97,8 @@ class UserLoggedInAuthentication(BaseAuthentication): def authenticate(self, request): """ - Returns a :obj:`User` if the request session currently has a logged in user. - Otherwise returns :const:`None`. + Returns a :obj:`User` if the request session currently has a logged in + user. Otherwise returns :const:`None`. """ # TODO: Might be cleaner to switch this back to using request.POST, # and let FormParser/MultiPartParser deal with the consequences. diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index b1a634a07..69e03f696 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -5,14 +5,13 @@ classes that can be added to a `View`. from django.contrib.auth.models import AnonymousUser from django.core.paginator import Paginator -from django.db.models.fields.related import ForeignKey from django.http import HttpResponse from djangorestframework import status from djangorestframework.renderers import BaseRenderer 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 import MSIE_USER_AGENT_REGEX from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence from StringIO import StringIO @@ -27,11 +26,7 @@ __all__ = ( # Reverse URL lookup behavior 'InstanceMixin', # Model behavior mixins - 'ReadModelMixin', - 'CreateModelMixin', - 'UpdateModelMixin', - 'DeleteModelMixin', - 'ListModelMixin' + 'ModelMixin', ) @@ -59,14 +54,14 @@ class RequestMixin(object): """ 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. + 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): """ @@ -80,7 +75,6 @@ class RequestMixin(object): self._load_method_and_content_type() return self._content_type - @property def DATA(self): """ @@ -93,7 +87,6 @@ class RequestMixin(object): self._load_data_and_files() return self._data - @property def FILES(self): """ @@ -105,7 +98,6 @@ class RequestMixin(object): self._load_data_and_files() return self._files - def _load_data_and_files(self): """ Parse the request content into self.DATA and self.FILES. @@ -114,18 +106,19 @@ class RequestMixin(object): self._load_method_and_content_type() if not hasattr(self, '_data'): - (self._data, self._files) = self._parse(self._get_stream(), self._content_type) - + (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. + 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._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. @@ -133,7 +126,8 @@ class RequestMixin(object): request = self.request try: - content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) + content_length = int(request.META.get('CONTENT_LENGTH', + request.META.get('HTTP_CONTENT_LENGTH'))) except (ValueError, TypeError): content_length = 0 @@ -142,18 +136,20 @@ class RequestMixin(object): if content_length == 0: return None elif hasattr(request, 'read'): - return request + 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. + 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): + 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. @@ -171,27 +167,24 @@ class RequestMixin(object): stream = StringIO(self._data.pop(self._CONTENT_PARAM)[0]) (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). + 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: + for parser_cls in self.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}) - + error = {'error': + "Unsupported media type in request '%s'." % content_type} + raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, error) @property def _parsed_media_types(self): @@ -200,7 +193,6 @@ class RequestMixin(object): """ return [parser.media_type for parser in self.parsers] - @property def _default_parser(self): """ @@ -209,29 +201,34 @@ class RequestMixin(object): 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. + + 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 + # Allow override of Accept header in URL query params + _ACCEPT_QUERY_PARAM = '_accept' _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. + 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): @@ -258,34 +255,41 @@ class ResponseMixin(object): content = renderer.render() # Build the HTTP Response - resp = HttpResponse(content, mimetype=response.media_type, status=response.status) + 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. + 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 + 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): + 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 + 'HTTP_USER_AGENT' in request.META and MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): - # Ignore MSIE's broken accept behavior and do something sensible instead + # Ignore MSIE's broken accept behavior and do something sensible + # instead. accept_list = ['text/html', '*/*'] - elif request.META.has_key('HTTP_ACCEPT'): + + elif 'HTTP_ACCEPT' in request.META: # Use standard HTTP Accept negotiation - accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] + accept_list = [token.strip() for token in + request.META['HTTP_ACCEPT'].split(',')] + else: # No accept header specified accept_list = ['*/*'] @@ -293,7 +297,7 @@ class ResponseMixin(object): # 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) + # Worst case is: len(accept_list) * len(self.renderers) renderers = [renderer_cls(self) for renderer_cls in self.renderers] for accepted_media_type_lst in order_by_precedence(accept_list): @@ -303,10 +307,9 @@ class ResponseMixin(object): return renderer, accepted_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}) - + error = {'detail': "Could not satisfy the client's Accept header", + 'available_types': self._rendered_media_types} + raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, error) @property def _rendered_media_types(self): @@ -334,39 +337,40 @@ class ResponseMixin(object): class AuthMixin(object): """ - Simple :class:`mixin` class to add authentication and permission checking to a :class:`View` class. + 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. + 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. + 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`. + 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``. + 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) @@ -375,7 +379,6 @@ class AuthMixin(object): return user return AnonymousUser() - # TODO: wrap this behavior around dispatch() def _check_permissions(self): """ @@ -395,10 +398,12 @@ class ResourceMixin(object): 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. + 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. + and filters the object representation into a serializable object for the + response. """ resource = None @@ -407,7 +412,8 @@ class ResourceMixin(object): """ Returns the cleaned, validated request content. - May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + 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) @@ -418,7 +424,8 @@ class ResourceMixin(object): """ Returns the cleaned, validated query parameters. - May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + May raise an :class:`response.ErrorResponse` with status code 400 + (Bad Request). """ return self.validate_request(self.request.GET) @@ -436,8 +443,10 @@ class ResourceMixin(object): 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. + 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) @@ -454,26 +463,28 @@ class ResourceMixin(object): return None - ########## + 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. + `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. + 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). + # 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 @@ -481,59 +492,57 @@ class InstanceMixin(object): ########## Model Mixins ########## -class ReadModelMixin(object): - """ - Behavior to read a `model` instance on GET requests - """ - def get(self, request, *args, **kwargs): - model = self.resource.model + +class ModelMixin(object): + def get_model(self): + """ + Return the model class for this view. + """ + return getattr(self, 'model', self.resource.model) + + def get_queryset(self): + """ + Return the queryset that should be used when retrieving or listing + instances. + """ + return getattr(self, 'queryset', + getattr(self.resource, 'queryset', + self.get_model().objects.all())) + + def get_ordering(self): + """ + Return the ordering that should be used when listing instances. + """ + return getattr(self, 'ordering', + getattr(self.resource, 'ordering', + None)) + + # Underlying instance API... + + def get_instance(self, *args, **kwargs): + """ + Return a model instance or None. + """ + model = self.get_model() + queryset = self.get_queryset() try: - if args: - # If we have any none kwargs then assume the last represents the primrary key - self.model_instance = model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - filtered_keywords = kwargs.copy() - if BaseRenderer._FORMAT_QUERY_PARAM in filtered_keywords: - del filtered_keywords[BaseRenderer._FORMAT_QUERY_PARAM] - self.model_instance = model.objects.get(**filtered_keywords) + return queryset.get(**kwargs) except model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND) + return None - return self.model_instance + def create_instance(self, *args, **kwargs): + model = self.get_model() - -class CreateModelMixin(object): - """ - Behavior to create a `model` instance on POST requests - """ - def post(self, request, *args, **kwargs): - model = self.resource.model - - # Copy the dict to keep self.CONTENT intact - content = dict(self.CONTENT) m2m_data = {} - - for field in model._meta.fields: - if isinstance(field, ForeignKey) and kwargs.has_key(field.name): - # translate 'related_field' kwargs into 'related_field_id' - kwargs[field.name + '_id'] = kwargs[field.name] + for field in model._meta.many_to_many: + if field.name in kwargs: + m2m_data[field.name] = ( + field.m2m_reverse_field_name(), kwargs[field.name] + ) del kwargs[field.name] - for field in model._meta.many_to_many: - if content.has_key(field.name): - m2m_data[field.name] = ( - field.m2m_reverse_field_name(), content[field.name] - ) - del content[field.name] - - all_kw_args = dict(content.items() + kwargs.items()) - - if args: - instance = model(pk=args[-1], **all_kw_args) - else: - instance = model(**all_kw_args) + instance = model(**kwargs) instance.save() for fieldname in m2m_data: @@ -549,93 +558,83 @@ class CreateModelMixin(object): data[m2m_data[fieldname][0]] = related_item manager.through(**data).save() - headers = {} - if hasattr(instance, 'get_absolute_url'): - headers['Location'] = self.resource(self).url(instance) - return Response(status.HTTP_201_CREATED, instance, headers) + return instance + def update_instance(self, instance, *args, **kwargs): + for (key, val) in kwargs.items(): + setattr(instance, key, val) + instance.save() + return instance -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 primary key - self.model_instance = model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - self.model_instance = model.objects.get(**kwargs) - - for (key, val) in self.CONTENT.items(): - setattr(self.model_instance, key, val) - except model.DoesNotExist: - self.model_instance = model(**self.CONTENT) - self.model_instance.save() - - self.model_instance.save() - return self.model_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, {}) - + def delete_instance(self, instance, *args, **kwargs): instance.delete() - return + return instance - -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): - model = self.resource.model - - queryset = self.queryset if self.queryset is not None else model.objects.all() - - if hasattr(self, 'resource'): - ordering = getattr(self.resource, 'ordering', None) - else: - ordering = None + def list_instances(self, *args, **kwargs): + queryset = self.get_queryset() + ordering = self.get_ordering() if ordering: - args = as_tuple(ordering) - queryset = queryset.order_by(*args) + queryset = queryset.order_by(ordering) return queryset.filter(**kwargs) + # Request/Response layer... + + def _get_url_kwargs(self, kwargs): + format_arg = BaseRenderer._FORMAT_QUERY_PARAM + if format_arg in kwargs: + kwargs = kwargs.copy() + del kwargs[format_arg] + return kwargs + + def _get_content_kwargs(self, kwargs): + return dict(self._get_url_kwargs(kwargs).items() + + self.CONTENT.items()) + + def read(self, request, *args, **kwargs): + kwargs = self._get_url_kwargs(kwargs) + instance = self.get_instance(**kwargs) + + if instance is None: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) + + return instance + + def update(self, request, *args, **kwargs): + kwargs = self._get_url_kwargs(kwargs) + instance = self.get_instance(**kwargs) + + kwargs = self._get_content_kwargs(kwargs) + if instance: + instance = self.update_instance(instance, **kwargs) + else: + instance = self.create_instance(**kwargs) + + return instance + + def create(self, request, *args, **kwargs): + kwargs = self._get_content_kwargs(kwargs) + instance = self.create_instance(**kwargs) + + headers = {} + try: + headers['Location'] = self.resource(self).url(instance) + except: # TODO: _SkipField should not really happen. + pass + + return Response(status.HTTP_201_CREATED, instance, headers) + + def destroy(self, request, *args, **kwargs): + kwargs = self._get_url_kwargs(kwargs) + instance = self.delete_instance(**kwargs) + if not instance: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) + + return instance + + def list(self, request, *args, **kwargs): + return self.list_instances(**kwargs) + ########## Pagination Mixins ########## @@ -658,7 +657,7 @@ class PaginatorMixin(object): return self.limit def url_with_page_number(self, page_number): - """ Constructs a url used for getting the next/previous urls """ + """Constructs a url used for getting the next/previous urls.""" url = "%s?page=%d" % (self.request.path, page_number) limit = self.get_limit() @@ -668,21 +667,21 @@ class PaginatorMixin(object): return url def next(self, page): - """ Returns a url to the next page of results (if any) """ + """Returns a url to the next page of results. (If any exists.)""" if not page.has_next(): return None return self.url_with_page_number(page.next_page_number()) def previous(self, page): - """ Returns a url to the previous page of results (if any) """ + """Returns a url to the previous page of results. (If any exists.)""" if not page.has_previous(): return None return self.url_with_page_number(page.previous_page_number()) def serialize_page_info(self, page): - """ This is some useful information that is added to the response """ + """This is some useful information that is added to the response.""" return { 'next': self.next(page), 'page': page.number, @@ -696,14 +695,15 @@ class PaginatorMixin(object): """ Given the response content, paginate and then serialize. - The response is modified to include to useful data relating to the number - of objects, number of pages, next/previous urls etc. etc. + The response is modified to include to useful data relating to the + number of objects, number of pages, next/previous urls etc. etc. The serialised objects are put into `results` on this new, modified response """ - # We don't want to paginate responses for anything other than GET requests + # We don't want to paginate responses for anything other than GET + # requests if self.method.upper() != 'GET': return self._resource.filter_response(obj) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 68b285b91..66ab0a855 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -1,28 +1,19 @@ from django import forms from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch from django.db import models -from django.db.models.query import QuerySet -from django.db.models.fields.related import RelatedField -from django.utils.encoding import smart_unicode from djangorestframework.response import ErrorResponse from djangorestframework.serializer import Serializer, _SkipField -from djangorestframework.utils import as_tuple - -import decimal -import inspect -import re - - class BaseResource(Serializer): """ - Base class for all Resource classes, which simply defines the interface they provide. + Base class for all Resource classes, which simply defines the interface + they provide. """ - fields = None - include = None - exclude = None + fields = () + include = () + exclude = () def __init__(self, view=None, depth=None, stack=[], **kwargs): super(BaseResource, self).__init__(depth, stack, **kwargs) @@ -31,7 +22,8 @@ class BaseResource(Serializer): def validate_request(self, data, files=None): """ Given the request content return the cleaned, validated content. - Typically raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + Typically raises a :exc:`response.ErrorResponse` with status code 400 + (Bad Request) on failure. """ return data @@ -45,18 +37,20 @@ class BaseResource(Serializer): class Resource(BaseResource): """ A Resource determines how a python object maps to some serializable data. - Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets. + Objects that a resource can act on include plain Python object instances, + Django Models, and Django QuerySets. """ - # The model attribute refers to the Django Model which this Resource maps to. - # (The Model's class, rather than an instance of the Model) + # The model attribute refers to the Django Model which this Resource maps + # to. (The Model's class, rather than an instance of the Model) model = None # By default the set of returned fields will be the set of: # # 0. All the fields on the model, excluding 'id'. # 1. All the properties on the model. - # 2. The absolute_url of the model, if a get_absolute_url method exists for the model. + # 2. The absolute_url of the model, if a get_absolute_url method exists for + # the model. # # If you wish to override this behaviour, # you should explicitly set the fields attribute on your class. @@ -66,42 +60,51 @@ class Resource(BaseResource): class FormResource(Resource): """ Resource class that uses forms for validation. - Also provides a :meth:`get_bound_form` method which may be used by some renderers. + Also provides a :meth:`get_bound_form` method which may be used by some + renderers. - On calling :meth:`validate_request` this validator may set a :attr:`bound_form_instance` attribute on the - view, which may be used by some renderers. + On calling :meth:`validate_request` this validator may set a + :attr:`bound_form_instance` attribute on the view, which may be used by + some renderers. """ form = None """ The :class:`Form` class that should be used for request validation. - This can be overridden by a :attr:`form` attribute on the :class:`views.View`. + This can be overridden by a :attr:`form` attribute on the + :class:`views.View`. """ - def validate_request(self, data, files=None): """ Given some content as input return some cleaned, validated content. - Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. - Validation is standard form validation, with an additional constraint that *no extra unknown fields* may be supplied. + Raises a :exc:`response.ErrorResponse` with status code 400 + # (Bad Request) on failure. - On failure the :exc:`response.ErrorResponse` content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. - If the :obj:`'errors'` key exists it is a list of strings of non-field errors. - If the :obj:`'field-errors'` key exists it is a dict of ``{'field name as string': ['errors as strings', ...]}``. + Validation is standard form validation, with an additional constraint + that *no extra unknown fields* may be supplied. + + On failure the :exc:`response.ErrorResponse` content is a dict which + may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'errors'` key exists it is a list of strings of non-field + errors. + If the :obj:`'field-errors'` key exists it is a dict of + ``{'field name as string': ['errors as strings', ...]}``. """ return self._validate(data, files) - def _validate(self, data, files, allowed_extra_fields=(), fake_data=None): """ - Wrapped by validate to hide the extra flags that are used in the implementation. + Wrapped by validate to hide the extra flags that are used in the + implementation. - allowed_extra_fields is a list of fields which are not defined by the form, but which we still - expect to see on the input. + allowed_extra_fields is a list of fields which are not defined by the + form, but which we still expect to see on the input. - fake_data is a string that should be used as an extra key, as a kludge to force .errors - to be populated when an empty dict is supplied in `data` + fake_data is a string that should be used as an extra key, as a kludge + to force `.errors` to be populated when an empty dict is supplied in + `data` """ # We'd like nice error messages even if no content is supplied. @@ -129,7 +132,7 @@ class FormResource(Resource): # In addition to regular validation we also ensure no additional fields are being passed in... unknown_fields = seen_fields_set - (form_fields_set | allowed_extra_fields_set) - unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept', '_method')) # TODO: Ugh. + unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept', '_method')) # TODO: Ugh. # Check using both regular validation, and our stricter no additional fields rule if bound_form.is_valid() and not unknown_fields: @@ -178,7 +181,6 @@ class FormResource(Resource): # Return HTTP 400 response (BAD REQUEST) raise ErrorResponse(400, detail) - def get_form_class(self, method=None): """ Returns the form class used to validate this resource. @@ -217,7 +219,6 @@ class FormResource(Resource): return form() - #class _RegisterModelResource(type): # """ # Auto register new ModelResource classes into ``_model_to_resource`` @@ -230,11 +231,12 @@ class FormResource(Resource): # return resource_cls - class ModelResource(FormResource): """ - Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. - Also provides a :meth:`get_bound_form` method which may be used by some renderers. + Resource class that uses forms for validation and otherwise falls back to a + model form if no form is set. + Also provides a :meth:`get_bound_form` method which may be used by some + renderers. """ # Auto-register new ModelResource classes into _model_to_resource @@ -245,14 +247,16 @@ class ModelResource(FormResource): The form class that should be used for request validation. If set to :const:`None` then the default model form validation will be used. - This can be overridden by a :attr:`form` attribute on the :class:`views.View`. + This can be overridden by a :attr:`form` attribute on the + :class:`views.View`. """ model = None """ The model class which this resource maps to. - This can be overridden by a :attr:`model` attribute on the :class:`views.View`. + This can be overridden by a :attr:`model` attribute on the + :class:`views.View`. """ fields = None @@ -261,22 +265,27 @@ class ModelResource(FormResource): May be any of: - The name of a model field. To view nested resources, give the field as a tuple of ("fieldName", resource) where `resource` may be any of ModelResource reference, the name of a ModelResourc reference as a string or a tuple of strings representing fields on the nested model. + The name of a model field. To view nested resources, give the field as a + tuple of ("fieldName", resource) where `resource` may be any of + ModelResource reference, the name of a ModelResourc reference as a string + or a tuple of strings representing fields on the nested model. The name of an attribute on the model. The name of an attribute on the resource. The name of a method on the model, with a signature like ``func(self)``. - The name of a method on the resource, with a signature like ``func(self, instance)``. + The name of a method on the resource, with a signature like + ``func(self, instance)``. """ exclude = ('id', 'pk') """ - The list of fields to exclude. This is only used if :attr:`fields` is not set. + The list of fields to exclude. This is only used if :attr:`fields` is not + set. """ - include = ('url',) """ - The list of extra fields to include. This is only used if :attr:`fields` is not set. + The list of extra fields to include. This is only used if :attr:`fields` + is not set. """ def __init__(self, view=None, depth=None, stack=[], **kwargs): @@ -289,30 +298,36 @@ class ModelResource(FormResource): self.model = getattr(view, 'model', None) or self.model - def validate_request(self, data, files=None): """ Given some content as input return some cleaned, validated content. - Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + + Raises a :exc:`response.ErrorResponse` with status code 400 + (Bad Request) on failure. Validation is standard form or model form validation, - with an additional constraint that no extra unknown fields may be supplied, - and that all fields specified by the fields class attribute must be supplied, - even if they are not validated by the form/model form. + with an additional constraint that no extra unknown fields may be + supplied, and that all fields specified by the fields class attribute + must be supplied, even if they are not validated by the Form/ModelForm. - On failure the ErrorResponse content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. - If the :obj:`'errors'` key exists it is a list of strings of non-field errors. - If the ''field-errors'` key exists it is a dict of {field name as string: list of errors as strings}. + On failure the ErrorResponse content is a dict which may contain + :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'errors'` key exists it is a list of strings of non-field + errors. + If the ''field-errors'` key exists it is a dict of + `{field name as string: list of errors as strings}`. """ - return self._validate(data, files, allowed_extra_fields=self._property_fields_set) - + return self._validate(data, files, + allowed_extra_fields=self._property_fields_set) def get_bound_form(self, data=None, files=None, method=None): """ Given some content return a ``Form`` instance bound to that content. - If the :attr:`form` class attribute has been explicitly set then that class will be used - to create the Form, otherwise the model will be used to create a ModelForm. + If the :attr:`form` class attribute has been explicitly set then that + class will be used + to create the Form, otherwise the model will be used to create a + ModelForm. """ form = self.get_form_class(method) @@ -339,14 +354,16 @@ class ModelResource(FormResource): return form() - def url(self, instance): """ - Attempts to reverse resolve the url of the given model *instance* for this resource. + Attempts to reverse resolve the url of the given model *instance* for + this resource. - Requires a ``View`` with :class:`mixins.InstanceMixin` to have been created for this resource. + Requires a ``View`` with :class:`mixins.InstanceMixin` to have been + created for this resource. - This method can be overridden if you need to set the resource url reversing explicitly. + This method can be overridden if you need to set the resource url + reversing explicitly. """ if not hasattr(self, 'view_callable'): @@ -363,7 +380,9 @@ class ModelResource(FormResource): # Note: defaults = tuple_item[2] for django >= 1.3 for result, params in possibility: - #instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ]) + # instance_attrs = dict([ (param, getattr(instance, param)) + # for param in params + # if hasattr(instance, param) ]) instance_attrs = {} for param in params: @@ -381,7 +400,6 @@ class ModelResource(FormResource): pass raise _SkipField - @property def _model_fields_set(self): """ @@ -389,11 +407,12 @@ class ModelResource(FormResource): """ model_fields = set(field.name for field in self.model._meta.fields) - if fields: - return model_fields & set(as_tuple(self.fields)) + if self.fields: + return model_fields & set(self.fields) return model_fields - set(as_tuple(self.exclude)) + @property def _property_fields_set(self): """ @@ -404,6 +423,6 @@ class ModelResource(FormResource): and not attr.startswith('_')) if self.fields: - return property_fields & set(as_tuple(self.fields)) + return property_fields & set(self.fields) - return property_fields.union(set(as_tuple(self.include))) - set(as_tuple(self.exclude)) + return property_fields.union(set(self.include)) - set(self.exclude) diff --git a/djangorestframework/tests/mixins.py b/djangorestframework/tests/mixins.py index 3b814aa7e..a09959e26 100644 --- a/djangorestframework/tests/mixins.py +++ b/djangorestframework/tests/mixins.py @@ -4,7 +4,7 @@ from django.utils import simplejson as json from djangorestframework import status from djangorestframework.compat import RequestFactory from django.contrib.auth.models import Group, User -from djangorestframework.mixins import CreateModelMixin, PaginatorMixin +from djangorestframework.mixins import PaginatorMixin, ModelMixin from djangorestframework.resources import ModelResource from djangorestframework.response import Response from djangorestframework.tests.models import CustomUser @@ -27,11 +27,11 @@ class TestModelCreation(TestModelsTestCase): form_data = {'name': 'foo'} request = self.req.post('/groups', data=form_data) - mixin = CreateModelMixin() + mixin = ModelMixin() mixin.resource = GroupResource mixin.CONTENT = form_data - response = mixin.post(request) + response = mixin.create(request) self.assertEquals(1, Group.objects.count()) self.assertEquals('foo', response.cleaned_content.name) @@ -53,11 +53,11 @@ class TestModelCreation(TestModelsTestCase): request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group] - mixin = CreateModelMixin() + mixin = ModelMixin() mixin.resource = UserResource mixin.CONTENT = cleaned_data - response = mixin.post(request) + response = mixin.create(request) self.assertEquals(1, User.objects.count()) self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals('foo', response.cleaned_content.groups.all()[0].name) @@ -76,11 +76,11 @@ class TestModelCreation(TestModelsTestCase): request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [] - mixin = CreateModelMixin() + mixin = ModelMixin() mixin.resource = UserResource mixin.CONTENT = cleaned_data - response = mixin.post(request) + response = mixin.create(request) self.assertEquals(1, CustomUser.objects.count()) self.assertEquals(0, response.cleaned_content.groups.count()) @@ -91,11 +91,11 @@ class TestModelCreation(TestModelsTestCase): request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group] - mixin = CreateModelMixin() + mixin = ModelMixin() mixin.resource = UserResource mixin.CONTENT = cleaned_data - response = mixin.post(request) + response = mixin.create(request) self.assertEquals(2, CustomUser.objects.count()) self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) @@ -107,11 +107,11 @@ class TestModelCreation(TestModelsTestCase): request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group, group2] - mixin = CreateModelMixin() + mixin = ModelMixin() mixin.resource = UserResource mixin.CONTENT = cleaned_data - response = mixin.post(request) + response = mixin.create(request) self.assertEquals(3, CustomUser.objects.count()) self.assertEquals(2, response.cleaned_content.groups.count()) self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index bd0d360cb..c3dfb98b6 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -156,7 +156,8 @@ class RendererIntegrationTests(TestCase): self.assertEquals(resp.status_code, DUMMYSTATUS) _flat_repr = '{"foo": ["bar", "baz"]}' -_indented_repr = '{\n "foo": [\n "bar", \n "baz"\n ]\n}' + +_indented_repr = '{\n "foo": [\n "bar",\n "baz"\n ]\n}' class JSONRendererTests(TestCase): @@ -179,7 +180,7 @@ class JSONRendererTests(TestCase): """ obj = {'foo': ['bar', 'baz']} renderer = JSONRenderer(None) - content = renderer.render(obj, 'application/json; indent=2') + content = renderer.render(obj, 'application/json; indent=4') self.assertEquals(content, _indented_repr) def test_render_and_parse(self): @@ -239,6 +240,7 @@ class JSONPRendererTests(TestCase): if YAMLRenderer: _yaml_repr = 'foo: [bar, baz]\n' + class YAMLRendererTests(TestCase): """ Tests specific to the JSON Renderer @@ -253,6 +255,7 @@ if YAMLRenderer: content = renderer.render(obj, 'application/yaml') self.assertEquals(content, _yaml_repr) + def test_render_and_parse(self): """ Test rendering and then parsing returns the original object. diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py index 305311f44..20fa053b4 100644 --- a/djangorestframework/utils/__init__.py +++ b/djangorestframework/utils/__init__.py @@ -22,7 +22,7 @@ def as_tuple(obj): """ Given an object which may be a list/tuple, another object, or None, return that object in list form. - + IE: If the object is already a list/tuple just return it. If the object is not None, return it in a list with a single element. @@ -150,10 +150,10 @@ class XMLRenderer(): xml.startElement(key, {}) self._to_xml(xml, value) xml.endElement(key) - + elif data is None: # Don't output any value - pass + pass else: xml.characters(smart_unicode(data)) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 4cc2c7cb0..18911a52c 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -25,7 +25,6 @@ __all__ = ( ) - class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ Handles incoming requests and maps them to REST operations. @@ -51,14 +50,13 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ List of all authenticating methods to attempt. """ - authentication = ( authentication.UserLoggedInAuthentication, - authentication.BasicAuthentication ) + authentication = (authentication.UserLoggedInAuthentication, + authentication.BasicAuthentication) """ List of all permissions that must be checked. """ - permissions = ( permissions.FullAnonAccess, ) - + permissions = (permissions.FullAnonAccess,) @classmethod def as_view(cls, **initkwargs): @@ -71,7 +69,6 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): view.cls_instance = cls(**initkwargs) return view - @property def allowed_methods(self): """ @@ -79,7 +76,6 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ return [method.upper() for method in self.http_method_names if hasattr(self, method)] - def http_method_not_allowed(self, request, *args, **kwargs): """ Return an HTTP 405 error if an operation is called which does not have a handler method. @@ -87,23 +83,20 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) - def initial(self, request, *args, **kargs): """ Hook for any code that needs to run prior to anything else. - Required if you want to do things like set `request.upload_handlers` before - the authentication and dispatch handling is run. + Required if you want to do things like set `request.upload_handlers` + before the authentication and dispatch handling is run. """ pass - def add_header(self, field, value): """ Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. """ self.headers[field] = value - # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt @@ -143,11 +136,13 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): response = Response(status.HTTP_204_NO_CONTENT) if request.method == 'OPTIONS': - # do not filter the response for HTTP OPTIONS, else the response fields are lost, + # do not filter the response for HTTP OPTIONS, + # else the response fields are lost, # as they do not correspond with model fields response.cleaned_content = response.raw_content else: - # Pre-serialize filtering (eg filter complex objects into natively serializable types) + # Pre-serialize filtering (eg filter complex objects into + # natively serializable types) response.cleaned_content = self.filter_response(response.raw_content) except ErrorResponse, exc: @@ -155,8 +150,8 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): # Always add these headers. # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-optimal for HTTP caching - need to sort that out. + # TODO - this isn't really the correct way to set the Vary header, + # also it's currently sub-optimal for HTTP caching. response.headers['Allow'] = ', '.join(self.allowed_methods) response.headers['Vary'] = 'Authenticate, Accept' @@ -168,7 +163,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): return self.render(response) def options(self, request, *args, **kwargs): - response_obj = { + ret = { 'name': get_name(self), 'description': get_description(self), 'renders': self._rendered_media_types, @@ -179,30 +174,46 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): field_name_types = {} for name, field in form.fields.iteritems(): field_name_types[name] = field.__class__.__name__ - response_obj['fields'] = field_name_types - return response_obj + ret['fields'] = field_name_types + return ret -class ModelView(View): +class ModelView(ModelMixin, View): """ A RESTful view that maps to a model in the database. """ resource = resources.ModelResource -class InstanceModelView(InstanceMixin, ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): + +class InstanceModelView(InstanceMixin, ModelView): """ - A view which provides default operations for read/update/delete against a model instance. + A view which provides default operations for read/update/delete against a + model instance. This view is also treated as the Canonical identifier + of the instances. """ _suffix = 'Instance' -class ListModelView(ListModelMixin, ModelView): + get = ModelMixin.read + put = ModelMixin.update + delete = ModelMixin.destroy + + +class ListModelView(ModelView): """ - A view which provides default operations for list, against a model in the database. + A view which provides default operations for list, against a model in the + database. """ _suffix = 'List' -class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView): + get = ModelMixin.list + + +class ListOrCreateModelView(ModelView): """ - A view which provides default operations for list and create, against a model in the database. + A view which provides default operations for list and create, against a + model in the database. """ _suffix = 'List' + + get = ModelMixin.list + post = ModelMixin.create