diff options
| author | Tom Christie | 2013-04-23 11:59:13 +0100 |
|---|---|---|
| committer | Tom Christie | 2013-04-23 11:59:13 +0100 |
| commit | 835d3f89d37b873b2ef96dc7d71922b035b07328 (patch) | |
| tree | bee0cdaac6e4fcaefd7fd83e9b9210d103e379af /rest_framework | |
| parent | 4bf1a09baeb885863e6028b97c2d51b26fb18534 (diff) | |
| parent | d75cebf75696602170a9d282d4b114d01d6e5d8e (diff) | |
| download | django-rest-framework-835d3f89d37b873b2ef96dc7d71922b035b07328.tar.bz2 | |
Merge remove-django-generics
Diffstat (limited to 'rest_framework')
| -rw-r--r-- | rest_framework/compat.py | 20 | ||||
| -rw-r--r-- | rest_framework/decorators.py | 22 | ||||
| -rw-r--r-- | rest_framework/generics.py | 271 | ||||
| -rw-r--r-- | rest_framework/mixins.py | 14 | ||||
| -rw-r--r-- | rest_framework/renderers.py | 11 | ||||
| -rw-r--r-- | rest_framework/routers.py | 81 | ||||
| -rw-r--r-- | rest_framework/tests/description.py | 63 | ||||
| -rw-r--r-- | rest_framework/tests/filterset.py | 6 | ||||
| -rw-r--r-- | rest_framework/utils/breadcrumbs.py | 5 | ||||
| -rw-r--r-- | rest_framework/utils/formatting.py | 77 | ||||
| -rw-r--r-- | rest_framework/views.py | 97 | ||||
| -rw-r--r-- | rest_framework/viewsets.py | 87 |
12 files changed, 521 insertions, 233 deletions
diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 067e9018..3828555b 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -87,9 +87,7 @@ else: raise ImportError("User model is not to be found.") -# First implementation of Django class-based views did not include head method -# in base View class - https://code.djangoproject.com/ticket/15668 -if django.VERSION >= (1, 4): +if django.VERSION >= (1, 5): from django.views.generic import View else: from django.views.generic import View as _View @@ -97,6 +95,8 @@ else: from django.utils.functional import update_wrapper class View(_View): + # 1.3 does not include head method in base View class + # See: https://code.djangoproject.com/ticket/15668 @classonlymethod def as_view(cls, **initkwargs): """ @@ -126,11 +126,15 @@ else: update_wrapper(view, cls.dispatch, assigned=()) return view -# Taken from @markotibold's attempt at supporting PATCH. -# https://github.com/markotibold/django-rest-framework/tree/patch -http_method_names = set(View.http_method_names) -http_method_names.add('patch') -View.http_method_names = list(http_method_names) # PATCH method is not implemented by Django + # _allowed_methods only present from 1.5 onwards + def _allowed_methods(self): + return [m.upper() for m in self.http_method_names if hasattr(self, m)] + + +# PATCH method is not implemented by Django +if 'patch' not in View.http_method_names: + View.http_method_names = View.http_method_names + ['patch'] + # PUT, DELETE do not require CSRF until 1.4. They should. Make it better. if django.VERSION >= (1, 4): diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 8250cd3b..00b37f8b 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -97,3 +97,25 @@ def permission_classes(permission_classes): func.permission_classes = permission_classes return func return decorator + + +def link(**kwargs): + """ + Used to mark a method on a ViewSet that should be routed for GET requests. + """ + def decorator(func): + func.bind_to_method = 'get' + func.kwargs = kwargs + return func + return decorator + + +def action(**kwargs): + """ + Used to mark a method on a ViewSet that should be routed for POST requests. + """ + def decorator(func): + func.bind_to_method = 'post' + func.kwargs = kwargs + return func + return decorator diff --git a/rest_framework/generics.py b/rest_framework/generics.py index f9133c73..ae03060b 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -4,30 +4,41 @@ Generic views that provide commonly needed behaviour. from __future__ import unicode_literals from rest_framework import views, mixins from rest_framework.settings import api_settings -from django.views.generic.detail import SingleObjectMixin -from django.views.generic.list import MultipleObjectMixin - +from django.core.exceptions import ImproperlyConfigured +from django.core.paginator import Paginator, InvalidPage +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext as _ ### Base classes for the generic views ### + class GenericAPIView(views.APIView): """ Base class for all other generic views. """ - model = None + queryset = None serializer_class = None - model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + + # Shortcut which may be used in place of `queryset`/`serializer_class` + model = None + filter_backend = api_settings.FILTER_BACKEND + paginate_by = api_settings.PAGINATE_BY + paginate_by_param = api_settings.PAGINATE_BY_PARAM + pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS + model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + page_kwarg = 'page' + lookup_field = 'pk' + allow_empty = True - def filter_queryset(self, queryset): - """ - Given a queryset, filter it with whichever filter backend is in use. - """ - if not self.filter_backend: - return queryset - backend = self.filter_backend() - return backend.filter_queryset(self.request, queryset, self) + ###################################### + # These are pending deprecation... + + pk_url_kwarg = 'pk' + slug_url_kwarg = 'slug' + slug_field = 'slug' def get_serializer_context(self): """ @@ -39,24 +50,6 @@ class GenericAPIView(views.APIView): 'view': self } - def get_serializer_class(self): - """ - Return the class to use for the serializer. - - Defaults to using `self.serializer_class`, falls back to constructing a - model serializer class using `self.model_serializer_class`, with - `self.model` as the model. - """ - serializer_class = self.serializer_class - - if serializer_class is None: - class DefaultSerializer(self.model_serializer_class): - class Meta: - model = self.model - serializer_class = DefaultSerializer - - return serializer_class - def get_serializer(self, instance=None, data=None, files=None, many=False, partial=False): """ @@ -68,30 +61,6 @@ class GenericAPIView(views.APIView): return serializer_class(instance, data=data, files=files, many=many, partial=partial, context=context) - def pre_save(self, obj): - """ - Placeholder method for calling before saving an object. - May be used eg. to set attributes on the object that are implicit - in either the request, or the url. - """ - pass - - def post_save(self, obj, created=False): - """ - Placeholder method for calling after saving an object. - """ - pass - - -class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): - """ - Base class for generic views onto a queryset. - """ - - paginate_by = api_settings.PAGINATE_BY - paginate_by_param = api_settings.PAGINATE_BY_PARAM - pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS - def get_pagination_serializer(self, page=None): """ Return a serializer instance to use with paginated data. @@ -104,9 +73,14 @@ class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): context = self.get_serializer_context() return pagination_serializer_class(instance=page, context=context) - def get_paginate_by(self, queryset): + def get_paginate_by(self, queryset=None): """ Return the size of pages to use with pagination. + + If `PAGINATE_BY_PARAM` is set it will attempt to get the page size + from a named query parameter in the url, eg. ?page_size=100 + + Otherwise defaults to using `self.paginate_by`. """ if self.paginate_by_param: query_params = self.request.QUERY_PARAMS @@ -114,32 +88,156 @@ class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): return int(query_params[self.paginate_by_param]) except (KeyError, ValueError): pass + return self.paginate_by + def paginate_queryset(self, queryset, page_size, paginator_class=Paginator): + """ + Paginate a queryset. + """ + paginator = paginator_class(queryset, page_size, + allow_empty_first_page=self.allow_empty) + page_kwarg = self.page_kwarg + page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 + try: + page_number = int(page) + except ValueError: + if page == 'last': + page_number = paginator.num_pages + else: + raise Http404(_("Page is not 'last', nor can it be converted to an int.")) + try: + page = paginator.page(page_number) + return (paginator, page, page.object_list, page.has_other_pages()) + except InvalidPage as e: + raise Http404(_('Invalid page (%(page_number)s): %(message)s') % { + 'page_number': page_number, + 'message': str(e) + }) + + def filter_queryset(self, queryset): + """ + Given a queryset, filter it with whichever filter backend is in use. -class SingleObjectAPIView(SingleObjectMixin, GenericAPIView): - """ - Base class for generic views onto a model instance. - """ + You are unlikely to want to override this method, although you may need + to call it either from a list view, or from a custom `get_object` + method if you want to apply the configured filtering backend to the + default queryset. + """ + if not self.filter_backend: + return queryset + backend = self.filter_backend() + return backend.filter_queryset(self.request, queryset, self) - pk_url_kwarg = 'pk' # Not provided in Django 1.3 - slug_url_kwarg = 'slug' # Not provided in Django 1.3 - slug_field = 'slug' + ######################## + ### The following methods provide default implementations + ### that you may want to override for more complex cases. + + def get_serializer_class(self): + """ + Return the class to use for the serializer. + Defaults to using `self.serializer_class`. + + You may want to override this if you need to provide different + serializations depending on the incoming request. + + (Eg. admins get full serialization, others get basic serilization) + """ + serializer_class = self.serializer_class + if serializer_class is not None: + return serializer_class + + # TODO: Deprecation warning + class DefaultSerializer(self.model_serializer_class): + class Meta: + model = self.model + return DefaultSerializer + + def get_queryset(self): + """ + Get the list of items for this view. + This must be an iterable, and may be a queryset. + Defaults to using `self.queryset`. + + You may want to override this if you need to provide different + querysets depending on the incoming request. + + (Eg. return a list of items that is specific to the user) + """ + if self.queryset is not None: + return self.queryset._clone() + + if self.model is not None: + # TODO: Deprecation warning + return self.model._default_manager.all() + + raise ImproperlyConfigured("'%s' must define 'queryset' or 'model'" + % self.__class__.__name__) def get_object(self, queryset=None): """ - Override default to add support for object-level permissions. + Returns the object the view is displaying. + + You may want to override this if you need to provide non-standard + queryset lookups. Eg if objects are referenced using multiple + keyword arguments in the url conf. """ - queryset = self.filter_queryset(self.get_queryset()) - obj = super(SingleObjectAPIView, self).get_object(queryset) + # Determine the base queryset to use. + if queryset is None: + queryset = self.filter_queryset(self.get_queryset()) + else: + pass # Deprecation warning + + # Perform the lookup filtering. + pk = self.kwargs.get(self.pk_url_kwarg, None) + slug = self.kwargs.get(self.slug_url_kwarg, None) + lookup = self.kwargs.get(self.lookup_field, None) + + if lookup is not None: + filter_kwargs = {self.lookup_field: lookup} + elif pk is not None: + # TODO: Deprecation warning + filter_kwargs = {'pk': pk} + elif slug is not None: + # TODO: Deprecation warning + filter_kwargs = {self.slug_field: slug} + else: + raise AttributeError("Generic detail view %s must be called with " + "either an object pk or a slug." + % self.__class__.__name__) + + obj = get_object_or_404(queryset, **filter_kwargs) + + # May raise a permission denied self.check_object_permissions(self.request, obj) + return obj + ######################## + ### The following are placeholder methods, + ### and are intended to be overridden. -### Concrete view classes that provide method handlers ### -### by composing the mixin classes with a base view. ### + def pre_save(self, obj): + """ + Placeholder method for calling before saving an object. + + May be used to set attributes on the object that are implicit + in either the request, or the url. + """ + pass + + def post_save(self, obj, created=False): + """ + Placeholder method for calling after saving an object. + """ + pass +########################################################## +### Concrete view classes that provide method handlers ### +### by composing the mixin classes with the base view. ### +########################################################## + class CreateAPIView(mixins.CreateModelMixin, GenericAPIView): @@ -151,7 +249,7 @@ class CreateAPIView(mixins.CreateModelMixin, class ListAPIView(mixins.ListModelMixin, - MultipleObjectAPIView): + GenericAPIView): """ Concrete view for listing a queryset. """ @@ -160,7 +258,7 @@ class ListAPIView(mixins.ListModelMixin, class RetrieveAPIView(mixins.RetrieveModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for retrieving a model instance. """ @@ -169,7 +267,7 @@ class RetrieveAPIView(mixins.RetrieveModelMixin, class DestroyAPIView(mixins.DestroyModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for deleting a model instance. @@ -179,7 +277,7 @@ class DestroyAPIView(mixins.DestroyModelMixin, class UpdateAPIView(mixins.UpdateModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for updating a model instance. @@ -188,13 +286,12 @@ class UpdateAPIView(mixins.UpdateModelMixin, return self.update(request, *args, **kwargs) def patch(self, request, *args, **kwargs): - kwargs['partial'] = True - return self.update(request, *args, **kwargs) + return self.partial_update(request, *args, **kwargs) class ListCreateAPIView(mixins.ListModelMixin, mixins.CreateModelMixin, - MultipleObjectAPIView): + GenericAPIView): """ Concrete view for listing a queryset or creating a model instance. """ @@ -207,7 +304,7 @@ class ListCreateAPIView(mixins.ListModelMixin, class RetrieveUpdateAPIView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for retrieving, updating a model instance. """ @@ -218,13 +315,12 @@ class RetrieveUpdateAPIView(mixins.RetrieveModelMixin, return self.update(request, *args, **kwargs) def patch(self, request, *args, **kwargs): - kwargs['partial'] = True - return self.update(request, *args, **kwargs) + return self.partial_update(request, *args, **kwargs) class RetrieveDestroyAPIView(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for retrieving or deleting a model instance. """ @@ -238,7 +334,7 @@ class RetrieveDestroyAPIView(mixins.RetrieveModelMixin, class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, - SingleObjectAPIView): + GenericAPIView): """ Concrete view for retrieving, updating or deleting a model instance. """ @@ -249,8 +345,19 @@ class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, return self.update(request, *args, **kwargs) def patch(self, request, *args, **kwargs): - kwargs['partial'] = True - return self.update(request, *args, **kwargs) + return self.partial_update(request, *args, **kwargs) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) + + +########################## +### Deprecated classes ### +########################## + +class MultipleObjectAPIView(GenericAPIView): + pass + + +class SingleObjectAPIView(GenericAPIView): + pass diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 3bd7d6df..6e40b5c4 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -67,20 +67,18 @@ class ListModelMixin(object): empty_error = "Empty list and '%(class_name)s.allow_empty' is False." def list(self, request, *args, **kwargs): - queryset = self.get_queryset() - self.object_list = self.filter_queryset(queryset) + self.object_list = self.filter_queryset(self.get_queryset()) # Default is to allow empty querysets. This can be altered by setting # `.allow_empty = False`, to raise 404 errors on empty querysets. - allow_empty = self.get_allow_empty() - if not allow_empty and not self.object_list: + if not self.allow_empty and not self.object_list: class_name = self.__class__.__name__ error_msg = self.empty_error % {'class_name': class_name} raise Http404(error_msg) # Pagination size is set by the `.paginate_by` attribute, # which may be `None` to disable pagination. - page_size = self.get_paginate_by(self.object_list) + page_size = self.get_paginate_by() if page_size: packed = self.paginate_queryset(self.object_list, page_size) paginator, page, queryset, is_paginated = packed @@ -135,6 +133,10 @@ class UpdateModelMixin(object): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.update(request, *args, **kwargs) + def pre_save(self, obj): """ Set any attributes on the object that are implicit in the request. @@ -142,7 +144,7 @@ class UpdateModelMixin(object): # pk and/or slug attributes are implicit in the URL. pk = self.kwargs.get(self.pk_url_kwarg, None) slug = self.kwargs.get(self.slug_url_kwarg, None) - slug_field = slug and self.get_slug_field() or None + slug_field = slug and self.slug_field or None if pk: setattr(obj, 'pk', pk) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 4c15e0db..752306ad 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -24,6 +24,7 @@ from rest_framework.settings import api_settings from rest_framework.request import clone_request from rest_framework.utils import encoders from rest_framework.utils.breadcrumbs import get_breadcrumbs +from rest_framework.utils.formatting import get_view_name, get_view_description from rest_framework import exceptions, parsers, status, VERSION @@ -438,16 +439,10 @@ class BrowsableAPIRenderer(BaseRenderer): return GenericContentForm() def get_name(self, view): - try: - return view.get_name() - except AttributeError: - return smart_text(view.__class__.__name__) + return get_view_name(view.__class__) def get_description(self, view): - try: - return view.get_description(html=True) - except AttributeError: - return smart_text(view.__doc__ or '') + return get_view_description(view.__class__, html=True) def render(self, data, accepted_media_type=None, renderer_context=None): """ diff --git a/rest_framework/routers.py b/rest_framework/routers.py new file mode 100644 index 00000000..afc51f3b --- /dev/null +++ b/rest_framework/routers.py @@ -0,0 +1,81 @@ +from django.conf.urls import url, patterns + + +class BaseRouter(object): + def __init__(self): + self.registry = [] + + def register(self, prefix, viewset, base_name): + self.registry.append((prefix, viewset, base_name)) + + def get_urlpatterns(self): + raise NotImplemented('get_urlpatterns must be overridden') + + @property + def urlpatterns(self): + if not hasattr(self, '_urlpatterns'): + self._urlpatterns = patterns('', *self.get_urlpatterns()) + return self._urlpatterns + + +class DefaultRouter(BaseRouter): + route_list = [ + (r'$', { + 'get': 'list', + 'post': 'create' + }, 'list'), + (r'(?P<pk>[^/]+)/$', { + 'get': 'retrieve', + 'put': 'update', + 'patch': 'partial_update', + 'delete': 'destroy' + }, 'detail'), + ] + extra_routes = r'(?P<pk>[^/]+)/%s/$' + name_format = '%s-%s' + + def get_urlpatterns(self): + ret = [] + for prefix, viewset, base_name in self.registry: + # Bind regular views + if not getattr(viewset, '_is_viewset', False): + regex = prefix + view = viewset + name = base_name + ret.append(url(regex, view, name=name)) + continue + + # Bind standard CRUD routes + for suffix, action_mapping, action_name in self.route_list: + + # Only actions which actually exist on the viewset will be bound + bound_actions = {} + for method, action in action_mapping.items(): + if hasattr(viewset, action): + bound_actions[method] = action + + # Build the url pattern + regex = prefix + suffix + view = viewset.as_view(bound_actions, name_suffix=action_name) + name = self.name_format % (base_name, action_name) + ret.append(url(regex, view, name=name)) + + # Bind any extra `@action` or `@link` routes + for action_name in dir(viewset): + func = getattr(viewset, action_name) + http_method = getattr(func, 'bind_to_method', None) + + # Skip if this is not an @action or @link method + if not http_method: + continue + + suffix = self.extra_routes % action_name + + # Build the url pattern + regex = prefix + suffix + view = viewset.as_view({http_method: action_name}, **func.kwargs) + name = self.name_format % (base_name, action_name) + ret.append(url(regex, view, name=name)) + + # Return a list of url patterns + return ret diff --git a/rest_framework/tests/description.py b/rest_framework/tests/description.py index 5b3315bc..52c1a34c 100644 --- a/rest_framework/tests/description.py +++ b/rest_framework/tests/description.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals from django.test import TestCase from rest_framework.views import APIView from rest_framework.compat import apply_markdown +from rest_framework.utils.formatting import get_view_name, get_view_description # We check that docstrings get nicely un-indented. DESCRIPTION = """an example docstring @@ -49,22 +50,16 @@ MARKED_DOWN_gte_21 = """<h2 id="an-example-docstring">an example docstring</h2> class TestViewNamesAndDescriptions(TestCase): - def test_resource_name_uses_classname_by_default(self): - """Ensure Resource names are based on the classname by default.""" + def test_view_name_uses_class_name(self): + """ + Ensure view names are based on the class name. + """ class MockView(APIView): pass - self.assertEqual(MockView().get_name(), 'Mock') + self.assertEqual(get_view_name(MockView), 'Mock') - def test_resource_name_can_be_set_explicitly(self): - """Ensure Resource names can be set using the 'get_name' method.""" - example = 'Some Other Name' - class MockView(APIView): - def get_name(self): - return example - self.assertEqual(MockView().get_name(), example) - - def test_resource_description_uses_docstring_by_default(self): - """Ensure Resource names are based on the docstring by default.""" + def test_view_description_uses_docstring(self): + """Ensure view descriptions are based on the docstring.""" class MockView(APIView): """an example docstring ==================== @@ -81,44 +76,32 @@ class TestViewNamesAndDescriptions(TestCase): # hash style header #""" - self.assertEqual(MockView().get_description(), DESCRIPTION) - - def test_resource_description_can_be_set_explicitly(self): - """Ensure Resource descriptions can be set using the 'get_description' method.""" - example = 'Some other description' - - class MockView(APIView): - """docstring""" - def get_description(self): - return example - self.assertEqual(MockView().get_description(), example) + self.assertEqual(get_view_description(MockView), DESCRIPTION) - def test_resource_description_supports_unicode(self): + def test_view_description_supports_unicode(self): + """ + Unicode in docstrings should be respected. + """ class MockView(APIView): """Проверка""" pass - self.assertEqual(MockView().get_description(), "Проверка") - - - def test_resource_description_does_not_require_docstring(self): - """Ensure that empty docstrings do not affect the Resource's description if it has been set using the 'get_description' method.""" - example = 'Some other description' - - class MockView(APIView): - def get_description(self): - return example - self.assertEqual(MockView().get_description(), example) + self.assertEqual(get_view_description(MockView), "Проверка") - def test_resource_description_can_be_empty(self): - """Ensure that if a resource has no doctring or 'description' class attribute, then it's description is the empty string.""" + def test_view_description_can_be_empty(self): + """ + Ensure that if a view has no docstring, + then it's description is the empty string. + """ class MockView(APIView): pass - self.assertEqual(MockView().get_description(), '') + self.assertEqual(get_view_description(MockView), '') def test_markdown(self): - """Ensure markdown to HTML works as expected""" + """ + Ensure markdown to HTML works as expected. + """ if apply_markdown: gte_21_match = apply_markdown(DESCRIPTION) == MARKED_DOWN_gte_21 lt_21_match = apply_markdown(DESCRIPTION) == MARKED_DOWN_lt_21 diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 1a71558c..1e53a5cd 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -61,7 +61,7 @@ if django_filters: class CommonFilteringTestCase(TestCase): def _serialize_object(self, obj): return {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} - + def setUp(self): """ Create 10 FilterableItem instances. @@ -190,7 +190,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): Integration tests for filtered detail views. """ urls = 'rest_framework.tests.filterset' - + def _get_url(self, item): return reverse('detail-view', kwargs=dict(pk=item.pk)) @@ -221,7 +221,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(low_item), param=search_decimal)) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, low_item_data) - + # Tests that multiple filters works. search_decimal = Decimal('5.25') search_date = datetime.date(2012, 10, 2) diff --git a/rest_framework/utils/breadcrumbs.py b/rest_framework/utils/breadcrumbs.py index af21ac79..18b3b207 100644 --- a/rest_framework/utils/breadcrumbs.py +++ b/rest_framework/utils/breadcrumbs.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals from django.core.urlresolvers import resolve, get_script_prefix +from rest_framework.utils.formatting import get_view_name def get_breadcrumbs(url): @@ -16,11 +17,11 @@ def get_breadcrumbs(url): pass else: # Check if this is a REST framework view, and if so add it to the breadcrumbs - if isinstance(getattr(view, 'cls_instance', None), APIView): + if issubclass(getattr(view, 'cls', None), APIView): # Don't list the same view twice in a row. # Probably an optional trailing slash. if not seen or seen[-1] != view: - breadcrumbs_list.insert(0, (view.cls_instance.get_name(), prefix + url)) + breadcrumbs_list.insert(0, (get_view_name(view.cls), prefix + url)) seen.append(view) if url == '': diff --git a/rest_framework/utils/formatting.py b/rest_framework/utils/formatting.py new file mode 100644 index 00000000..79566db1 --- /dev/null +++ b/rest_framework/utils/formatting.py @@ -0,0 +1,77 @@ +""" +Utility functions to return a formatted name and description for a given view. +""" +from __future__ import unicode_literals + +from django.utils.html import escape +from django.utils.safestring import mark_safe +from rest_framework.compat import apply_markdown +import re + + +def _remove_trailing_string(content, trailing): + """ + Strip trailing component `trailing` from `content` if it exists. + Used when generating names from view classes. + """ + if content.endswith(trailing) and content != trailing: + return content[:-len(trailing)] + return content + + +def _remove_leading_indent(content): + """ + Remove leading indent from a block of text. + Used when generating descriptions from docstrings. + """ + whitespace_counts = [len(line) - len(line.lstrip(' ')) + for line in content.splitlines()[1:] if line.lstrip()] + + # unindent the content if needed + if whitespace_counts: + whitespace_pattern = '^' + (' ' * min(whitespace_counts)) + content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', content) + content = content.strip('\n') + return content + + +def _camelcase_to_spaces(content): + """ + Translate 'CamelCaseNames' to 'Camel Case Names'. + Used when generating names from view classes. + """ + camelcase_boundry = '(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))' + content = re.sub(camelcase_boundry, ' \\1', content).strip() + return ' '.join(content.split('_')).title() + + +def get_view_name(cls): + """ + Return a formatted name for an `APIView` class or `@api_view` function. + """ + name = cls.__name__ + name = _remove_trailing_string(name, 'View') + name = _remove_trailing_string(name, 'ViewSet') + return _camelcase_to_spaces(name) + + +def get_view_description(cls, html=False): + """ + Return a description for an `APIView` class or `@api_view` function. + """ + description = cls.__doc__ or '' + description = _remove_leading_indent(description) + if html: + return markup_description(description) + return description + + +def markup_description(description): + """ + Apply HTML markup to the given description. + """ + if apply_markdown: + description = apply_markdown(description) + else: + description = escape(description).replace('\n', '<br />') + return mark_safe(description) diff --git a/rest_framework/views.py b/rest_framework/views.py index 7c97607b..b8e948e0 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -4,51 +4,13 @@ Provides an APIView class that is used as the base of all class-based views. from __future__ import unicode_literals from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse -from django.utils.html import escape -from django.utils.safestring import mark_safe from django.views.decorators.csrf import csrf_exempt from rest_framework import status, exceptions -from rest_framework.compat import View, apply_markdown +from rest_framework.compat import View from rest_framework.response import Response from rest_framework.request import Request from rest_framework.settings import api_settings -import re - - -def _remove_trailing_string(content, trailing): - """ - Strip trailing component `trailing` from `content` if it exists. - Used when generating names from view classes. - """ - if content.endswith(trailing) and content != trailing: - return content[:-len(trailing)] - return content - - -def _remove_leading_indent(content): - """ - Remove leading indent from a block of text. - Used when generating descriptions from docstrings. - """ - whitespace_counts = [len(line) - len(line.lstrip(' ')) - for line in content.splitlines()[1:] if line.lstrip()] - - # unindent the content if needed - if whitespace_counts: - whitespace_pattern = '^' + (' ' * min(whitespace_counts)) - content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', content) - content = content.strip('\n') - return content - - -def _camelcase_to_spaces(content): - """ - Translate 'CamelCaseNames' to 'Camel Case Names'. - Used when generating names from view classes. - """ - camelcase_boundry = '(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))' - content = re.sub(camelcase_boundry, ' \\1', content).strip() - return ' '.join(content.split('_')).title() +from rest_framework.utils.formatting import get_view_name, get_view_description class APIView(View): @@ -64,22 +26,21 @@ class APIView(View): @classmethod def as_view(cls, **initkwargs): """ - Override the default :meth:`as_view` to store an instance of the view - as an attribute on the callable function. This allows us to discover - information about the view when we do URL reverse lookups. + Store the original class on the view function. + + This allows us to discover information about the view when we do URL + reverse lookups. Used for breadcrumb generation. """ - # TODO: deprecate? view = super(APIView, cls).as_view(**initkwargs) - view.cls_instance = cls(**initkwargs) + view.cls = cls return view @property def allowed_methods(self): """ - Return the list of allowed HTTP methods, uppercased. + Wrap Django's private `_allowed_methods` interface in a public property. """ - return [method.upper() for method in self.http_method_names - if hasattr(self, method)] + return self._allowed_methods() @property def default_response_headers(self): @@ -90,43 +51,10 @@ class APIView(View): 'Vary': 'Accept' } - def get_name(self): - """ - Return the resource or view class name for use as this view's name. - Override to customize. - """ - # TODO: deprecate? - name = self.__class__.__name__ - name = _remove_trailing_string(name, 'View') - return _camelcase_to_spaces(name) - - def get_description(self, html=False): - """ - Return the resource or view docstring for use as this view's description. - Override to customize. - """ - # TODO: deprecate? - description = self.__doc__ or '' - description = _remove_leading_indent(description) - if html: - return self.markup_description(description) - return description - - def markup_description(self, description): - """ - Apply HTML markup to the description of this view. - """ - # TODO: deprecate? - if apply_markdown: - description = apply_markdown(description) - else: - description = escape(description).replace('\n', '<br />') - return mark_safe(description) - def metadata(self, request): return { - 'name': self.get_name(), - 'description': self.get_description(), + 'name': get_view_name(self.__class__), + 'description': get_view_description(self.__class__), 'renders': [renderer.media_type for renderer in self.renderer_classes], 'parses': [parser.media_type for parser in self.parser_classes], } @@ -140,7 +68,8 @@ class APIView(View): def http_method_not_allowed(self, request, *args, **kwargs): """ - Called if `request.method` does not correspond to a handler method. + If `request.method` does not correspond to a handler method, + determine what kind of exception to raise. """ raise exceptions.MethodNotAllowed(request.method) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py new file mode 100644 index 00000000..28ab30e2 --- /dev/null +++ b/rest_framework/viewsets.py @@ -0,0 +1,87 @@ +from functools import update_wrapper +from django.utils.decorators import classonlymethod +from rest_framework import views, generics, mixins + + +class ViewSetMixin(object): + """ + This is the magic. + + Overrides `.as_view()` so that it takes an `actions` keyword that performs + the binding of HTTP methods to actions on the Resource. + + For example, to create a concrete view binding the 'GET' and 'POST' methods + to the 'list' and 'create' actions... + + view = MyViewSet.as_view({'get': 'list', 'post': 'create'}) + """ + _is_viewset = True + + @classonlymethod + def as_view(cls, actions=None, name_suffix=None, **initkwargs): + """ + Main entry point for a request-response process. + + Because of the way class based views create a closure around the + instantiated view, we need to totally reimplement `.as_view`, + and slightly modify the view function that is created and returned. + """ + # sanitize keyword arguments + for key in initkwargs: + if key in cls.http_method_names: + raise TypeError("You tried to pass in the %s method name as a " + "keyword argument to %s(). Don't do that." + % (key, cls.__name__)) + if not hasattr(cls, key): + raise TypeError("%s() received an invalid keyword %r" % ( + cls.__name__, key)) + + def view(request, *args, **kwargs): + self = cls(**initkwargs) + + # Bind methods to actions + # This is the bit that's different to a standard view + for method, action in actions.items(): + handler = getattr(self, action) + setattr(self, method, handler) + + # Patch this in as it's otherwise only present from 1.5 onwards + if hasattr(self, 'get') and not hasattr(self, 'head'): + self.head = self.get + + # And continue as usual + return self.dispatch(request, *args, **kwargs) + + # take name and docstring from class + update_wrapper(view, cls, updated=()) + + # and possible attributes set by decorators + # like csrf_exempt from dispatch + update_wrapper(view, cls.dispatch, assigned=()) + + view.cls = cls + return view + + +class ViewSet(ViewSetMixin, views.APIView): + pass + + +# Note the inheritence of both MultipleObjectAPIView *and* SingleObjectAPIView +# is a bit weird given the diamond inheritence, but it will work for now. +# There's some implementation clean up that can happen later. +class ModelViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + ViewSetMixin, + generics.GenericAPIView): + pass + + +class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + ViewSetMixin, + generics.GenericAPIView): + pass |
