diff --git a/requirements.txt b/requirements.txt index 3fccf6d8..4fcf7274 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -git+https://github.com/tomchristie/django-rest-framework.git@2.4.0 +#git+https://github.com/tomchristie/django-rest-framework.git@2.4.0 +djangorestframework==2.3.13 django-reversion==1.8.0 Django==1.6.1 South==0.8.3 diff --git a/taiga/base/auth/api.py b/taiga/base/auth/api.py index 8c0fe707..3fc55d82 100644 --- a/taiga/base/auth/api.py +++ b/taiga/base/auth/api.py @@ -9,7 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework import status, viewsets -from rest_framework.decorators import list_route +from taiga.base.decorators import list_route from taiga.base.domains.models import DomainMember from taiga.base.domains import get_active_domain diff --git a/taiga/base/decorators.py b/taiga/base/decorators.py index 0ee28751..a7b23fb2 100644 --- a/taiga/base/decorators.py +++ b/taiga/base/decorators.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from functools import wraps +import warnings def change_instance_attr(name, new_value): """ @@ -27,3 +28,56 @@ def change_instance_attr(name, new_value): return wrapper return change_instance_attr + +## Rest Framework 2.4 backport some decorators. + +def detail_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = True + func.kwargs = kwargs + return func + return decorator + + +def list_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for list requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = False + func.kwargs = kwargs + return func + return decorator + + +def link(**kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail GET requests. + """ + msg = 'link is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): + func.bind_to_methods = ['get'] + func.detail = True + func.kwargs = kwargs + return func + return decorator + + +def action(methods=['post'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail POST requests. + """ + msg = 'action is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): + func.bind_to_methods = methods + func.detail = True + func.kwargs = kwargs + return func + return decorator diff --git a/taiga/base/routers.py b/taiga/base/routers.py index 55bcc6f8..d60f8fc3 100644 --- a/taiga/base/routers.py +++ b/taiga/base/routers.py @@ -1,16 +1,307 @@ -# -*- coding: utf-8 -*- +# Django Rest Framework 2.4.0 routers module (should be removed when 2.4 is released) -from rest_framework import routers +import itertools +from collections import namedtuple +from django.conf.urls import patterns, url +from django.core.exceptions import ImproperlyConfigured +from rest_framework import views +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.urlpatterns import format_suffix_patterns -class DefaultRouter(routers.DefaultRouter): +Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) +DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) + + +def replace_methodname(format_string, methodname): + """ + Partially format a format_string, swapping out any + '{methodname}' or '{methodnamehyphen}' components. + """ + methodnamehyphen = methodname.replace('_', '-') + ret = format_string + ret = ret.replace('{methodname}', methodname) + ret = ret.replace('{methodnamehyphen}', methodnamehyphen) + return ret + + +def flatten(list_of_lists): + """ + Takes an iterable of iterables, returns a single iterable containing all items + """ + return itertools.chain(*list_of_lists) + + +class BaseRouter(object): + def __init__(self): + self.registry = [] + + def register(self, prefix, viewset, base_name=None): + if base_name is None: + base_name = self.get_default_base_name(viewset) + self.registry.append((prefix, viewset, base_name)) + + def get_default_base_name(self, viewset): + """ + If `base_name` is not specified, attempt to automatically determine + it from the viewset. + """ + raise NotImplemented('get_default_base_name must be overridden') + + def get_urls(self): + """ + Return a list of URL patterns, given the registered viewsets. + """ + raise NotImplemented('get_urls must be overridden') + + @property + def urls(self): + if not hasattr(self, '_urls'): + self._urls = patterns('', *self.get_urls()) + return self._urls + + +class SimpleRouter(BaseRouter): routes = [ - routers.Route( + # List route. + Route( + url=r'^{prefix}{trailing_slash}$', + mapping={ + 'get': 'list', + 'post': 'create' + }, + name='{basename}-list', + initkwargs={'suffix': 'List'} + ), + # Dynamically generated list routes. + # Generated using @list_route decorator + # on methods of the viewset. + DynamicListRoute( + url=r'^{prefix}/{methodname}{trailing_slash}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ), + # Detail route. + Route( + url=r'^{prefix}/{lookup}{trailing_slash}$', + mapping={ + 'get': 'retrieve', + 'put': 'update', + 'patch': 'partial_update', + 'delete': 'destroy' + }, + name='{basename}-detail', + initkwargs={'suffix': 'Instance'} + ), + # Dynamically generated detail routes. + # Generated using @detail_route decorator on methods of the viewset. + DynamicDetailRoute( + url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ), + ] + + def __init__(self, trailing_slash=True): + self.trailing_slash = trailing_slash and '/' or '' + super(SimpleRouter, self).__init__() + + def get_default_base_name(self, viewset): + """ + If `base_name` is not specified, attempt to automatically determine + it from the viewset. + """ + model_cls = getattr(viewset, 'model', None) + queryset = getattr(viewset, 'queryset', None) + if model_cls is None and queryset is not None: + model_cls = queryset.model + + assert model_cls, '`base_name` argument not specified, and could ' \ + 'not automatically determine the name from the viewset, as ' \ + 'it does not have a `.model` or `.queryset` attribute.' + + return model_cls._meta.object_name.lower() + + def get_routes(self, viewset): + """ + Augment `self.routes` with any dynamically generated routes. + + Returns a list of the Route namedtuple. + """ + + known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) + + # Determine any `@detail_route` or `@list_route` decorated methods on the viewset + detail_routes = [] + list_routes = [] + for methodname in dir(viewset): + attr = getattr(viewset, methodname) + httpmethods = getattr(attr, 'bind_to_methods', None) + detail = getattr(attr, 'detail', True) + if httpmethods: + if methodname in known_actions: + raise ImproperlyConfigured('Cannot use @detail_route or @list_route ' + 'decorators on method "%s" ' + 'as it is an existing route' % methodname) + httpmethods = [method.lower() for method in httpmethods] + if detail: + detail_routes.append((httpmethods, methodname)) + else: + list_routes.append((httpmethods, methodname)) + + ret = [] + for route in self.routes: + if isinstance(route, DynamicDetailRoute): + # Dynamic detail routes (@detail_route decorator) + for httpmethods, methodname in detail_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) + elif isinstance(route, DynamicListRoute): + # Dynamic list routes (@list_route decorator) + for httpmethods, methodname in list_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) + else: + # Standard route + ret.append(route) + + return ret + + def get_method_map(self, viewset, method_map): + """ + Given a viewset, and a mapping of http methods to actions, + return a new mapping which only includes any mappings that + are actually implemented by the viewset. + """ + bound_methods = {} + for method, action in method_map.items(): + if hasattr(viewset, action): + bound_methods[method] = action + return bound_methods + + def get_lookup_regex(self, viewset, lookup_prefix=''): + """ + Given a viewset, return the portion of URL regex that is used + to match against a single instance. + + Note that lookup_prefix is not used directly inside REST rest_framework + itself, but is required in order to nicely support nested router + implementations, such as drf-nested-routers. + + https://github.com/alanjds/drf-nested-routers + """ + base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' + # Use `pk` as default field, unset set. Default regex should not + # consume `.json` style suffixes and should break at '/' boundaries. + lookup_field = getattr(viewset, 'lookup_field', 'pk') + lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+') + return base_regex.format( + lookup_prefix=lookup_prefix, + lookup_field=lookup_field, + lookup_value=lookup_value + ) + + def get_urls(self): + """ + Use the registered viewsets to generate a list of URL patterns. + """ + ret = [] + + for prefix, viewset, basename in self.registry: + lookup = self.get_lookup_regex(viewset) + routes = self.get_routes(viewset) + + for route in routes: + + # Only actions which actually exist on the viewset will be bound + mapping = self.get_method_map(viewset, route.mapping) + if not mapping: + continue + + # Build the url pattern + regex = route.url.format( + prefix=prefix, + lookup=lookup, + trailing_slash=self.trailing_slash + ) + view = viewset.as_view(mapping, **route.initkwargs) + name = route.name.format(basename=basename) + ret.append(url(regex, view, name=name)) + + return ret + + +class DRFDefaultRouter(SimpleRouter): + """ + The default router extends the SimpleRouter, but also adds in a default + API root view, and adds format suffix patterns to the URLs. + """ + include_root_view = True + include_format_suffixes = True + root_view_name = 'api-root' + + def get_api_root_view(self): + """ + Return a view to use as the API root. + """ + api_root_dict = {} + list_name = self.routes[0].name + for prefix, viewset, basename in self.registry: + api_root_dict[prefix] = list_name.format(basename=basename) + + class APIRoot(views.APIView): + _ignore_model_permissions = True + + def get(self, request, format=None): + ret = {} + for key, url_name in api_root_dict.items(): + ret[key] = reverse(url_name, request=request, format=format) + return Response(ret) + + return APIRoot.as_view() + + def get_urls(self): + """ + Generate the list of URL patterns, including a default root view + for the API, and appending `.json` style format suffixes. + """ + urls = [] + + if self.include_root_view: + root_url = url(r'^$', self.get_api_root_view(), name=self.root_view_name) + urls.append(root_url) + + default_urls = super(DRFDefaultRouter, self).get_urls() + urls.extend(default_urls) + + if self.include_format_suffixes: + urls = format_suffix_patterns(urls) + + return urls + + +class DefaultRouter(DRFDefaultRouter): + routes = [ + Route( url=r'^{prefix}/(?P\d+)/restore/(?P\d+)$', mapping={'post': 'restore'}, name='{basename}-restore', initkwargs={} ) - ] + routers.DefaultRouter.routes + ] + DRFDefaultRouter.routes __all__ = ["DefaultRouter"] diff --git a/taiga/base/users/api.py b/taiga/base/users/api.py index 73c7598f..6c28dcd3 100644 --- a/taiga/base/users/api.py +++ b/taiga/base/users/api.py @@ -8,13 +8,13 @@ from django.contrib.auth import logout, login, authenticate from django.contrib.auth.models import Permission from django.utils.translation import ugettext_lazy as _ -from rest_framework.decorators import list_route, action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework import status, viewsets from djmail.template_mail import MagicMailBuilder +from taiga.base.decorators import list_route, action from taiga.base import exceptions as exc from taiga.base.filters import FilterBackend from taiga.base.api import ModelCrudViewSet, RetrieveModelMixin, ModelListViewSet diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 3becb2d3..640a554d 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -7,9 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated, AllowAny -from rest_framework.decorators import list_route from rest_framework.response import Response -from rest_framework.decorators import detail_route from rest_framework import viewsets from rest_framework import status @@ -17,6 +15,7 @@ from djmail.template_mail import MagicMailBuilder from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base.decorators import list_route, detail_route from taiga.base.permissions import has_project_perm from taiga.base.api import ModelCrudViewSet, ModelListViewSet, RetrieveModelMixin from taiga.base.domains import get_active_domain diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 2150943f..5fbf3214 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -6,13 +6,13 @@ from django.utils.translation import ugettext_lazy as _ from django.db.models import Q from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import list_route from rest_framework.response import Response from rest_framework import status from rest_framework import filters from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet, NeighborsApiMixin from taiga.base.notifications.api import NotificationSenderMixin from taiga.projects.permissions import AttachmentPermission diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index 5bebe797..3896cb6e 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -4,11 +4,11 @@ from django.utils.translation import ugettext_lazy as _ from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import detail_route from rest_framework.response import Response from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet from taiga.base.notifications.api import NotificationSenderMixin diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 5ac0897e..8c196972 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -5,12 +5,12 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import list_route from rest_framework.response import Response from rest_framework import status from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base.decorators import list_route from taiga.base.permissions import has_project_perm from taiga.base.api import ModelCrudViewSet from taiga.base.notifications.api import NotificationSenderMixin diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index b4a1a673..d9618402 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -7,12 +7,12 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import list_route, action from rest_framework.response import Response from rest_framework import status from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base.decorators import list_route, action from taiga.base.permissions import has_project_perm from taiga.base.api import ModelCrudViewSet, NeighborsApiMixin from taiga.base.notifications.api import NotificationSenderMixin