lookup_field on hyperlinked fields, and overriddable hyperlinked fields. Closes #688

This commit is contained in:
Tom Christie 2013-05-01 09:03:09 +01:00
parent 22af28d146
commit 35f99cddc4
3 changed files with 136 additions and 81 deletions

View File

@ -123,9 +123,9 @@ Would serialize to a representation like this:
'album_name': 'Graceland', 'album_name': 'Graceland',
'artist': 'Paul Simon' 'artist': 'Paul Simon'
'tracks': [ 'tracks': [
'http://www.example.com/api/tracks/45', 'http://www.example.com/api/tracks/45/',
'http://www.example.com/api/tracks/46', 'http://www.example.com/api/tracks/46/',
'http://www.example.com/api/tracks/47', 'http://www.example.com/api/tracks/47/',
... ...
] ]
} }
@ -138,9 +138,7 @@ By default this field is read-write, although you can change this behavior using
* `many` - If applied to a to-many relationship, you should set this argument to `True`. * `many` - If applied to a to-many relationship, you should set this argument to `True`.
* `required` - If set to `False`, the field will accept values of `None` or the empty-string for nullable relationships. * `required` - If set to `False`, the field will accept values of `None` or the empty-string for nullable relationships.
* `queryset` - By default `ModelSerializer` classes will use the default queryset for the relationship. `Serializer` classes must either set a queryset explicitly, or set `read_only=True`. * `queryset` - By default `ModelSerializer` classes will use the default queryset for the relationship. `Serializer` classes must either set a queryset explicitly, or set `read_only=True`.
* `slug_field` - The field on the target that should be used for the lookup. Default is `'slug'`. * `lookup_field` - The field on the target that should be used for the lookup. Should correspond to a URL keyword argument on the referenced view. Default is `'pk'`.
* `pk_url_kwarg` - The named url parameter for the pk field lookup. Default is `pk`.
* `slug_url_kwarg` - The named url parameter for the slug field lookup. Default is to use the same value as given for `slug_field`.
* `format` - If using format suffixes, hyperlinked fields will use the same format suffix for the target unless overridden by using the `format` argument. * `format` - If using format suffixes, hyperlinked fields will use the same format suffix for the target unless overridden by using the `format` argument.
## SlugRelatedField ## SlugRelatedField
@ -196,7 +194,7 @@ Would serialize to a representation like this:
{ {
'album_name': 'The Eraser', 'album_name': 'The Eraser',
'artist': 'Thom Yorke' 'artist': 'Thom Yorke'
'track_listing': 'http://www.example.com/api/track_list/12', 'track_listing': 'http://www.example.com/api/track_list/12/',
} }
This field is always read-only. This field is always read-only.
@ -291,32 +289,23 @@ This custom field would then serialize to the following representation.
## Reverse relations ## Reverse relations
Note that reverse relationships are not automatically generated by the `ModelSerializer` and `HyperlinkedModelSerializer` classes. To include a reverse relationship, you cannot simply add it to the fields list. Note that reverse relationships are not automatically included by the `ModelSerializer` and `HyperlinkedModelSerializer` classes. To include a reverse relationship, you must explicitly add it to the fields list. For example:
**The following will not work:**
class AlbumSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer):
class Meta: class Meta:
fields = ('tracks', ...) fields = ('tracks', ...)
Instead, you must explicitly add it to the serializer. For example:
class AlbumSerializer(serializers.ModelSerializer): You'll normally want to ensure that you've set an appropriate `related_name` argument on the relationship, that you can use as the field name. For example:
tracks = serializers.PrimaryKeyRelatedField(many=True)
...
By default, the field will uses the same accessor as it's field name to retrieve the relationship, so in this example, `Album` instances would need to have the `tracks` attribute for this relationship to work.
The best way to ensure this is typically to make sure that the relationship on the model definition has it's `related_name` argument properly set. For example:
class Track(models.Model): class Track(models.Model):
album = models.ForeignKey(Album, related_name='tracks') album = models.ForeignKey(Album, related_name='tracks')
... ...
Alternatively, you can use the `source` argument on the serializer field, to use a different accessor attribute than the field name. For example. If you have not set a related name for the reverse relationship, you'll need to use the automatically generated related name in the `fields` argument. For example:
class AlbumSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer):
tracks = serializers.PrimaryKeyRelatedField(many=True, source='track_set') class Meta:
fields = ('track_set', ...)
See the Django documentation on [reverse relationships][reverse-relationships] for more details. See the Django documentation on [reverse relationships][reverse-relationships] for more details.
@ -394,6 +383,40 @@ Note that reverse generic keys, expressed using the `GenericRelation` field, can
For more information see [the Django documentation on generic relations][generic-relations]. For more information see [the Django documentation on generic relations][generic-relations].
## Advanced Hyperlinked fields
If you have very specific requirements for the style of your hyperlinked relationships you can override `HyperlinkedRelatedField`.
There are two methods you'll need to override.
#### get_url(self, obj, view_name, request, format)
This method should return the URL that corresponds to the given object.
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
attributes are not configured to correctly match the URL conf.
#### get_object(self, queryset, view_name, view_args, view_kwargs)
This method should the object that corresponds to the matched URL conf arguments.
May raise an `ObjectDoesNotExist` exception.
### Example
For example, if all your object URLs used both a account and a slug in the the URL to reference the object, you might create a custom field like this:
class CustomHyperlinkedField(serializers.HyperlinkedRelatedField):
def get_url(self, obj, view_name, request, format):
kwargs = {'account': obj.account, 'slug': obj.slug}
return reverse(view_name, kwargs=kwargs, request=request, format=format)
def get_object(self, queryset, view_name, view_args, view_kwargs):
account = view_kwargs['account']
slug = view_kwargs['slug']
return queryset.get(account=account, slug=sug)
--- ---
## Deprecated APIs ## Deprecated APIs

View File

@ -288,10 +288,8 @@ class HyperlinkedRelatedField(RelatedField):
""" """
Represents a relationship using hyperlinking. Represents a relationship using hyperlinking.
""" """
pk_url_kwarg = 'pk'
slug_field = 'slug'
slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden
read_only = False read_only = False
lookup_field = 'pk'
default_error_messages = { default_error_messages = {
'no_match': _('Invalid hyperlink - No URL match'), 'no_match': _('Invalid hyperlink - No URL match'),
@ -301,25 +299,84 @@ class HyperlinkedRelatedField(RelatedField):
'incorrect_type': _('Incorrect type. Expected url string, received %s.'), 'incorrect_type': _('Incorrect type. Expected url string, received %s.'),
} }
# These are all pending deprecation
pk_url_kwarg = 'pk'
slug_field = 'slug'
slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
try: try:
self.view_name = kwargs.pop('view_name') self.view_name = kwargs.pop('view_name')
except KeyError: except KeyError:
raise ValueError("Hyperlinked field requires 'view_name' kwarg") raise ValueError("Hyperlinked field requires 'view_name' kwarg")
self.lookup_field = kwargs.pop('lookup_field', self.lookup_field)
self.format = kwargs.pop('format', None)
# These are pending deprecation
self.pk_url_kwarg = kwargs.pop('pk_url_kwarg', self.pk_url_kwarg)
self.slug_field = kwargs.pop('slug_field', self.slug_field) self.slug_field = kwargs.pop('slug_field', self.slug_field)
default_slug_kwarg = self.slug_url_kwarg or self.slug_field default_slug_kwarg = self.slug_url_kwarg or self.slug_field
self.pk_url_kwarg = kwargs.pop('pk_url_kwarg', self.pk_url_kwarg)
self.slug_url_kwarg = kwargs.pop('slug_url_kwarg', default_slug_kwarg) self.slug_url_kwarg = kwargs.pop('slug_url_kwarg', default_slug_kwarg)
self.format = kwargs.pop('format', None)
super(HyperlinkedRelatedField, self).__init__(*args, **kwargs) super(HyperlinkedRelatedField, self).__init__(*args, **kwargs)
def get_slug_field(self): def get_url(self, obj, view_name, request, format):
""" """
Get the name of a slug field to be used to look up by slug. Given an object, return the URL that hyperlinks to the object.
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
attributes are not configured to correctly match the URL conf.
""" """
return self.slug_field lookup_field = getattr(obj, self.lookup_field)
kwargs = {self.lookup_field: lookup_field}
try:
return reverse(view_name, kwargs=kwargs, request=request, format=format)
except NoReverseMatch:
pass
if self.pk_url_kwarg != 'pk':
# Only try pk if it has been explicitly set.
# Otherwise, the default `lookup_field = 'pk'` has us covered.
pk = obj.pk
kwargs = {self.pk_url_kwarg: pk}
try:
return reverse(view_name, kwargs=kwargs, request=request, format=format)
except NoReverseMatch:
pass
slug = getattr(obj, self.slug_field, None)
if slug is not None:
# Only try slug if it corresponds to an attribute on the object.
kwargs = {self.slug_url_kwarg: slug}
try:
return reverse(view_name, kwargs=kwargs, request=request, format=format)
except NoReverseMatch:
pass
raise NoReverseMatch()
def get_object(self, queryset, view_name, view_args, view_kwargs):
"""
Return the object corresponding to a matched URL.
Takes the matched URL conf arguments, and the queryset, and should
return an object instance, or raise an `ObjectDoesNotExist` exception.
"""
lookup = view_kwargs.get(self.lookup_field, None)
pk = view_kwargs.get(self.pk_url_kwarg, None)
slug = view_kwargs.get(self.slug_url_kwarg, None)
if lookup is not None:
filter_kwargs = {self.lookup_field: lookup}
elif pk is not None:
filter_kwargs = {'pk': pk}
elif slug is not None:
filter_kwargs = {self.slug_field: slug}
else:
raise ObjectDoesNotExist()
return queryset.get(**filter_kwargs)
def to_native(self, obj): def to_native(self, obj):
view_name = self.view_name view_name = self.view_name
@ -327,43 +384,35 @@ class HyperlinkedRelatedField(RelatedField):
format = self.format or self.context.get('format', None) format = self.format or self.context.get('format', None)
if request is None: if request is None:
warnings.warn("Using `HyperlinkedRelatedField` without including the " msg = (
"request in the serializer context is deprecated. " "Using `HyperlinkedRelatedField` without including the request "
"Add `context={'request': request}` when instantiating the serializer.", "in the serializer context is deprecated. "
DeprecationWarning, stacklevel=4) "Add `context={'request': request}` when instantiating "
"the serializer."
)
warnings.warn(msg, DeprecationWarning, stacklevel=4)
pk = getattr(obj, 'pk', None) # If the object has not yet been saved then we cannot hyperlink to it.
if pk is None: if getattr(obj, 'pk', None) is None:
return return
kwargs = {self.pk_url_kwarg: pk}
# Return the hyperlink, or error if incorrectly configured.
try: try:
return reverse(view_name, kwargs=kwargs, request=request, format=format) return self.get_url(obj, view_name, request, format)
except NoReverseMatch: except NoReverseMatch:
pass msg = (
'Could not resolve URL for hyperlinked relationship using '
slug = getattr(obj, self.slug_field, None) 'view name "%s". You may have failed to include the related '
'model in your API, or incorrectly configured the '
if not slug: '`lookup_field` attribute on this field.'
raise Exception('Could not resolve URL for field using view name "%s"' % view_name) )
raise Exception(msg % view_name)
kwargs = {self.slug_url_kwarg: slug}
try:
return reverse(view_name, kwargs=kwargs, request=request, format=format)
except NoReverseMatch:
pass
kwargs = {self.pk_url_kwarg: obj.pk, self.slug_url_kwarg: slug}
try:
return reverse(view_name, kwargs=kwargs, request=request, format=format)
except NoReverseMatch:
pass
raise Exception('Could not resolve URL for field using view name "%s"' % view_name)
def from_native(self, value): def from_native(self, value):
# Convert URL -> model instance pk # Convert URL -> model instance pk
# TODO: Use values_list # TODO: Use values_list
if self.queryset is None: queryset = self.queryset
if queryset is None:
raise Exception('Writable related fields must include a `queryset` argument') raise Exception('Writable related fields must include a `queryset` argument')
try: try:
@ -387,29 +436,11 @@ class HyperlinkedRelatedField(RelatedField):
if match.view_name != self.view_name: if match.view_name != self.view_name:
raise ValidationError(self.error_messages['incorrect_match']) raise ValidationError(self.error_messages['incorrect_match'])
pk = match.kwargs.get(self.pk_url_kwarg, None)
slug = match.kwargs.get(self.slug_url_kwarg, None)
# Try explicit primary key.
if pk is not None:
queryset = self.queryset.filter(pk=pk)
# Next, try looking up by slug.
elif slug is not None:
slug_field = self.get_slug_field()
queryset = self.queryset.filter(**{slug_field: slug})
# If none of those are defined, it's probably a configuation error.
else:
raise ValidationError(self.error_messages['configuration_error'])
try: try:
obj = queryset.get() return self.get_object(queryset, match.view_name,
except ObjectDoesNotExist: match.args, match.kwargs)
except (ObjectDoesNotExist, TypeError, ValueError):
raise ValidationError(self.error_messages['does_not_exist']) raise ValidationError(self.error_messages['does_not_exist'])
except (TypeError, ValueError):
msg = self.error_messages['incorrect_type']
raise ValidationError(msg % type(value).__name__)
return obj
class HyperlinkedIdentityField(Field): class HyperlinkedIdentityField(Field):

View File

@ -836,6 +836,7 @@ class HyperlinkedModelSerializer(ModelSerializer):
""" """
_options_class = HyperlinkedModelSerializerOptions _options_class = HyperlinkedModelSerializerOptions
_default_view_name = '%(model_name)s-detail' _default_view_name = '%(model_name)s-detail'
_hyperlink_field_class = HyperlinkedRelatedField
url = HyperlinkedIdentityField() url = HyperlinkedIdentityField()
@ -874,7 +875,7 @@ class HyperlinkedModelSerializer(ModelSerializer):
if model_field: if model_field:
kwargs['required'] = not(model_field.null or model_field.blank) kwargs['required'] = not(model_field.null or model_field.blank)
return HyperlinkedRelatedField(**kwargs) return self._hyperlink_field_class(**kwargs)
def get_identity(self, data): def get_identity(self, data):
""" """