This commit is contained in:
GitHub Merge Button 2012-01-03 01:12:26 -08:00
commit 102d7cd89b
7 changed files with 383 additions and 338 deletions

View File

@ -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 The set of authentication methods which are used is then specified by setting
:attr:`authentication` attribute on the :class:`View` class, and listing a set of :class:`authentication` classes. the :attr:`authentication` attribute on the :class:`View` class, and listing a
set of :class:`authentication` classes.
""" """
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
@ -26,23 +29,25 @@ class BaseAuthentication(object):
def __init__(self, view): 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 self.view = view
def authenticate(self, request): 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`, .. [*] 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 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 permissions classes (see the :mod:`permissions` module) on the view
handle the object and use it to determine if the request has the required can handle the object and use it to determine if the request has
permissions or not. the required permissions or not.
This can be an important distinction if you're implementing some token This can be an important distinction if you're implementing some
based authentication mechanism, where the authentication context token based authentication mechanism, where the authentication
may be more involved than simply mapping to a :obj:`User`. context may be more involved than simply mapping to a :obj:`User`.
""" """
return None return None
@ -51,11 +56,17 @@ class BasicAuthentication(BaseAuthentication):
""" """
Use HTTP Basic authentication. 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): def authenticate(self, request):
""" """
Returns a :obj:`User` if a correct username and password have been supplied Returns a :obj:`User` if a correct username and password have been
using HTTP Basic authentication. Otherwise returns :const:`None`. supplied using HTTP Basic authentication.
Otherwise returns :const:`None`.
""" """
from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError
@ -68,11 +79,12 @@ class BasicAuthentication(BaseAuthentication):
return None return None
try: 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: except DjangoUnicodeDecodeError:
return None return None
user = authenticate(username=uname, password=passwd) user = authenticate(username=username, password=password)
if user is not None and user.is_active: if user is not None and user.is_active:
return user return user
return None return None
@ -85,8 +97,8 @@ class UserLoggedInAuthentication(BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
""" """
Returns a :obj:`User` if the request session currently has a logged in user. Returns a :obj:`User` if the request session currently has a logged in
Otherwise returns :const:`None`. user. Otherwise returns :const:`None`.
""" """
# TODO: Might be cleaner to switch this back to using request.POST, # TODO: Might be cleaner to switch this back to using request.POST,
# and let FormParser/MultiPartParser deal with the consequences. # and let FormParser/MultiPartParser deal with the consequences.

View File

@ -5,14 +5,13 @@ classes that can be added to a `View`.
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models.fields.related import ForeignKey
from django.http import HttpResponse from django.http import HttpResponse
from djangorestframework import status from djangorestframework import status
from djangorestframework.renderers import BaseRenderer from djangorestframework.renderers import BaseRenderer
from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.resources import Resource, FormResource, ModelResource
from djangorestframework.response import Response, ErrorResponse 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 djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence
from StringIO import StringIO from StringIO import StringIO
@ -27,11 +26,7 @@ __all__ = (
# Reverse URL lookup behavior # Reverse URL lookup behavior
'InstanceMixin', 'InstanceMixin',
# Model behavior mixins # Model behavior mixins
'ReadModelMixin', 'ModelMixin',
'CreateModelMixin',
'UpdateModelMixin',
'DeleteModelMixin',
'ListModelMixin'
) )
@ -59,14 +54,14 @@ class RequestMixin(object):
""" """
Returns the HTTP method. Returns the HTTP method.
This should be used instead of just reading :const:`request.method`, as it allows the `method` This should be used instead of just reading :const:`request.method`, as
to be overridden by using a hidden `form` field on a form POST request. it allows the `method` to be overridden by using a hidden `form` field
on a form POST request.
""" """
if not hasattr(self, '_method'): if not hasattr(self, '_method'):
self._load_method_and_content_type() self._load_method_and_content_type()
return self._method return self._method
@property @property
def content_type(self): def content_type(self):
""" """
@ -80,7 +75,6 @@ class RequestMixin(object):
self._load_method_and_content_type() self._load_method_and_content_type()
return self._content_type return self._content_type
@property @property
def DATA(self): def DATA(self):
""" """
@ -93,7 +87,6 @@ class RequestMixin(object):
self._load_data_and_files() self._load_data_and_files()
return self._data return self._data
@property @property
def FILES(self): def FILES(self):
""" """
@ -105,7 +98,6 @@ class RequestMixin(object):
self._load_data_and_files() self._load_data_and_files()
return self._files return self._files
def _load_data_and_files(self): def _load_data_and_files(self):
""" """
Parse the request content into self.DATA and self.FILES. Parse the request content into self.DATA and self.FILES.
@ -114,18 +106,19 @@ class RequestMixin(object):
self._load_method_and_content_type() self._load_method_and_content_type()
if not hasattr(self, '_data'): 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): 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._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() self._perform_form_overloading()
def _get_stream(self): def _get_stream(self):
""" """
Returns an object that may be used to stream the request content. Returns an object that may be used to stream the request content.
@ -133,7 +126,8 @@ class RequestMixin(object):
request = self.request request = self.request
try: 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): except (ValueError, TypeError):
content_length = 0 content_length = 0
@ -142,18 +136,20 @@ class RequestMixin(object):
if content_length == 0: if content_length == 0:
return None return None
elif hasattr(request, 'read'): elif hasattr(request, 'read'):
return request return request
return StringIO(request.raw_post_data) return StringIO(request.raw_post_data)
def _perform_form_overloading(self): 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 If this is a form POST request, then we need to check if the method and
overridden by setting them in hidden form fields or not. 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. # 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 return
# At this point we're committed to parsing the request as form data. # 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]) stream = StringIO(self._data.pop(self._CONTENT_PARAM)[0])
(self._data, self._files) = self._parse(stream, self._content_type) (self._data, self._files) = self._parse(stream, self._content_type)
def _parse(self, stream, content_type): def _parse(self, stream, content_type):
""" """
Parse the request content. 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: if stream is None or content_type is None:
return (None, None) return (None, None)
parsers = as_tuple(self.parsers) for parser_cls in self.parsers:
for parser_cls in parsers:
parser = parser_cls(self) parser = parser_cls(self)
if parser.can_handle_request(content_type): if parser.can_handle_request(content_type):
return parser.parse(stream) return parser.parse(stream)
raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, error = {'error':
{'error': 'Unsupported media type in request \'%s\'.' % "Unsupported media type in request '%s'." % content_type}
content_type}) raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, error)
@property @property
def _parsed_media_types(self): def _parsed_media_types(self):
@ -200,7 +193,6 @@ class RequestMixin(object):
""" """
return [parser.media_type for parser in self.parsers] return [parser.media_type for parser in self.parsers]
@property @property
def _default_parser(self): def _default_parser(self):
""" """
@ -209,29 +201,34 @@ class RequestMixin(object):
return self.parsers[0] return self.parsers[0]
########## ResponseMixin ########## ########## ResponseMixin ##########
class ResponseMixin(object): class ResponseMixin(object):
""" """
Adds behavior for pluggable `Renderers` to a :class:`views.View` class. Adds behavior for pluggable `Renderers` to a :class:`views.View` class.
Default behavior is to use standard HTTP Accept header content negotiation. 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 _IGNORE_IE_ACCEPT_HEADER = True
""" """
The set of response renderers that the view can handle. 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 = () renderers = ()
# TODO: wrap this behavior around dispatch(), ensuring it works # TODO: wrap this behavior around dispatch(), ensuring it works
# out of the box with existing Django classes that use render_to_response. # out of the box with existing Django classes that use render_to_response.
def render(self, response): def render(self, response):
@ -258,34 +255,41 @@ class ResponseMixin(object):
content = renderer.render() content = renderer.render()
# Build the HTTP Response # 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(): for (key, val) in response.headers.items():
resp[key] = val resp[key] = val
return resp return resp
def _determine_renderer(self, request): def _determine_renderer(self, request):
""" """
Determines the appropriate renderer for the output, given the client's 'Accept' header, Determines the appropriate renderer for the output, given the client's
and the :attr:`renderers` set on this class. 'Accept' header, and the :attr:`renderers` set on this class.
Returns a 2-tuple of `(renderer, media_type)` 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 # Use _accept parameter override
accept_list = [request.GET.get(self._ACCEPT_QUERY_PARAM)] accept_list = [request.GET.get(self._ACCEPT_QUERY_PARAM)]
elif (self._IGNORE_IE_ACCEPT_HEADER and 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'])): 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', '*/*'] accept_list = ['text/html', '*/*']
elif request.META.has_key('HTTP_ACCEPT'):
elif 'HTTP_ACCEPT' in request.META:
# Use standard HTTP Accept negotiation # 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: else:
# No accept header specified # No accept header specified
accept_list = ['*/*'] accept_list = ['*/*']
@ -293,7 +297,7 @@ class ResponseMixin(object):
# Check the acceptable media types against each renderer, # Check the acceptable media types against each renderer,
# attempting more specific media types first # attempting more specific media types first
# NB. The inner loop here isn't as bad as it first looks :) # 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] renderers = [renderer_cls(self) for renderer_cls in self.renderers]
for accepted_media_type_lst in order_by_precedence(accept_list): for accepted_media_type_lst in order_by_precedence(accept_list):
@ -303,10 +307,9 @@ class ResponseMixin(object):
return renderer, accepted_media_type return renderer, accepted_media_type
# No acceptable renderers were found # No acceptable renderers were found
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, error = {'detail': "Could not satisfy the client's Accept header",
{'detail': 'Could not satisfy the client\'s Accept header', 'available_types': self._rendered_media_types}
'available_types': self._rendered_media_types}) raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, error)
@property @property
def _rendered_media_types(self): def _rendered_media_types(self):
@ -334,39 +337,40 @@ class ResponseMixin(object):
class AuthMixin(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. 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 = () authentication = ()
""" """
The set of permissions that will be enforced on this view. 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 = () permissions = ()
@property @property
def user(self): def user(self):
""" """
Returns the :obj:`user` for the current request, as determined by the set of Returns the :obj:`user` for the current request, as determined by the
:class:`authentication` classes applied to the :class:`View`. set of :class:`authentication` classes applied to the :class:`View`.
""" """
if not hasattr(self, '_user'): if not hasattr(self, '_user'):
self._user = self._authenticate() self._user = self._authenticate()
return self._user return self._user
def _authenticate(self): def _authenticate(self):
""" """
Attempt to authenticate the request using each authentication class in turn. Attempt to authenticate the request using each authentication class in
Returns a ``User`` object, which may be ``AnonymousUser``. turn. Returns a ``User`` object, which may be ``AnonymousUser``.
""" """
for authentication_cls in self.authentication: for authentication_cls in self.authentication:
authentication = authentication_cls(self) authentication = authentication_cls(self)
@ -375,7 +379,6 @@ class AuthMixin(object):
return user return user
return AnonymousUser() return AnonymousUser()
# TODO: wrap this behavior around dispatch() # TODO: wrap this behavior around dispatch()
def _check_permissions(self): def _check_permissions(self):
""" """
@ -395,10 +398,12 @@ class ResourceMixin(object):
Should be a class as described in the :mod:`resources` module. 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, 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 resource = None
@ -407,7 +412,8 @@ class ResourceMixin(object):
""" """
Returns the cleaned, validated request content. 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'): if not hasattr(self, '_content'):
self._content = self.validate_request(self.DATA, self.FILES) self._content = self.validate_request(self.DATA, self.FILES)
@ -418,7 +424,8 @@ class ResourceMixin(object):
""" """
Returns the cleaned, validated query parameters. 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) return self.validate_request(self.request.GET)
@ -436,8 +443,10 @@ class ResourceMixin(object):
def validate_request(self, data, files=None): def validate_request(self, data, files=None):
""" """
Given the request *data* and optional *files*, return the cleaned, validated content. Given the request *data* and optional *files*, return the cleaned,
May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. validated content.
May raise an :class:`response.ErrorResponse` with status code 400
(Bad Request) on failure.
""" """
return self._resource.validate_request(data, files) return self._resource.validate_request(data, files)
@ -454,26 +463,28 @@ class ResourceMixin(object):
return None return None
########## ##########
class InstanceMixin(object): class InstanceMixin(object):
""" """
`Mixin` class that is used to identify a `View` class as being the canonical identifier `Mixin` class that is used to identify a `View` class as being the
for the resources it is mapped to. canonical identifier for the resources it is mapped to.
""" """
@classmethod @classmethod
def as_view(cls, **initkwargs): 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) view = super(InstanceMixin, cls).as_view(**initkwargs)
resource = getattr(cls(**initkwargs), 'resource', None) resource = getattr(cls(**initkwargs), 'resource', None)
if resource: if resource:
# We do a little dance when we store the view callable... # 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 # we need to store it wrapped in a 1-tuple, so that inspect will
# as a function when we later look it up (rather than turning it into a method). # 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. # This makes sure our URL reversing works ok.
resource.view_callable = (view,) resource.view_callable = (view,)
return view return view
@ -481,59 +492,57 @@ class InstanceMixin(object):
########## Model Mixins ########## ########## Model Mixins ##########
class ReadModelMixin(object):
""" class ModelMixin(object):
Behavior to read a `model` instance on GET requests def get_model(self):
""" """
def get(self, request, *args, **kwargs): Return the model class for this view.
model = self.resource.model """
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: try:
if args: return queryset.get(**kwargs)
# 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)
except model.DoesNotExist: 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 = {} m2m_data = {}
for field in model._meta.many_to_many:
for field in model._meta.fields: if field.name in kwargs:
if isinstance(field, ForeignKey) and kwargs.has_key(field.name): m2m_data[field.name] = (
# translate 'related_field' kwargs into 'related_field_id' field.m2m_reverse_field_name(), kwargs[field.name]
kwargs[field.name + '_id'] = kwargs[field.name] )
del kwargs[field.name] del kwargs[field.name]
for field in model._meta.many_to_many: instance = model(**kwargs)
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.save() instance.save()
for fieldname in m2m_data: for fieldname in m2m_data:
@ -549,93 +558,83 @@ class CreateModelMixin(object):
data[m2m_data[fieldname][0]] = related_item data[m2m_data[fieldname][0]] = related_item
manager.through(**data).save() manager.through(**data).save()
headers = {} return instance
if hasattr(instance, 'get_absolute_url'):
headers['Location'] = self.resource(self).url(instance)
return Response(status.HTTP_201_CREATED, instance, headers)
def update_instance(self, instance, *args, **kwargs):
for (key, val) in kwargs.items():
setattr(instance, key, val)
instance.save()
return instance
class UpdateModelMixin(object): def delete_instance(self, instance, *args, **kwargs):
"""
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, {})
instance.delete() instance.delete()
return return instance
def list_instances(self, *args, **kwargs):
class ListModelMixin(object): queryset = self.get_queryset()
""" ordering = self.get_ordering()
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
if ordering: if ordering:
args = as_tuple(ordering) queryset = queryset.order_by(ordering)
queryset = queryset.order_by(*args)
return queryset.filter(**kwargs) 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 ########## ########## Pagination Mixins ##########
@ -658,7 +657,7 @@ class PaginatorMixin(object):
return self.limit return self.limit
def url_with_page_number(self, page_number): 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) url = "%s?page=%d" % (self.request.path, page_number)
limit = self.get_limit() limit = self.get_limit()
@ -668,21 +667,21 @@ class PaginatorMixin(object):
return url return url
def next(self, page): 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(): if not page.has_next():
return None return None
return self.url_with_page_number(page.next_page_number()) return self.url_with_page_number(page.next_page_number())
def previous(self, page): 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(): if not page.has_previous():
return None return None
return self.url_with_page_number(page.previous_page_number()) return self.url_with_page_number(page.previous_page_number())
def serialize_page_info(self, page): 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 { return {
'next': self.next(page), 'next': self.next(page),
'page': page.number, 'page': page.number,
@ -696,14 +695,15 @@ class PaginatorMixin(object):
""" """
Given the response content, paginate and then serialize. Given the response content, paginate and then serialize.
The response is modified to include to useful data relating to the number The response is modified to include to useful data relating to the
of objects, number of pages, next/previous urls etc. etc. number of objects, number of pages, next/previous urls etc. etc.
The serialised objects are put into `results` on this new, modified The serialised objects are put into `results` on this new, modified
response 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': if self.method.upper() != 'GET':
return self._resource.filter_response(obj) return self._resource.filter_response(obj)

View File

@ -1,28 +1,19 @@
from django import forms from django import forms
from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch
from django.db import models 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.response import ErrorResponse
from djangorestframework.serializer import Serializer, _SkipField from djangorestframework.serializer import Serializer, _SkipField
from djangorestframework.utils import as_tuple
import decimal
import inspect
import re
class BaseResource(Serializer): 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 fields = ()
include = None include = ()
exclude = None exclude = ()
def __init__(self, view=None, depth=None, stack=[], **kwargs): def __init__(self, view=None, depth=None, stack=[], **kwargs):
super(BaseResource, self).__init__(depth, stack, **kwargs) super(BaseResource, self).__init__(depth, stack, **kwargs)
@ -31,7 +22,8 @@ class BaseResource(Serializer):
def validate_request(self, data, files=None): def validate_request(self, data, files=None):
""" """
Given the request content return the cleaned, validated content. 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 return data
@ -45,18 +37,20 @@ class BaseResource(Serializer):
class Resource(BaseResource): class Resource(BaseResource):
""" """
A Resource determines how a python object maps to some serializable data. 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 attribute refers to the Django Model which this Resource maps
# (The Model's class, rather than an instance of the Model) # to. (The Model's class, rather than an instance of the Model)
model = None model = None
# By default the set of returned fields will be the set of: # By default the set of returned fields will be the set of:
# #
# 0. All the fields on the model, excluding 'id'. # 0. All the fields on the model, excluding 'id'.
# 1. All the properties on the model. # 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, # If you wish to override this behaviour,
# you should explicitly set the fields attribute on your class. # you should explicitly set the fields attribute on your class.
@ -66,42 +60,51 @@ class Resource(BaseResource):
class FormResource(Resource): class FormResource(Resource):
""" """
Resource class that uses forms for validation. 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 On calling :meth:`validate_request` this validator may set a
view, which may be used by some renderers. :attr:`bound_form_instance` attribute on the view, which may be used by
some renderers.
""" """
form = None form = None
""" """
The :class:`Form` class that should be used for request validation. 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): def validate_request(self, data, files=None):
""" """
Given some content as input return some cleaned, validated content. 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. Validation is standard form validation, with an additional constraint
If the :obj:`'errors'` key exists it is a list of strings of non-field errors. that *no extra unknown fields* may be supplied.
If the :obj:`'field-errors'` key exists it is a dict of ``{'field name as string': ['errors as strings', ...]}``.
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) return self._validate(data, files)
def _validate(self, data, files, allowed_extra_fields=(), fake_data=None): 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 allowed_extra_fields is a list of fields which are not defined by the
expect to see on the input. 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 fake_data is a string that should be used as an extra key, as a kludge
to be populated when an empty dict is supplied in `data` 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. # 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... # 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 = 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 # Check using both regular validation, and our stricter no additional fields rule
if bound_form.is_valid() and not unknown_fields: if bound_form.is_valid() and not unknown_fields:
@ -178,7 +181,6 @@ class FormResource(Resource):
# Return HTTP 400 response (BAD REQUEST) # Return HTTP 400 response (BAD REQUEST)
raise ErrorResponse(400, detail) raise ErrorResponse(400, detail)
def get_form_class(self, method=None): def get_form_class(self, method=None):
""" """
Returns the form class used to validate this resource. Returns the form class used to validate this resource.
@ -217,7 +219,6 @@ class FormResource(Resource):
return form() return form()
#class _RegisterModelResource(type): #class _RegisterModelResource(type):
# """ # """
# Auto register new ModelResource classes into ``_model_to_resource`` # Auto register new ModelResource classes into ``_model_to_resource``
@ -230,11 +231,12 @@ class FormResource(Resource):
# return resource_cls # return resource_cls
class ModelResource(FormResource): class ModelResource(FormResource):
""" """
Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. Resource class that uses forms for validation and otherwise falls back to a
Also provides a :meth:`get_bound_form` method which may be used by some renderers. 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 # 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. The form class that should be used for request validation.
If set to :const:`None` then the default model form validation will be used. 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 model = None
""" """
The model class which this resource maps to. 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 fields = None
@ -261,22 +265,27 @@ class ModelResource(FormResource):
May be any of: 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 model.
The name of an attribute on the resource. 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 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') 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',) 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): 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 self.model = getattr(view, 'model', None) or self.model
def validate_request(self, data, files=None): def validate_request(self, data, files=None):
""" """
Given some content as input return some cleaned, validated content. 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, Validation is standard form or model form validation,
with an additional constraint that no extra unknown fields may be supplied, with an additional constraint that no extra unknown fields may be
and that all fields specified by the fields class attribute must be supplied, supplied, and that all fields specified by the fields class attribute
even if they are not validated by the form/model form. 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. On failure the ErrorResponse content is a dict which may contain
If the :obj:`'errors'` key exists it is a list of strings of non-field errors. :obj:`'errors'` and :obj:`'field-errors'` keys.
If the ''field-errors'` key exists it is a dict of {field name as string: list of errors as strings}. 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): def get_bound_form(self, data=None, files=None, method=None):
""" """
Given some content return a ``Form`` instance bound to that content. 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 If the :attr:`form` class attribute has been explicitly set then that
to create the Form, otherwise the model will be used to create a ModelForm. class will be used
to create the Form, otherwise the model will be used to create a
ModelForm.
""" """
form = self.get_form_class(method) form = self.get_form_class(method)
@ -339,14 +354,16 @@ class ModelResource(FormResource):
return form() return form()
def url(self, instance): 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'): if not hasattr(self, 'view_callable'):
@ -363,7 +380,9 @@ class ModelResource(FormResource):
# Note: defaults = tuple_item[2] for django >= 1.3 # Note: defaults = tuple_item[2] for django >= 1.3
for result, params in possibility: 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 = {} instance_attrs = {}
for param in params: for param in params:
@ -381,7 +400,6 @@ class ModelResource(FormResource):
pass pass
raise _SkipField raise _SkipField
@property @property
def _model_fields_set(self): def _model_fields_set(self):
""" """
@ -389,11 +407,12 @@ class ModelResource(FormResource):
""" """
model_fields = set(field.name for field in self.model._meta.fields) model_fields = set(field.name for field in self.model._meta.fields)
if fields: if self.fields:
return model_fields & set(as_tuple(self.fields)) return model_fields & set(self.fields)
return model_fields - set(as_tuple(self.exclude)) return model_fields - set(as_tuple(self.exclude))
@property @property
def _property_fields_set(self): def _property_fields_set(self):
""" """
@ -404,6 +423,6 @@ class ModelResource(FormResource):
and not attr.startswith('_')) and not attr.startswith('_'))
if self.fields: 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)

View File

@ -4,7 +4,7 @@ from django.utils import simplejson as json
from djangorestframework import status from djangorestframework import status
from djangorestframework.compat import RequestFactory from djangorestframework.compat import RequestFactory
from django.contrib.auth.models import Group, User 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.resources import ModelResource
from djangorestframework.response import Response from djangorestframework.response import Response
from djangorestframework.tests.models import CustomUser from djangorestframework.tests.models import CustomUser
@ -27,11 +27,11 @@ class TestModelCreation(TestModelsTestCase):
form_data = {'name': 'foo'} form_data = {'name': 'foo'}
request = self.req.post('/groups', data=form_data) request = self.req.post('/groups', data=form_data)
mixin = CreateModelMixin() mixin = ModelMixin()
mixin.resource = GroupResource mixin.resource = GroupResource
mixin.CONTENT = form_data mixin.CONTENT = form_data
response = mixin.post(request) response = mixin.create(request)
self.assertEquals(1, Group.objects.count()) self.assertEquals(1, Group.objects.count())
self.assertEquals('foo', response.cleaned_content.name) self.assertEquals('foo', response.cleaned_content.name)
@ -53,11 +53,11 @@ class TestModelCreation(TestModelsTestCase):
request = self.req.post('/groups', data=form_data) request = self.req.post('/groups', data=form_data)
cleaned_data = dict(form_data) cleaned_data = dict(form_data)
cleaned_data['groups'] = [group] cleaned_data['groups'] = [group]
mixin = CreateModelMixin() mixin = ModelMixin()
mixin.resource = UserResource mixin.resource = UserResource
mixin.CONTENT = cleaned_data mixin.CONTENT = cleaned_data
response = mixin.post(request) response = mixin.create(request)
self.assertEquals(1, User.objects.count()) self.assertEquals(1, User.objects.count())
self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals(1, response.cleaned_content.groups.count())
self.assertEquals('foo', response.cleaned_content.groups.all()[0].name) 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) request = self.req.post('/groups', data=form_data)
cleaned_data = dict(form_data) cleaned_data = dict(form_data)
cleaned_data['groups'] = [] cleaned_data['groups'] = []
mixin = CreateModelMixin() mixin = ModelMixin()
mixin.resource = UserResource mixin.resource = UserResource
mixin.CONTENT = cleaned_data mixin.CONTENT = cleaned_data
response = mixin.post(request) response = mixin.create(request)
self.assertEquals(1, CustomUser.objects.count()) self.assertEquals(1, CustomUser.objects.count())
self.assertEquals(0, response.cleaned_content.groups.count()) self.assertEquals(0, response.cleaned_content.groups.count())
@ -91,11 +91,11 @@ class TestModelCreation(TestModelsTestCase):
request = self.req.post('/groups', data=form_data) request = self.req.post('/groups', data=form_data)
cleaned_data = dict(form_data) cleaned_data = dict(form_data)
cleaned_data['groups'] = [group] cleaned_data['groups'] = [group]
mixin = CreateModelMixin() mixin = ModelMixin()
mixin.resource = UserResource mixin.resource = UserResource
mixin.CONTENT = cleaned_data mixin.CONTENT = cleaned_data
response = mixin.post(request) response = mixin.create(request)
self.assertEquals(2, CustomUser.objects.count()) self.assertEquals(2, CustomUser.objects.count())
self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals(1, response.cleaned_content.groups.count())
self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) 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) request = self.req.post('/groups', data=form_data)
cleaned_data = dict(form_data) cleaned_data = dict(form_data)
cleaned_data['groups'] = [group, group2] cleaned_data['groups'] = [group, group2]
mixin = CreateModelMixin() mixin = ModelMixin()
mixin.resource = UserResource mixin.resource = UserResource
mixin.CONTENT = cleaned_data mixin.CONTENT = cleaned_data
response = mixin.post(request) response = mixin.create(request)
self.assertEquals(3, CustomUser.objects.count()) self.assertEquals(3, CustomUser.objects.count())
self.assertEquals(2, response.cleaned_content.groups.count()) self.assertEquals(2, response.cleaned_content.groups.count())
self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name)

View File

@ -156,7 +156,8 @@ class RendererIntegrationTests(TestCase):
self.assertEquals(resp.status_code, DUMMYSTATUS) self.assertEquals(resp.status_code, DUMMYSTATUS)
_flat_repr = '{"foo": ["bar", "baz"]}' _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): class JSONRendererTests(TestCase):
@ -179,7 +180,7 @@ class JSONRendererTests(TestCase):
""" """
obj = {'foo': ['bar', 'baz']} obj = {'foo': ['bar', 'baz']}
renderer = JSONRenderer(None) renderer = JSONRenderer(None)
content = renderer.render(obj, 'application/json; indent=2') content = renderer.render(obj, 'application/json; indent=4')
self.assertEquals(content, _indented_repr) self.assertEquals(content, _indented_repr)
def test_render_and_parse(self): def test_render_and_parse(self):
@ -239,6 +240,7 @@ class JSONPRendererTests(TestCase):
if YAMLRenderer: if YAMLRenderer:
_yaml_repr = 'foo: [bar, baz]\n' _yaml_repr = 'foo: [bar, baz]\n'
class YAMLRendererTests(TestCase): class YAMLRendererTests(TestCase):
""" """
Tests specific to the JSON Renderer Tests specific to the JSON Renderer
@ -253,6 +255,7 @@ if YAMLRenderer:
content = renderer.render(obj, 'application/yaml') content = renderer.render(obj, 'application/yaml')
self.assertEquals(content, _yaml_repr) self.assertEquals(content, _yaml_repr)
def test_render_and_parse(self): def test_render_and_parse(self):
""" """
Test rendering and then parsing returns the original object. Test rendering and then parsing returns the original object.

View File

@ -25,7 +25,6 @@ __all__ = (
) )
class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
""" """
Handles incoming requests and maps them to REST operations. 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. List of all authenticating methods to attempt.
""" """
authentication = ( authentication.UserLoggedInAuthentication, authentication = (authentication.UserLoggedInAuthentication,
authentication.BasicAuthentication ) authentication.BasicAuthentication)
""" """
List of all permissions that must be checked. List of all permissions that must be checked.
""" """
permissions = ( permissions.FullAnonAccess, ) permissions = (permissions.FullAnonAccess,)
@classmethod @classmethod
def as_view(cls, **initkwargs): def as_view(cls, **initkwargs):
@ -71,7 +69,6 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
view.cls_instance = cls(**initkwargs) view.cls_instance = cls(**initkwargs)
return view return view
@property @property
def allowed_methods(self): 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)] return [method.upper() for method in self.http_method_names if hasattr(self, method)]
def http_method_not_allowed(self, request, *args, **kwargs): 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. 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, raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED,
{'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) {'detail': 'Method \'%s\' not allowed on this resource.' % self.method})
def initial(self, request, *args, **kargs): def initial(self, request, *args, **kargs):
""" """
Hook for any code that needs to run prior to anything else. 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 Required if you want to do things like set `request.upload_handlers`
the authentication and dispatch handling is run. before the authentication and dispatch handling is run.
""" """
pass pass
def add_header(self, field, value): def add_header(self, field, value):
""" """
Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class.
""" """
self.headers[field] = value self.headers[field] = value
# Note: session based authentication is explicitly CSRF validated, # Note: session based authentication is explicitly CSRF validated,
# all other authentication is CSRF exempt. # all other authentication is CSRF exempt.
@csrf_exempt @csrf_exempt
@ -143,11 +136,13 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
response = Response(status.HTTP_204_NO_CONTENT) response = Response(status.HTTP_204_NO_CONTENT)
if request.method == 'OPTIONS': 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 # as they do not correspond with model fields
response.cleaned_content = response.raw_content response.cleaned_content = response.raw_content
else: 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) response.cleaned_content = self.filter_response(response.raw_content)
except ErrorResponse, exc: except ErrorResponse, exc:
@ -155,8 +150,8 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
# Always add these headers. # Always add these headers.
# #
# TODO - this isn't actually the correct way to set the vary header, # TODO - this isn't really the correct way to set the Vary header,
# also it's currently sub-optimal for HTTP caching - need to sort that out. # also it's currently sub-optimal for HTTP caching.
response.headers['Allow'] = ', '.join(self.allowed_methods) response.headers['Allow'] = ', '.join(self.allowed_methods)
response.headers['Vary'] = 'Authenticate, Accept' response.headers['Vary'] = 'Authenticate, Accept'
@ -168,7 +163,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
return self.render(response) return self.render(response)
def options(self, request, *args, **kwargs): def options(self, request, *args, **kwargs):
response_obj = { ret = {
'name': get_name(self), 'name': get_name(self),
'description': get_description(self), 'description': get_description(self),
'renders': self._rendered_media_types, 'renders': self._rendered_media_types,
@ -179,30 +174,46 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
field_name_types = {} field_name_types = {}
for name, field in form.fields.iteritems(): for name, field in form.fields.iteritems():
field_name_types[name] = field.__class__.__name__ field_name_types[name] = field.__class__.__name__
response_obj['fields'] = field_name_types ret['fields'] = field_name_types
return response_obj return ret
class ModelView(View): class ModelView(ModelMixin, View):
""" """
A RESTful view that maps to a model in the database. A RESTful view that maps to a model in the database.
""" """
resource = resources.ModelResource 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' _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' _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' _suffix = 'List'
get = ModelMixin.list
post = ModelMixin.create