diff options
| author | Tom Christie | 2011-06-02 12:58:10 +0100 |
|---|---|---|
| committer | Tom Christie | 2011-06-02 12:58:10 +0100 |
| commit | b50492853f537a2473bb0a9eea86c8b0ed6b8824 (patch) | |
| tree | d289d39aacf187a8a0696a4c1c863aabe1472c3a /djangorestframework/resources.py | |
| parent | 7ee9adbe5c03c29cd4a894dd476548f7fe73b5e4 (diff) | |
| parent | fc1640de75511006e89f033c9270ec91a9f1e4d4 (diff) | |
| download | django-rest-framework-b50492853f537a2473bb0a9eea86c8b0ed6b8824.tar.bz2 | |
pull in -dev as 0.2.0
Diffstat (limited to 'djangorestframework/resources.py')
| -rw-r--r-- | djangorestframework/resources.py | 494 |
1 files changed, 494 insertions, 0 deletions
diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py new file mode 100644 index 00000000..adf5c1c3 --- /dev/null +++ b/djangorestframework/resources.py @@ -0,0 +1,494 @@ +from django import forms +from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch +from django.db import models +from django.db.models.query import QuerySet +from django.db.models.fields.related import RelatedField +from django.utils.encoding import smart_unicode + +from djangorestframework.response import ErrorResponse +from djangorestframework.utils import as_tuple + +import decimal +import inspect +import re + + +# TODO: _IgnoreFieldException + +# Map model classes to resource classes +#_model_to_resource = {} + + +def _model_to_dict(instance, resource=None): + """ + Given a model instance, return a ``dict`` representing the model. + + The implementation is similar to Django's ``django.forms.model_to_dict``, except: + + * It doesn't coerce related objects into primary keys. + * It doesn't drop ``editable=False`` fields. + * It also supports attribute or method fields on the instance or resource. + """ + opts = instance._meta + data = {} + + #print [rel.name for rel in opts.get_all_related_objects()] + #related = [rel.get_accessor_name() for rel in opts.get_all_related_objects()] + #print [getattr(instance, rel) for rel in related] + #if resource.fields: + # fields = resource.fields + #else: + # fields = set(opts.fields + opts.many_to_many) + + fields = resource and resource.fields or () + include = resource and resource.include or () + exclude = resource and resource.exclude or () + + extra_fields = fields and list(resource.fields) or [] + + # Model fields + for f in opts.fields + opts.many_to_many: + if fields and not f.name in fields: + continue + if exclude and f.name in exclude: + continue + if isinstance(f, models.ForeignKey): + data[f.name] = getattr(instance, f.name) + else: + data[f.name] = f.value_from_object(instance) + + if extra_fields and f.name in extra_fields: + extra_fields.remove(f.name) + + # Method fields + for fname in extra_fields: + if hasattr(resource, fname): + # check the resource first, to allow it to override fields + obj = getattr(resource, fname) + # if it's a method like foo(self, instance), then call it + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 2: + obj = obj(instance) + elif hasattr(instance, fname): + # now check the object instance + obj = getattr(instance, fname) + else: + continue + + # TODO: It would be nicer if this didn't recurse here. + # Let's keep _model_to_dict flat, and _object_to_data recursive. + data[fname] = _object_to_data(obj) + + return data + + +def _object_to_data(obj, resource=None): + """ + Convert an object into a serializable representation. + """ + if isinstance(obj, dict): + # dictionaries + # TODO: apply same _model_to_dict logic fields/exclude here + return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ]) + if isinstance(obj, (tuple, list, set, QuerySet)): + # basic iterables + return [_object_to_data(item, resource) for item in obj] + if isinstance(obj, models.Manager): + # Manager objects + return [_object_to_data(item, resource) for item in obj.all()] + if isinstance(obj, models.Model): + # Model instances + return _object_to_data(_model_to_dict(obj, resource)) + if isinstance(obj, decimal.Decimal): + # Decimals (force to string representation) + return str(obj) + if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]: + # function with no args + return _object_to_data(obj(), resource) + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1: + # bound method + return _object_to_data(obj(), resource) + + return smart_unicode(obj, strings_only=True) + + +class BaseResource(object): + """ + Base class for all Resource classes, which simply defines the interface they provide. + """ + fields = None + include = None + exclude = None + + def __init__(self, view): + self.view = view + + def validate_request(self, data, files=None): + """ + Given the request content return the cleaned, validated content. + Typically raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + """ + return data + + def filter_response(self, obj): + """ + Given the response content, filter it into a serializable object. + """ + return _object_to_data(obj, self) + + +class Resource(BaseResource): + """ + A Resource determines how a python object maps to some serializable data. + Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets. + """ + + # The model attribute refers to the Django Model which this Resource maps to. + # (The Model's class, rather than an instance of the Model) + model = None + + # By default the set of returned fields will be the set of: + # + # 0. All the fields on the model, excluding 'id'. + # 1. All the properties on the model. + # 2. The absolute_url of the model, if a get_absolute_url method exists for the model. + # + # If you wish to override this behaviour, + # you should explicitly set the fields attribute on your class. + fields = None + + +class FormResource(Resource): + """ + Resource class that uses forms for validation. + Also provides a :meth:`get_bound_form` method which may be used by some renderers. + + On calling :meth:`validate_request` this validator may set a :attr:`bound_form_instance` attribute on the + view, which may be used by some renderers. + """ + + """ + The :class:`Form` class that should be used for request validation. + This can be overridden by a :attr:`form` attribute on the :class:`views.View`. + """ + form = None + + + def validate_request(self, data, files=None): + """ + Given some content as input return some cleaned, validated content. + Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + + Validation is standard form validation, with an additional constraint that *no extra unknown fields* may be supplied. + + On failure the :exc:`response.ErrorResponse` content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'errors'` key exists it is a list of strings of non-field errors. + If the :obj:`'field-errors'` key exists it is a dict of ``{'field name as string': ['errors as strings', ...]}``. + """ + return self._validate(data, files) + + + def _validate(self, data, files, allowed_extra_fields=(), fake_data=None): + """ + Wrapped by validate to hide the extra flags that are used in the implementation. + + allowed_extra_fields is a list of fields which are not defined by the form, but which we still + expect to see on the input. + + fake_data is a string that should be used as an extra key, as a kludge to force .errors + to be populated when an empty dict is supplied in `data` + """ + + # We'd like nice error messages even if no content is supplied. + # Typically if an empty dict is given to a form Django will + # return .is_valid() == False, but .errors == {} + # + # To get around this case we revalidate with some fake data. + if fake_data: + data[fake_data] = '_fake_data' + allowed_extra_fields = allowed_extra_fields + ('_fake_data',) + + bound_form = self.get_bound_form(data, files) + + if bound_form is None: + return data + + self.view.bound_form_instance = bound_form + + data = data and data or {} + files = files and files or {} + + seen_fields_set = set(data.keys()) + form_fields_set = set(bound_form.fields.keys()) + allowed_extra_fields_set = set(allowed_extra_fields) + + # In addition to regular validation we also ensure no additional fields are being passed in... + unknown_fields = seen_fields_set - (form_fields_set | allowed_extra_fields_set) + unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept')) # TODO: Ugh. + + # Check using both regular validation, and our stricter no additional fields rule + if bound_form.is_valid() and not unknown_fields: + # Validation succeeded... + cleaned_data = bound_form.cleaned_data + + cleaned_data.update(bound_form.files) + + # Add in any extra fields to the cleaned content... + for key in (allowed_extra_fields_set & seen_fields_set) - set(cleaned_data.keys()): + cleaned_data[key] = data[key] + + return cleaned_data + + # Validation failed... + detail = {} + + if not bound_form.errors and not unknown_fields: + # is_valid() was False, but errors was empty. + # If we havn't already done so attempt revalidation with some fake data + # to force django to give us an errors dict. + if fake_data is None: + return self._validate(data, files, allowed_extra_fields, '_fake_data') + + # If we've already set fake_dict and we're still here, fallback gracefully. + detail = {u'errors': [u'No content was supplied.']} + + else: + # Add any non-field errors + if bound_form.non_field_errors(): + detail[u'errors'] = bound_form.non_field_errors() + + # Add standard field errors + field_errors = dict( + (key, map(unicode, val)) + for (key, val) + in bound_form.errors.iteritems() + if not key.startswith('__') + ) + + # Add any unknown field errors + for key in unknown_fields: + field_errors[key] = [u'This field does not exist.'] + + if field_errors: + detail[u'field-errors'] = field_errors + + # Return HTTP 400 response (BAD REQUEST) + raise ErrorResponse(400, detail) + + + def get_bound_form(self, data=None, files=None, method=None): + """ + Given some content return a Django form bound to that content. + If form validation is turned off (:attr:`form` class attribute is :const:`None`) then returns :const:`None`. + """ + + # A form on the view overrides a form on the resource. + form = getattr(self.view, 'form', self.form) + + # Use the requested method or determine the request method + if method is None and hasattr(self.view, 'request') and hasattr(self.view, 'method'): + method = self.view.method + elif method is None and hasattr(self.view, 'request'): + method = self.view.request.method + + # A method form on the view or resource overrides the general case. + # Method forms are attributes like `get_form` `post_form` `put_form`. + if method: + form = getattr(self, '%s_form' % method.lower(), form) + form = getattr(self.view, '%s_form' % method.lower(), form) + + if not form: + return None + + if data is not None: + return form(data, files) + + return form() + + + +#class _RegisterModelResource(type): +# """ +# Auto register new ModelResource classes into ``_model_to_resource`` +# """ +# def __new__(cls, name, bases, dct): +# resource_cls = type.__new__(cls, name, bases, dct) +# model_cls = dct.get('model', None) +# if model_cls: +# _model_to_resource[model_cls] = resource_cls +# return resource_cls + + + +class ModelResource(FormResource): + """ + Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. + Also provides a :meth:`get_bound_form` method which may be used by some renderers. + """ + + # Auto-register new ModelResource classes into _model_to_resource + #__metaclass__ = _RegisterModelResource + + """ + The form class that should be used for request validation. + If set to :const:`None` then the default model form validation will be used. + + This can be overridden by a :attr:`form` attribute on the :class:`views.View`. + """ + form = None + + """ + The model class which this resource maps to. + + This can be overridden by a :attr:`model` attribute on the :class:`views.View`. + """ + model = None + + """ + The list of fields to use on the output. + + May be any of: + + The name of a model field. + The name of an attribute on the model. + The name of an attribute on the resource. + The name of a method on the model, with a signature like ``func(self)``. + The name of a method on the resource, with a signature like ``func(self, instance)``. + """ + fields = None + + """ + The list of fields to exclude. This is only used if :attr:`fields` is not set. + """ + exclude = ('id', 'pk') + + """ + The list of extra fields to include. This is only used if :attr:`fields` is not set. + """ + include = ('url',) + + + def __init__(self, view): + """ + Allow :attr:`form` and :attr:`model` attributes set on the + :class:`View` to override the :attr:`form` and :attr:`model` + attributes set on the :class:`Resource`. + """ + super(ModelResource, self).__init__(view) + + if getattr(view, 'model', None): + self.model = view.model + + def validate_request(self, data, files=None): + """ + Given some content as input return some cleaned, validated content. + Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + + Validation is standard form or model form validation, + with an additional constraint that no extra unknown fields may be supplied, + and that all fields specified by the fields class attribute must be supplied, + even if they are not validated by the form/model form. + + On failure the ErrorResponse content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'errors'` key exists it is a list of strings of non-field errors. + If the ''field-errors'` key exists it is a dict of {field name as string: list of errors as strings}. + """ + return self._validate(data, files, allowed_extra_fields=self._property_fields_set) + + + def get_bound_form(self, data=None, files=None, method=None): + """ + Given some content return a ``Form`` instance bound to that content. + + If the :attr:`form` class attribute has been explicitly set then that class will be used + to create the Form, otherwise the model will be used to create a ModelForm. + """ + + form = super(ModelResource, self).get_bound_form(data, files, method=method) + + # Use an explict Form if it exists + if form: + return form + + elif self.model: + # Fall back to ModelForm which we create on the fly + class OnTheFlyModelForm(forms.ModelForm): + class Meta: + model = self.model + #fields = tuple(self._model_fields_set) + + # Instantiate the ModelForm as appropriate + if data and isinstance(data, models.Model): + # Bound to an existing model instance + return OnTheFlyModelForm(instance=content) + elif data is not None: + return OnTheFlyModelForm(data, files) + return OnTheFlyModelForm() + + # Both form and model not set? Okay bruv, whatevs... + return None + + + def url(self, instance): + """ + Attempts to reverse resolve the url of the given model *instance* for this resource. + + Requires a ``View`` with :class:`mixins.InstanceMixin` to have been created for this resource. + + This method can be overridden if you need to set the resource url reversing explicitly. + """ + + # dis does teh magicks... + urlconf = get_urlconf() + resolver = get_resolver(urlconf) + + possibilities = resolver.reverse_dict.getlist(self.view_callable[0]) + for tuple_item in possibilities: + possibility = tuple_item[0] + # pattern = tuple_item[1] + # Note: defaults = tuple_item[2] for django >= 1.3 + for result, params in possibility: + + #instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ]) + + instance_attrs = {} + for param in params: + if not hasattr(instance, param): + continue + attr = getattr(instance, param) + if isinstance(attr, models.Model): + instance_attrs[param] = attr.pk + else: + instance_attrs[param] = attr + + try: + return reverse(self.view_callable[0], kwargs=instance_attrs) + except NoReverseMatch: + pass + raise NoReverseMatch + + + @property + def _model_fields_set(self): + """ + Return a set containing the names of validated fields on the model. + """ + model_fields = set(field.name for field in self.model._meta.fields) + + if fields: + return model_fields & set(as_tuple(self.fields)) + + return model_fields - set(as_tuple(self.exclude)) + + @property + def _property_fields_set(self): + """ + Returns a set containing the names of validated properties on the model. + """ + property_fields = set(attr for attr in dir(self.model) if + isinstance(getattr(self.model, attr, None), property) + and not attr.startswith('_')) + + if self.fields: + return property_fields & set(as_tuple(self.fields)) + + return property_fields.union(set(as_tuple(self.include))) - set(as_tuple(self.exclude)) |
