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..ab5ce20 100644 --- a/rest_framework_extensions/fields.py +++ b/rest_framework_extensions/fields.py @@ -1,8 +1,18 @@ # -*- 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): +class ResourceUriField(HyperlinkedIdentityField): """ Represents a hyperlinking uri that points to the detail view for that object. @@ -20,9 +30,114 @@ class Meta: "resource_uri": "http://localhost/v1/surveys/1/", } """ - # todo: test me - read_only = True + pass - def __init__(self, *args, **kwargs): - kwargs.setdefault('source', '*') - super(ResourceUriField, self).__init__(*args, **kwargs) \ No newline at end of file + +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 + )