From b0fd5345a51c9007c71eadd3570e9e42a69c2da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaakko=20Kantoj=C3=A4rvi?= Date: Wed, 13 Jul 2016 20:55:01 +0300 Subject: [PATCH 1/2] Reimplement nested routes and move lookup config to viewsets This change requires that parent viewset defines lookup_url_kwarg or lookup_field. In exchange we do not need to define anything about url kwargs at register time. Old way is still supported, though pending deprecation. Also there is NestedHyperlinkedRelatedField and NestedHyperlinkedIdentityField which will use our config from viewsets to make writing serializers simpler and less error prone. You can also use register using with-statement. --- docs/index.md | 282 +++++++----------- rest_framework_extensions/fields.py | 124 +++++++- rest_framework_extensions/mixins.py | 51 +++- rest_framework_extensions/routers.py | 125 +++++++- rest_framework_extensions/serializers.py | 41 +++ rest_framework_extensions/utils.py | 7 - .../unit/routers/nested_router_mixin/tests.py | 10 +- 7 files changed, 441 insertions(+), 199 deletions(-) diff --git a/docs/index.md b/docs/index.md index 658052e..b686c1f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -153,12 +153,16 @@ routers. `ExtendedRouterMixin` has all set of drf-extensions features. For examp ### Nested routes -*New in DRF-extensions 0.2.4* +*New in DRF-extensions 0.3.3* + +*Implementation introduced in DRF-extensions 0.2.4 is pending deprecated and is not documented anymore* Nested routes allows you create nested resources with [viewsets](http://www.django-rest-framework.org/api-guide/viewsets.html). For example: + # yourapp.urls + from rest_framework_extensions.routers import ExtendedSimpleRouter from yourapp.views import ( UserViewSet, @@ -167,46 +171,54 @@ For example: ) router = ExtendedSimpleRouter() - ( - router.register(r'users', UserViewSet, base_name='user') - .register(r'groups', - GroupViewSet, - base_name='users-group', - parents_query_lookups=['user_groups']) - .register(r'permissions', - PermissionViewSet, - base_name='users-groups-permission', - parents_query_lookups=['group__user', 'group']) - ) + + with router.register(r'users', + UserViewSet, + base_name='user') as user: + with user.register(r'groups', + GroupViewSet, + base_name='users-group') as groups: + groups.register(r'permissions', + PermissionViewSet, + base_name='users-groups-permission') + urlpatterns = router.urls -There is one requirement for viewsets which used in nested routers. They should add mixin `NestedViewSetMixin`. That mixin -adds automatic filtering by parent lookups: +There is few things you need to take care in viewsets which are used in nested routes. +Views should contain mixin `NestedViewSetMixin`, which adds automatic filtering by parent lookups. +You should define `lookup_url_kwarg` (or `lookup_field`) in root and nested classes. +In addition you need to define `parent_lookup_map` for `NestedViewSetMixin` to know how to use url parameters. # yourapp.views from rest_framework_extensions.mixins import NestedViewSetMixin - class UserViewSet(NestedViewSetMixin, ModelViewSet): + class UserViewSet(ModelViewSet): model = UserModel + lookup_url_kwarg = 'user_id' class GroupViewSet(NestedViewSetMixin, ModelViewSet): model = GroupModel + lookup_url_kwarg = 'group_id' + parent_lookup_map = {'user_id': 'user.id'} class PermissionViewSet(NestedViewSetMixin, ModelViewSet): model = PermissionModel + lookup_url_kwarg = 'permission_id' + parent_lookup_map = {'user_id': 'group.user.id', + 'group_id': 'group.id'} - -With such kind of router we have next resources: +With above files we have following resources: * `/users/` - list of all users. Resolve name is **user-list** -* `/users//` - user detail. Resolve name is **user-detail** -* `/users//groups/` - list of groups for exact user. +* `/users//` - user detail. Resolve name is **user-detail** +* `/users//groups/` - list of groups for exact user. Resolve name is **users-group-list** -* `/users//groups//` - user group detail. If user doesn't have group then resource will -be not found. Resolve name is **users-group-detail** -* `/users//groups//permissions/` - list of permissions for user group. +* `/users//groups//` - user group detail. +If user doesn't have group then resource will be not found. +Resolve name is **users-group-detail** +* `/users//groups//permissions/` - list of permissions for user group. Resolve name is **users-groups-permission-list** -* `/users//groups//permissions//` - user group permission detail. +* `/users//groups//permissions//` - user group permission detail. If user doesn't have group or group doesn't have permission then resource will be not found. Resolve name is **users-groups-permission-detail** @@ -236,46 +248,54 @@ Every resource is automatically filtered by parent lookups. } ] -For request above permissions will be filtered by user with pk `1` and group with pk `2`: +For request above permissions will be filtered by group user with id `1` and group with id `2`: - Permission.objects.filter(group__user=1, group=2) + Permission.objects.filter(group__user__id=1, group__id=2) Example with registering more then one nested resource in one depth: - permissions_routes = router.register( - r'permissions', - PermissionViewSet, - base_name='permission' - ) - permissions_routes.register( - r'groups', - GroupViewSet, - base_name='permissions-group', - parents_query_lookups=['permissions'] - ) - permissions_routes.register( - r'users', - UserViewSet, - base_name='permissions-user', - parents_query_lookups=['groups__permissions'] - ) - -With such kind of router we have next resources: - -* `/permissions/` - list of all permissions. Resolve name is **permission-list** -* `/permissions//` - permission detail. Resolve name is **permission-detail** -* `/permissions//groups/` - list of groups for exact permission. + with router.register(r'permissions', + PermissionViewSet, + base_name='permission') as permissions: + permissions.register(r'groups', + GroupViewSet, + base_name='permissions-group') + permissions.register(r'users', + UserViewSet, + base_name='permissions-user') + + # or + + permissions = router.register(r'permissions', + PermissionViewSet, + base_name='permission') + permissions.register(r'groups', + GroupViewSet, + base_name='permissions-group') + permissions.register(r'users', + UserViewSet, + base_name='permissions-user') + +With such router and `lookup_url_kwarg = 'permission_id'` in `PermissionViewSet` we would have following resources: + +* `/permissions/` - list of all permissions. +Resolve name is **permission-list** +* `/permissions//` - permission detail. +Resolve name is **permission-detail** +* `/permissions//groups/` - list of groups for exact permission. Resolve name is **permissions-group-list** -* `/permissions//groups//` - permission group detail. If group doesn't have -permission then resource will be not found. Resolve name is **permissions-group-detail** -* `/permissions//users/` - list of users for exact permission. +* `/permissions//groups//` - permission group detail. +If group doesn't have permission then resource will be not found. +Resolve name is **permissions-group-detail** +* `/permissions//users/` - list of users for exact permission. Resolve name is **permissions-user-list** -* `/permissions//user//` - permission user detail. If user doesn't have -permission then resource will be not found. Resolve name is **permissions-user-detail** +* `/permissions//user//` - permission user detail. +If user doesn't have permission then resource will be not found. +Resolve name is **permissions-user-detail** #### Nested router mixin -You can use `rest_framework_extensions.routers.NestedRouterMixin` for adding nesting feature into your routers: +You can use `rest_framework_extensions.routers.NestedRouterMixin` to add nesting feature into your own router: from rest_framework_extensions.routers import NestedRouterMixin from rest_framework.routers import SimpleRouter @@ -283,121 +303,6 @@ You can use `rest_framework_extensions.routers.NestedRouterMixin` for adding nes class SimpleRouterWithNesting(NestedRouterMixin, SimpleRouter): pass -#### Usage with generic relations - -If you want to use nested router for [generic relation](https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/#generic-relations) -fields, you should explicitly filter `QuerySet` by content type. - -For example if you have such kind of models: - - class Task(models.Model): - title = models.CharField(max_length=30) - - class Book(models.Model): - title = models.CharField(max_length=30) - - class Comment(models.Model): - content_type = models.ForeignKey(ContentType) - object_id = models.PositiveIntegerField() - content_object = generic.GenericForeignKey() - text = models.CharField(max_length=30) - -Lets create viewsets for that models: - - class TaskViewSet(NestedViewSetMixin, ModelViewSet): - model = TaskModel - - class BookViewSet(NestedViewSetMixin, ModelViewSet): - model = BookModel - - class CommentViewSet(NestedViewSetMixin, ModelViewSet): - queryset = CommentModel.objects.all() - -And router like this: - - router = ExtendedSimpleRouter() - # tasks route - ( - router.register(r'tasks', TaskViewSet) - .register(r'comments', - CommentViewSet, - 'tasks-comment', - parents_query_lookups=['object_id']) - ) - # books route - ( - router.register(r'books', BookViewSet) - .register(r'comments', - CommentViewSet, - 'books-comment', - parents_query_lookups=['object_id']) - ) - -As you can see we've added to `parents_query_lookups` only one `object_id` value. But when you make requests to `comments` -endpoint for both tasks and books routes there is no context for current content type. - - # Request - GET /tasks/123/comments/ HTTP/1.1 - Accept: application/json - - # Response - HTTP/1.1 200 OK - Content-Type: application/json; charset=UTF-8 - - [ - { - id: 1, - content_type: 1, - object_id: 123, - text: "Good task!" - }, - { - id: 2, - content_type: 2, // oops. Wrong content type (for book) - object_id: 123, // task and book has the same id - text: "Good book!" - }, - ] - -For such kind of cases you should explicitly filter `QuerySets` of nested viewsets by content type: - - from django.contrib.contenttypes.models import ContentType - - class CommentViewSet(NestedViewSetMixin, ModelViewSet): - queryset = CommentModel.objects.all() - - class TaskCommentViewSet(CommentViewSet): - def get_queryset(self): - return super(TaskCommentViewSet, self).get_queryset().filter( - content_type=ContentType.objects.get_for_model(TaskModel) - ) - - class BookCommentViewSet(CommentViewSet): - def get_queryset(self): - return super(BookCommentViewSet, self).get_queryset().filter( - content_type=ContentType.objects.get_for_model(BookModel) - ) - -Lets use new viewsets in router: - - router = ExtendedSimpleRouter() - # tasks route - ( - router.register(r'tasks', TaskViewSet) - .register(r'comments', - TaskCommentViewSet, - 'tasks-comment', - parents_query_lookups=['object_id']) - ) - # books route - ( - router.register(r'books', BookViewSet) - .register(r'comments', - BookCommentViewSet, - 'books-comment', - parents_query_lookups=['object_id']) - ) - ### Serializers @@ -483,6 +388,45 @@ Request example: name: "Serpuhov" } +With nested routes you would need `NestedHyperlinkedIdentityField` instead. + +#### NestedHyperlinkedRelatedField + +Version of `rest_framework.serializer.HyperlinkedRelatedField` that handles nested routes ([about nested routes](#nested-routes)). +You can pass dict that defines how to resolve keyword aguments for reverse url resolving using argument `lookup_map`. +If not passed then the map is resolved from current view or you can pass view as object or string via `lookup_map`. +In addition you need to define `view_name` that points to correct nested route url. +Some examples: + + from rest_framework_extensions.fields import NestedHyperlinkedRelatedField + + class CitySerializer(serializers.ModelSerializer): + resource_uri = NestedHyperlinkedIdentityField(view_name='city-detail') + houses = NestedHyperlinkedRelatedField( + many=True, + view_name='houses-list', + lookup_map='yourapp.api_views.HousesViewSet') + citizens = NestedHyperlinkedRelatedField( + many=True, + view_name='citizens-detail', + lookup_map = { + 'town_id': 'town.id', + 'citizen_id': 'id', + }) + + class Meta: + model = City + fields = ('resource_uri', 'houses', 'citizens') + +If `lookup_map` value is callable, it will be called with selected model object as only argument. + +**This field is read only for now.** +Implementation of write support is pending. + +#### NestedHyperlinkedIdentityField + +Same as `NestedHyperlinkedRelatedField` except it always uses object itself (`source='*'`). + ### Permissions diff --git a/rest_framework_extensions/fields.py b/rest_framework_extensions/fields.py index d815529..f2d03cb 100644 --- a/rest_framework_extensions/fields.py +++ b/rest_framework_extensions/fields.py @@ -1,5 +1,15 @@ # -*- coding: utf-8 -*- -from rest_framework.relations import HyperlinkedRelatedField +import functools +from django.core.exceptions import ImproperlyConfigured +from django.core.urlresolvers import NoReverseMatch +from django.utils import six +from django.utils.functional import cached_property +from django.utils.module_loading import import_string +from rest_framework.fields import get_attribute +from rest_framework.relations import ( + HyperlinkedRelatedField, + HyperlinkedIdentityField, +) class ResourceUriField(HyperlinkedRelatedField): @@ -25,4 +35,114 @@ class Meta: def __init__(self, *args, **kwargs): kwargs.setdefault('source', '*') - super(ResourceUriField, self).__init__(*args, **kwargs) \ No newline at end of file + super(ResourceUriField, self).__init__(*args, **kwargs) + + +class NestedHyperlinkedRelatedField(HyperlinkedRelatedField): + """ + Handles HyperlinkedRelatedField with views that are nested. + + Args: + view_name: Name of url rule used for reverse resolving + lookup_map: Item specifig mapping how to resolve url kwargs for named url conf. + If value is dict, it's used as is. + If value is string, it's resolved to class using django import_string. + If value is class (e.g. resolved from string above) lookup_map is constructed + member variables `lookup_field`, `lookup_url_kwarg` and `parent_lookup_map`. + If value is ommited, we try look for view from context and do above. + If no value is resolvable by above means, error is raised. + + """ + def __init__(self, lookup_map=None, **kwargs): + self.__lookup_map = lookup_map + assert 'lookup_field' not in kwargs, "Do not use `lookup_field` use `lookup_map` instead." + assert 'lookup_url_kwarg' not in kwargs, "Do not use `lookup_url_kwarg` use `lookup_map` instead." + # FIXME: implement update operations with related fields + kwargs['read_only'] = True + kwargs['queryset'] = None + super(NestedHyperlinkedRelatedField, self).__init__(**kwargs) + + @cached_property + def _lookup_map(self): + lookup_map = self.__lookup_map or self.context.get('view', None) + + assert lookup_map, ( + "Field `{field}` of type `{type}` in `{serializer}` requires `lookup_map` " + "to be able to reverse `view_name`. \n" + "You can pass that as argument or it can be resolved from view parameters. " + "To resolve from view parameters, you need to pass view as argument " + "(via `lookup_map` as string or reference) or in context by adding " + "`context={{'view': view}}` when instantiating the serializer. ".format( + field=self.field_name, + type=self.__class__.__name__, + serializer=self.parent.__class__.__name__, + ) + ) + + if isinstance(lookup_map, dict): + return lookup_map + + if isinstance(lookup_map, six.string_types): + lookup_map = import_string(lookup_map) + + lookup_field = getattr(lookup_map, 'lookup_field', None) or self.lookup_field + lookup_url_kw = getattr(lookup_map, 'lookup_url_kwarg', None) or lookup_field + parent_lookup_map = getattr(lookup_map, 'parent_lookup_map', {}) + map_ = {lookup_url_kw: lookup_field} + map_.update(parent_lookup_map) + return map_ + + def get_url(self, obj, view_name, request, format): + # Unsaved objects will not have a valid URL. + if hasattr(obj, 'pk') and obj.pk in (None, ''): + return None + + # get lookup map (will use properties lookup_map and view) + lookup_map = self._lookup_map + get = lambda x: x(obj) if callable(x) else get_attribute(obj, x.split('.')) + + # build kwargs for reverse + try: + kwargs = dict(( + (key, get(source)) for (key, source) in lookup_map.items() + )) + except (KeyError, AttributeError) as exc: + raise ValueError( + "Got {exc_type} when attempting to create url for field " + "`{field}` on serializer `{serializer}`. " + "The serializer field lookup_map might be configured incorrectly. " + "We used `{instance}` instance with lookup map `{lookup_map}`. " + "Original exception text was: {exc}.".format( + exc_type=type(exc).__name__, + field=self.field_name, + serializer=self.parent.__class__.__name__, + instance=obj.__class__.__name__, + lookup_map=lookup_map, + exc=exc, + ) + ) + + try: + return self.reverse(self.view_name, kwargs=kwargs, request=request, format=format) + except NoReverseMatch as exc: + raise ImproperlyConfigured( + "Could not resolve URL for hyperlinked relationship in field " + "`{field}` on serializer `{serializer}`. " + "You may have failed to include the related model in your API, " + "or incorrectly configured `view_name` or `lookup_map` " + "attribute on this field. Original error: {exc}".format( + field=self.field_name, + view_name=self.view_name, + serializer=self.parent.__class__.__name__, + kwargs=kwargs, + exc=exc, + ) + ) + + +class NestedHyperlinkedIdentityField(NestedHyperlinkedRelatedField, HyperlinkedIdentityField): + """ + Represents a nested hyperlinked resource itself. + Will get lookup_map from view class in serializer context + """ + pass diff --git a/rest_framework_extensions/mixins.py b/rest_framework_extensions/mixins.py index 10c4e76..4948a4e 100644 --- a/rest_framework_extensions/mixins.py +++ b/rest_framework_extensions/mixins.py @@ -1,15 +1,20 @@ # -*- coding: utf-8 -*- # Try to import six from Django, fallback to included `six`. +import logging from django.utils import six - +from django.http import Http404 +from django.utils.encoding import force_str +from django.core.exceptions import FieldError from rest_framework_extensions.cache.mixins import CacheResponseMixin from rest_framework_extensions.etag.mixins import ReadOnlyETAGMixin, ETAGMixin from rest_framework_extensions.utils import get_rest_framework_features from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin from rest_framework_extensions.settings import extensions_api_settings -from django.http import Http404 + + +logger = logging.getLogger('rest_framework_extensions.request') class DetailSerializerMixin(object): @@ -56,22 +61,44 @@ class CacheResponseAndETAGMixin(ETAGMixin, CacheResponseMixin): class NestedViewSetMixin(object): - def get_queryset(self): - return self.filter_queryset_by_parents_lookups( - super(NestedViewSetMixin, self).get_queryset() - ) - - def filter_queryset_by_parents_lookups(self, queryset): - parents_query_dict = self.get_parents_query_dict() - if parents_query_dict: + """ + Adds filtering in get_page_size based on .parent_lookup_map definitions. + + Raises: + Http404: If queryset.filter() raised ValueError. + This happens if filter string was wrong. + """ + def filter_queryset(self, queryset): + queryset = self.filter_queryset_by_parent_lookups(queryset) + return super(NestedViewSetMixin, self).filter_queryset(queryset) + + def filter_queryset_by_parent_lookups(self, queryset): + """ + Filter queryset using parent_lookup_map + """ + map_ = getattr(self, 'parent_lookup_map', {}) + filters = self.get_parents_query_dict() # TODO: replace with {} when call is removed + for kw, filter_ in map_.items(): + if callable(filter_): + filter_ = filter_() + filter_ = force_str(filter_).replace('.', '__') + value = self.kwargs.get(kw, None) + if value is not None: + filters[filter_] = value + if filters: try: - return queryset.filter(**parents_query_dict) - except ValueError: + return queryset.filter(**filters) + except (ValueError, FieldError): + logger.exception("queryset filtering with parent_lookup_map failed") raise Http404 else: return queryset def get_parents_query_dict(self): + """ + Resolve legacy parent query dict. + Deprecated in 0.2.9. Warning is raised in NestedRouterItem if this feature is used. + """ result = {} for kwarg_name, kwarg_value in six.iteritems(self.kwargs): if kwarg_name.startswith(extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX): diff --git a/rest_framework_extensions/routers.py b/rest_framework_extensions/routers.py index dc89cd2..74fe1a3 100644 --- a/rest_framework_extensions/routers.py +++ b/rest_framework_extensions/routers.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- +import warnings from distutils.version import StrictVersion from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch +from django.utils.functional import cached_property import rest_framework from rest_framework.routers import ( @@ -13,8 +15,9 @@ from rest_framework import views from rest_framework.reverse import reverse from rest_framework.response import Response -from rest_framework_extensions.utils import flatten, compose_parent_pk_kwarg_name +from rest_framework_extensions.utils import flatten from rest_framework_extensions.compat_drf import add_trailing_slash_if_needed +from rest_framework_extensions.settings import extensions_api_settings class ExtendedActionLinkRouterMixin(object): @@ -149,7 +152,7 @@ def get_dynamic_routes_instances(self, viewset, route, dynamic_routes): return dynamic_routes_instances -class NestedRegistryItem(object): +class LegacyNestedRegistryItem(object): def __init__(self, router, parent_prefix, parent_item=None, parent_viewset=None): self.router = router self.parent_prefix = parent_prefix @@ -162,7 +165,7 @@ def register(self, prefix, viewset, base_name, parents_query_lookups): viewset=viewset, base_name=base_name, ) - return NestedRegistryItem( + return LegacyNestedRegistryItem( router=self.router, parent_prefix=prefix, parent_item=self, @@ -180,6 +183,13 @@ def get_parent_prefix(self, parents_query_lookups): current_item = self i = len(parents_query_lookups) - 1 parent_lookup_value_regex = getattr(self.parent_viewset, 'lookup_value_regex', '[^/.]+') + + def compose_parent_pk_kwarg_name(value): + return '{0}{1}'.format( + extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, + value + ) + while current_item: prefix = '{parent_prefix}/(?P<{parent_pk_kwarg_name}>{parent_lookup_value_regex})/{prefix}'.format( parent_prefix=current_item.parent_prefix, @@ -192,16 +202,117 @@ def get_parent_prefix(self, parents_query_lookups): return prefix.strip('/') +class NestedRegistryItem(object): + def __init__(self, router, prefix, viewset, extra_kwargs=None, parent_item=None): + self.router = router + self.prefix = prefix + self.viewset = viewset + self.extra_kwargs = extra_kwargs or {} + self.parent_item = parent_item + self.parent_pattern = parent_item.full_pattern if parent_item else '' + + self.__register() + + def __register(self): + # Check that same lookup_url_kwarg is not used twice in same nested chain + lookup_url_kwarg = self.lookup_url_kwarg + current = self.parent_item + while current: + assert current.lookup_url_kwarg != lookup_url_kwarg, ( + "Viewsets %r and %r are nested and they have same " + "lookup_url_kwarg %r. Recheck values of lookup_url_kwarg " + "and lookup_field in those viewsets." + % (current.viewset, self.viewset, lookup_url_kwarg) + ) + current = current.parent_item + + prefix = '{0}/{1}'.format(self.parent_pattern, self.prefix).strip('/') + self.router._register(prefix, self.viewset, **self.extra_kwargs) + + def register(self, prefix, viewset, base_name=None, parents_query_lookups=None, **kwargs): + # support passing as positional argument + kwargs['base_name'] = base_name + + # support legacy interface. + if parents_query_lookups: + warnings.warn( + "Usage of `parents_query_lookups` for nested routes is " + "pending deprecation." + "Use `parent_lookup_map` in view class instead.", + PendingDeprecationWarning + ) + def iter_from(cur): + while cur is not None: + yield cur + cur = cur.parent_item + prev_legacy_item = None + for item in reversed(list(iter_from(self))): + legacy_item = LegacyNestedRegistryItem( + router=item.router, + parent_prefix=item.prefix, + parent_viewset=item.viewset, + parent_item=prev_legacy_item, + ) + prev_legacy_item = legacy_item + return legacy_item.register( + prefix=prefix, + viewset=viewset, + parents_query_lookups=parents_query_lookups, + **kwargs) + + return NestedRegistryItem( + router=self.router, + prefix=prefix, + viewset=viewset, + extra_kwargs=kwargs, + parent_item=self, + ) + + @cached_property + def lookup_url_kwarg(self): + return ( + getattr(self.viewset, 'lookup_url_kwarg', None) or + getattr(self.viewset, 'lookup_field', None) or + 'ok' + ) + + @cached_property + def full_pattern(self): + lookup_value_regex = getattr(self.viewset, 'lookup_value_regex', '[^/.]+') + lookup_url_kwarg = self.lookup_url_kwarg + + return '{parent_pattern}/{prefix}/(?P<{lookup_url_kwarg}>{lookup_value_regex})'.format( + parent_pattern=self.parent_pattern, + prefix=self.prefix, + lookup_url_kwarg=lookup_url_kwarg, + lookup_value_regex=lookup_value_regex, + ).strip('/') + + def __enter__(self): + """ + Support with statement + + Example: + with api.register(r'example', ExampleViewSet) as example: + example.register(r'nested', NestedBiewSet) + """ + return self + + def __exit__(self, type, value, traceback): + pass + + class NestedRouterMixin(object): def _register(self, *args, **kwargs): return super(NestedRouterMixin, self).register(*args, **kwargs) - def register(self, *args, **kwargs): - self._register(*args, **kwargs) + def register(self, prefix, viewset, base_name=None, **kwargs): + kwargs['base_name'] = base_name return NestedRegistryItem( router=self, - parent_prefix=self.registry[-1][0], - parent_viewset=self.registry[-1][1] + prefix=prefix, + viewset=viewset, + extra_kwargs=kwargs ) def get_api_root_view(self, **kwargs): diff --git a/rest_framework_extensions/serializers.py b/rest_framework_extensions/serializers.py index 1efaf7a..0a728a4 100644 --- a/rest_framework_extensions/serializers.py +++ b/rest_framework_extensions/serializers.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +from rest_framework.serializers import HyperlinkedModelSerializer +from rest_framework.relations import HyperlinkedRelatedField +from rest_framework_extensions.fields import( + NestedHyperlinkedRelatedField, + NestedHyperlinkedIdentityField, +) from rest_framework_extensions.compat import get_concrete_model from rest_framework_extensions.utils import get_model_opts_concrete_fields @@ -40,3 +46,38 @@ def update(self, instance, validated_attrs): else: instance.save() return instance + + +class NestedHyperlinkedModelSerializer(HyperlinkedModelSerializer): + """ + Extension of `HyperlinkedModelSerializer` that adds support for + nested resources. + """ + serializer_related_field = NestedHyperlinkedRelatedField + serializer_url_field = NestedHyperlinkedIdentityField + + def get_default_field_names(self, declared_fields, model_info): + """ + Return the default list of field names that will be used if the + `Meta.fields` option is not specified. + """ + return ( + [self.url_field_name] + + list(declared_fields.keys()) + + list(model_info.fields.keys()) + + list(model_info.forward_relations.keys()) + ) + + def build_nested_field(self, field_name, relation_info, nested_depth): + """ + Create nested fields for forward and reverse relationships. + """ + class NestedSerializer(NestedHyperlinkedModelSerializer): + class Meta: + model = relation_info.related_model + depth = nested_depth - 1 + + field_class = NestedSerializer + field_kwargs = get_nested_relation_kwargs(relation_info) + + return field_class, field_kwargs diff --git a/rest_framework_extensions/utils.py b/rest_framework_extensions/utils.py index f226456..b7689d8 100644 --- a/rest_framework_extensions/utils.py +++ b/rest_framework_extensions/utils.py @@ -13,7 +13,6 @@ DefaultAPIModelInstanceKeyConstructor, DefaultAPIModelListKeyConstructor ) -from rest_framework_extensions.settings import extensions_api_settings def get_rest_framework_features(): @@ -73,12 +72,6 @@ def get_model_opts_concrete_fields(opts): return opts.concrete_fields -def compose_parent_pk_kwarg_name(value): - return '{0}{1}'.format( - extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, - value - ) - default_cache_key_func = DefaultKeyConstructor() default_object_cache_key_func = DefaultObjectKeyConstructor() diff --git a/tests_app/tests/unit/routers/nested_router_mixin/tests.py b/tests_app/tests/unit/routers/nested_router_mixin/tests.py index 8ef0eb3..9b17e4b 100644 --- a/tests_app/tests/unit/routers/nested_router_mixin/tests.py +++ b/tests_app/tests/unit/routers/nested_router_mixin/tests.py @@ -2,13 +2,19 @@ from rest_framework_extensions.compat_drf import get_lookup_allowed_symbols from rest_framework_extensions.test import APITestCase from rest_framework_extensions.routers import ExtendedSimpleRouter -from rest_framework_extensions.utils import compose_parent_pk_kwarg_name +from rest_framework_extensions.settings import extensions_api_settings from .views import ( UserViewSet, GroupViewSet, PermissionViewSet, ) +def compose_parent_pk_kwarg_name(value): + return '{0}{1}'.format( + extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, + value + ) + class NestedRouterMixinTest(APITestCase): def get_lookup_regex(self, value): @@ -111,4 +117,4 @@ def test_nested_route_depth_3(self): self.get_parent_lookup_regex('group'), self.get_lookup_regex('pk') ), - ) \ No newline at end of file + ) From 2175f4f5486fab3e132a72abed48b8e8e37fd8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaakko=20Kantoj=C3=A4rvi?= Date: Wed, 27 Jul 2016 20:03:29 +0300 Subject: [PATCH 2/2] ResourceUriField is identical to HyperlinkedIdentityField --- rest_framework_extensions/fields.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/rest_framework_extensions/fields.py b/rest_framework_extensions/fields.py index f2d03cb..ab5ce20 100644 --- a/rest_framework_extensions/fields.py +++ b/rest_framework_extensions/fields.py @@ -12,7 +12,7 @@ ) -class ResourceUriField(HyperlinkedRelatedField): +class ResourceUriField(HyperlinkedIdentityField): """ Represents a hyperlinking uri that points to the detail view for that object. @@ -30,12 +30,7 @@ class Meta: "resource_uri": "http://localhost/v1/surveys/1/", } """ - # todo: test me - read_only = True - - def __init__(self, *args, **kwargs): - kwargs.setdefault('source', '*') - super(ResourceUriField, self).__init__(*args, **kwargs) + pass class NestedHyperlinkedRelatedField(HyperlinkedRelatedField):