From 8f6bcac7f3f156831343cc7fec79f624dcc2639f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 May 2011 09:59:36 +0100 Subject: cleanup --- djangorestframework/mixins.py | 51 ++- djangorestframework/parsers.py | 6 +- djangorestframework/renderers.py | 3 +- djangorestframework/resource.py | 552 -------------------------------- djangorestframework/resources.py | 568 +++++++++++++++++++++++++++++++++ djangorestframework/tests/resource.py | 31 -- djangorestframework/tests/resources.py | 31 ++ djangorestframework/views.py | 93 +++--- 8 files changed, 695 insertions(+), 640 deletions(-) delete mode 100644 djangorestframework/resource.py create mode 100644 djangorestframework/resources.py delete mode 100644 djangorestframework/tests/resource.py create mode 100644 djangorestframework/tests/resources.py diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 524e9268..12f2d779 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -46,12 +46,20 @@ class RequestMixin(object): _CONTENTTYPE_PARAM = '_content_type' _CONTENT_PARAM = '_content' + """ + The set of request parsers that the view can handle. + + Should be a tuple/list of classes as described in the ``parsers`` module. + """ parsers = () @property def method(self): """ Returns the HTTP method. + + This should be used instead of ``request.method``, as it allows the method + to be overridden by using a hidden form field on a form POST request. """ if not hasattr(self, '_method'): self._load_method_and_content_type() @@ -62,6 +70,10 @@ class RequestMixin(object): def content_type(self): """ Returns the content type header. + + This should be used instead of ``request.META.get('HTTP_CONTENT_TYPE')``, + as it allows the content type to be overridden by using a hidden form + field on a form POST request. """ if not hasattr(self, '_content_type'): self._load_method_and_content_type() @@ -71,7 +83,10 @@ class RequestMixin(object): @property def DATA(self): """ - Returns the request data. + Parses the request body and returns the data. + + Similar to ``request.POST``, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). """ if not hasattr(self, '_data'): self._load_data_and_files() @@ -81,7 +96,9 @@ class RequestMixin(object): @property def FILES(self): """ - Returns the request files. + Parses the request body and returns the files. + Similar to request.FILES, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). """ if not hasattr(self, '_files'): self._load_data_and_files() @@ -205,8 +222,14 @@ class ResponseMixin(object): _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params _IGNORE_IE_ACCEPT_HEADER = True + """ + The set of response renderers that the view can handle. + + Should be a tuple/list of classes as described in the ``renderers`` module. + """ renderers = () - + + # TODO: wrap this behavior around dispatch(), ensuring it works # out of the box with existing Django classes that use render_to_response. def render(self, response): @@ -330,14 +353,33 @@ class AuthMixin(object): """ Simple mixin class to add authentication and permission checking to a ``View`` class. """ + + """ + The set of authentication types that this view can handle. + + + Should be a tuple/list of classes as described in the ``authentication`` module. + """ authentication = () + + """ + The set of permissions that will be enforced on this view. + + Should be a tuple/list of classes as described in the ``permissions`` module. + """ permissions = () + @property def user(self): + """ + Returns the user for the current request, as determined by the set of + authentication classes applied to the ``View``. + """ if not hasattr(self, '_user'): self._user = self._authenticate() return self._user + def _authenticate(self): """ @@ -351,6 +393,7 @@ class AuthMixin(object): return user return AnonymousUser() + # TODO: wrap this behavior around dispatch() def _check_permissions(self): """ @@ -359,7 +402,7 @@ class AuthMixin(object): user = self.user for permission_cls in self.permissions: permission = permission_cls(self) - permission.check_permission(user) + permission.check_permission(user) ########## Resource Mixin ########## diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 9e1b971b..4337098a 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -111,7 +111,8 @@ class PlainTextParser(BaseParser): class FormParser(BaseParser, DataFlatener): - """The default parser for form data. + """ + The default parser for form data. Return a dict containing a single value for each non-reserved parameter. In order to handle select multiple (and having possibly more than a single value for each parameter), @@ -122,7 +123,8 @@ class FormParser(BaseParser, DataFlatener): """The value of the parameter when the select multiple is empty. Browsers are usually stripping the select multiple that have no option selected from the parameters sent. A common hack to avoid this is to send the parameter with a value specifying that the list is empty. - This value will always be stripped before the data is returned.""" + This value will always be stripped before the data is returned. + """ EMPTY_VALUE = '_empty' RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index e8763f34..371b5ef0 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -7,6 +7,7 @@ and providing forms and links depending on the allowed methods, renderers and pa """ from django import forms from django.conf import settings +from django.core.serializers.json import DateTimeAwareJSONEncoder from django.template import RequestContext, loader from django.utils import simplejson as json @@ -81,7 +82,7 @@ class JSONRenderer(BaseRenderer): except (ValueError, TypeError): indent = None - return json.dumps(obj, indent=indent, sort_keys=sort_keys) + return json.dumps(obj, cls=DateTimeAwareJSONEncoder, indent=indent, sort_keys=sort_keys) class XMLRenderer(BaseRenderer): diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py deleted file mode 100644 index 775d5288..00000000 --- a/djangorestframework/resource.py +++ /dev/null @@ -1,552 +0,0 @@ -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 - -import decimal -import inspect -import re - - - -def _model_to_dict(instance, fields=None, exclude=None): - """ - This is a clone of Django's ``django.forms.model_to_dict`` except that it - doesn't coerce related objects into primary keys. - """ - opts = instance._meta - data = {} - for f in opts.fields + opts.many_to_many: - if not f.editable: - continue - 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) - return data - - -def _object_to_data(obj): - """ - Convert an object into a serializable representation. - """ - if isinstance(obj, dict): - # dictionaries - 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) for item in obj] - if isinstance(obj, models.Manager): - # Manager objects - return [_object_to_data(item) for item in obj.all()] - if isinstance(obj, models.Model): - # Model instances - return _object_to_data(_model_to_dict(obj)) - 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()) - if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1: - # method with only a 'self' args - return _object_to_data(obj()) - - # fallback - return smart_unicode(obj, strings_only=True) - - -def _form_to_data(form): - """ - Returns a dict containing the data in a form instance. - - This code is pretty much a clone of the ``Form.as_p()`` ``Form.as_ul`` - and ``Form.as_table()`` methods, except that it returns data suitable - for arbitrary serialization, rather than rendering the result directly - into html. - """ - ret = {} - for name, field in form.fields.items(): - if not form.is_bound: - data = form.initial.get(name, field.initial) - if callable(data): - data = data() - else: - if isinstance(field, FileField) and form.data is None: - data = form.initial.get(name, field.initial) - else: - data = field.widget.value_from_datadict(form.data, form.files, name) - ret[name] = field.prepare_value(data) - return ret - - -class BaseResource(object): - """Base class for all Resource classes, which simply defines the interface they provide.""" - - def __init__(self, view): - self.view = view - - def validate(self, data, files): - """Given some content as input return some cleaned, validated content. - Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. - - Must be overridden to be implemented.""" - return data - - def object_to_data(self, obj): - return _object_to_data(obj) - - -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 - - # TODO: Replace this with new Serializer code based on Forms API. - def object_to_data(self, obj): - """ - A (horrible) munging of Piston's pre-serialization. Returns a dict. - """ - - def _any(thing, fields=()): - """ - Dispatch, all types are routed through here. - """ - ret = None - - if isinstance(thing, QuerySet): - ret = _qs(thing, fields=fields) - elif isinstance(thing, (tuple, list)): - ret = _list(thing) - elif isinstance(thing, dict): - ret = _dict(thing) - elif isinstance(thing, int): - ret = thing - elif isinstance(thing, bool): - ret = thing - elif isinstance(thing, type(None)): - ret = thing - elif isinstance(thing, decimal.Decimal): - ret = str(thing) - elif isinstance(thing, models.Model): - ret = _model(thing, fields=fields) - #elif isinstance(thing, HttpResponse): TRC - # raise HttpStatusCode(thing) - elif inspect.isfunction(thing): - if not inspect.getargspec(thing)[0]: - ret = _any(thing()) - elif hasattr(thing, '__rendertable__'): - f = thing.__rendertable__ - if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: - ret = _any(f()) - else: - ret = unicode(thing) # TRC - - return ret - - def _fk(data, field): - """ - Foreign keys. - """ - return _any(getattr(data, field.name)) - - def _related(data, fields=()): - """ - Foreign keys. - """ - return [ _model(m, fields) for m in data.iterator() ] - - def _m2m(data, field, fields=()): - """ - Many to many (re-route to `_model`.) - """ - return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] - - - def _method_fields(data, fields): - if not data: - return { } - - has = dir(data) - ret = dict() - - for field in fields: - if field in has: - ret[field] = getattr(data, field) - - return ret - - def _model(data, fields=()): - """ - Models. Will respect the `fields` and/or - `exclude` on the handler (see `typemapper`.) - """ - ret = { } - #handler = self.in_typemapper(type(data), self.anonymous) # TRC - handler = None # TRC - get_absolute_url = False - - if fields: - v = lambda f: getattr(data, f.attname) - - get_fields = set(fields) - if 'absolute_url' in get_fields: # MOVED (TRC) - get_absolute_url = True - - met_fields = _method_fields(handler, get_fields) # TRC - - for f in data._meta.local_fields: - if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): - if not f.rel: - if f.attname in get_fields: - ret[f.attname] = _any(v(f)) - get_fields.remove(f.attname) - else: - if f.attname[:-3] in get_fields: - ret[f.name] = _fk(data, f) - get_fields.remove(f.name) - - for mf in data._meta.many_to_many: - if mf.serialize and mf.attname not in met_fields: - if mf.attname in get_fields: - ret[mf.name] = _m2m(data, mf) - get_fields.remove(mf.name) - - # try to get the remainder of fields - for maybe_field in get_fields: - - if isinstance(maybe_field, (list, tuple)): - model, fields = maybe_field - inst = getattr(data, model, None) - - if inst: - if hasattr(inst, 'all'): - ret[model] = _related(inst, fields) - elif callable(inst): - if len(inspect.getargspec(inst)[0]) == 1: - ret[model] = _any(inst(), fields) - else: - ret[model] = _model(inst, fields) - - elif maybe_field in met_fields: - # Overriding normal field which has a "resource method" - # so you can alter the contents of certain fields without - # using different names. - ret[maybe_field] = _any(met_fields[maybe_field](data)) - - else: - maybe = getattr(data, maybe_field, None) - if maybe: - if callable(maybe): - if len(inspect.getargspec(maybe)[0]) == 1: - ret[maybe_field] = _any(maybe()) - else: - ret[maybe_field] = _any(maybe) - else: - pass # TRC - #handler_f = getattr(handler or self.handler, maybe_field, None) - # - #if handler_f: - # ret[maybe_field] = _any(handler_f(data)) - - else: - # Add absolute_url if it exists - get_absolute_url = True - - # Add all the fields - for f in data._meta.fields: - if f.attname != 'id': - ret[f.attname] = _any(getattr(data, f.attname)) - - # Add all the propertiess - klass = data.__class__ - for attr in dir(klass): - if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): - #if attr.endswith('_url') or attr.endswith('_uri'): - # ret[attr] = self.make_absolute(_any(getattr(data, attr))) - #else: - ret[attr] = _any(getattr(data, attr)) - #fields = dir(data.__class__) + ret.keys() - #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] - #print add_ons - ###print dir(data.__class__) - #from django.db.models import Model - #model_fields = dir(Model) - - #for attr in dir(data): - ## #if attr.startswith('_'): - ## # continue - # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): - # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields - - #for k in add_ons: - # ret[k] = _any(getattr(data, k)) - - # TRC - # resouce uri - #if self.in_typemapper(type(data), self.anonymous): - # handler = self.in_typemapper(type(data), self.anonymous) - # if hasattr(handler, 'resource_uri'): - # url_id, fields = handler.resource_uri() - # ret['resource_uri'] = permalink( lambda: (url_id, - # (getattr(data, f) for f in fields) ) )() - - # TRC - #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: - # try: ret['resource_uri'] = data.get_api_url() - # except: pass - - # absolute uri - if hasattr(data, 'get_absolute_url') and get_absolute_url: - try: ret['absolute_url'] = data.get_absolute_url() - except: pass - - #for key, val in ret.items(): - # if key.endswith('_url') or key.endswith('_uri'): - # ret[key] = self.add_domain(val) - - return ret - - def _qs(data, fields=()): - """ - Querysets. - """ - return [ _any(v, fields) for v in data ] - - def _list(data): - """ - Lists. - """ - return [ _any(v) for v in data ] - - def _dict(data): - """ - Dictionaries. - """ - return dict([ (k, _any(v)) for k, v in data.iteritems() ]) - - # Kickstart the seralizin'. - return _any(obj, self.fields) - - -class FormResource(Resource): - """Validator class that uses forms for validation. - Also provides a get_bound_form() method which may be used by some renderers. - - The view class should provide `.form` attribute which specifies the form classmethod - to be used for validation. - - On calling validate() this validator may set a `.bound_form_instance` attribute on the - view, which may be used by some renderers.""" - - - def validate(self, data, files): - """ - Given some content as input return some cleaned, validated content. - Raises a 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 ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. - If the '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) - - - def _validate(self, data, files, allowed_extra_fields=()): - """ - Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses. - extra_fields is a list of fields which are not defined by the form, but which we still - expect to see on the input. - """ - bound_form = self.get_bound_form(data, files) - - if bound_form is None: - return data - - self.view.bound_form_instance = bound_form - - 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) - - # 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: - 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): - """Given some content return a Django form bound to that content. - If form validation is turned off (form class attribute is None) then returns None.""" - form_cls = getattr(self, 'form', None) - - if not form_cls: - return None - - if data is not None: - return form_cls(data, files) - - return form_cls() - - -class ModelResource(FormResource): - """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. - Also provides a get_bound_form() method which may be used by some renderers.""" - - """The form class that should be used for validation, or None to use model form validation.""" - form = None - - """The model class from which the model form should be constructed if no form is set.""" - model = None - - """The list of fields we expect to receive as input. Fields in this list will may be received with - raising non-existent field errors, even if they do not exist as fields on the ModelForm. - - Setting the fields class attribute causes the exclude_fields class attribute to be disregarded.""" - fields = None - - """The list of fields to exclude from the Model. This is only used if the fields class attribute is not set.""" - exclude_fields = ('id', 'pk') - - - # TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out - # TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.) - def validate(self, data, files): - """ - Given some content as input return some cleaned, validated content. - Raises a 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 'errors' and 'field-errors' keys. - If the '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): - """Given some content return a Django form bound to that content. - - If the form class attribute has been explicitly set then use that class to create a Form, - otherwise if model is set use that class to create a ModelForm, otherwise return None.""" - - form_cls = getattr(self, 'form', None) - model_cls = getattr(self, 'model', None) - - if form_cls: - # Use explict Form - return super(ModelFormValidator, self).get_bound_form(data, files) - - elif model_cls: - # Fall back to ModelForm which we create on the fly - class OnTheFlyModelForm(forms.ModelForm): - class Meta: - model = model_cls - #fields = tuple(self._model_fields_set) - - # Instantiate the ModelForm as appropriate - if content and isinstance(content, models.Model): - # Bound to an existing model instance - return OnTheFlyModelForm(instance=content) - elif not data is None: - return OnTheFlyModelForm(data, files) - return OnTheFlyModelForm() - - # Both form and model not set? Okay bruv, whatevs... - return None - - - @property - def _model_fields_set(self): - """Return a set containing the names of validated fields on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - model_fields = set(field.name for field in model._meta.fields) - - if fields: - return model_fields & set(as_tuple(fields)) - - return model_fields - set(as_tuple(exclude_fields)) - - @property - def _property_fields_set(self): - """Returns a set containing the names of validated properties on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - property_fields = set(attr for attr in dir(model) if - isinstance(getattr(model, attr, None), property) - and not attr.startswith('_')) - - if fields: - return property_fields & set(as_tuple(fields)) - - return property_fields - set(as_tuple(exclude_fields)) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py new file mode 100644 index 00000000..f47b41d0 --- /dev/null +++ b/djangorestframework/resources.py @@ -0,0 +1,568 @@ +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 + +import decimal +import inspect +import re + + + +def _model_to_dict(instance, fields=None, exclude=None): + """ + This is a clone of Django's ``django.forms.model_to_dict`` except that it + doesn't coerce related objects into primary keys. + """ + 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] + + for f in opts.fields + opts.many_to_many: + #if not f.editable: + # continue + 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) + + #print fields - (opts.fields + opts.many_to_many) + #for related in [rel.get_accessor_name() for rel in opts.get_all_related_objects()]: + # if fields and not related in fields: + # continue + # if exclude and related in exclude: + # continue + # data[related] = getattr(instance, related) + + return data + + +def _object_to_data(obj): + """ + Convert an object into a serializable representation. + """ + if isinstance(obj, dict): + # dictionaries + 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) for item in obj] + if isinstance(obj, models.Manager): + # Manager objects + return [_object_to_data(item) for item in obj.all()] + if isinstance(obj, models.Model): + # Model instances + return _object_to_data(_model_to_dict(obj)) + 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()) + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1: + # method with only a 'self' args + return _object_to_data(obj()) + + # fallback + return smart_unicode(obj, strings_only=True) + + +def _form_to_data(form): + """ + Returns a dict containing the data in a form instance. + + This code is pretty much a clone of the ``Form.as_p()`` ``Form.as_ul`` + and ``Form.as_table()`` methods, except that it returns data suitable + for arbitrary serialization, rather than rendering the result directly + into html. + """ + ret = {} + for name, field in form.fields.items(): + if not form.is_bound: + data = form.initial.get(name, field.initial) + if callable(data): + data = data() + else: + if isinstance(field, FileField) and form.data is None: + data = form.initial.get(name, field.initial) + else: + data = field.widget.value_from_datadict(form.data, form.files, name) + ret[name] = field.prepare_value(data) + return ret + + +class BaseResource(object): + """Base class for all Resource classes, which simply defines the interface they provide.""" + + def __init__(self, view): + self.view = view + + def validate(self, data, files): + """Given some content as input return some cleaned, validated content. + Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. + + Must be overridden to be implemented.""" + return data + + def object_to_data(self, obj): + return _object_to_data(obj) + + +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 + + # TODO: Replace this with new Serializer code based on Forms API. + def object_to_data(self, obj): + """ + A (horrible) munging of Piston's pre-serialization. Returns a dict. + """ + + return _object_to_data(obj) + + def _any(thing, fields=()): + """ + Dispatch, all types are routed through here. + """ + ret = None + + if isinstance(thing, QuerySet): + ret = _qs(thing, fields=fields) + elif isinstance(thing, (tuple, list)): + ret = _list(thing) + elif isinstance(thing, dict): + ret = _dict(thing) + elif isinstance(thing, int): + ret = thing + elif isinstance(thing, bool): + ret = thing + elif isinstance(thing, type(None)): + ret = thing + elif isinstance(thing, decimal.Decimal): + ret = str(thing) + elif isinstance(thing, models.Model): + ret = _model(thing, fields=fields) + #elif isinstance(thing, HttpResponse): TRC + # raise HttpStatusCode(thing) + elif inspect.isfunction(thing): + if not inspect.getargspec(thing)[0]: + ret = _any(thing()) + elif hasattr(thing, '__rendertable__'): + f = thing.__rendertable__ + if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: + ret = _any(f()) + else: + ret = unicode(thing) # TRC + + return ret + + def _fk(data, field): + """ + Foreign keys. + """ + return _any(getattr(data, field.name)) + + def _related(data, fields=()): + """ + Foreign keys. + """ + return [ _model(m, fields) for m in data.iterator() ] + + def _m2m(data, field, fields=()): + """ + Many to many (re-route to `_model`.) + """ + return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] + + + def _method_fields(data, fields): + if not data: + return { } + + has = dir(data) + ret = dict() + + for field in fields: + if field in has: + ret[field] = getattr(data, field) + + return ret + + def _model(data, fields=()): + """ + Models. Will respect the `fields` and/or + `exclude` on the handler (see `typemapper`.) + """ + ret = { } + #handler = self.in_typemapper(type(data), self.anonymous) # TRC + handler = None # TRC + get_absolute_url = False + + if fields: + v = lambda f: getattr(data, f.attname) + + get_fields = set(fields) + if 'absolute_url' in get_fields: # MOVED (TRC) + get_absolute_url = True + + met_fields = _method_fields(handler, get_fields) # TRC + + for f in data._meta.local_fields: + if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): + if not f.rel: + if f.attname in get_fields: + ret[f.attname] = _any(v(f)) + get_fields.remove(f.attname) + else: + if f.attname[:-3] in get_fields: + ret[f.name] = _fk(data, f) + get_fields.remove(f.name) + + for mf in data._meta.many_to_many: + if mf.serialize and mf.attname not in met_fields: + if mf.attname in get_fields: + ret[mf.name] = _m2m(data, mf) + get_fields.remove(mf.name) + + # try to get the remainder of fields + for maybe_field in get_fields: + + if isinstance(maybe_field, (list, tuple)): + model, fields = maybe_field + inst = getattr(data, model, None) + + if inst: + if hasattr(inst, 'all'): + ret[model] = _related(inst, fields) + elif callable(inst): + if len(inspect.getargspec(inst)[0]) == 1: + ret[model] = _any(inst(), fields) + else: + ret[model] = _model(inst, fields) + + elif maybe_field in met_fields: + # Overriding normal field which has a "resource method" + # so you can alter the contents of certain fields without + # using different names. + ret[maybe_field] = _any(met_fields[maybe_field](data)) + + else: + maybe = getattr(data, maybe_field, None) + if maybe: + if callable(maybe): + if len(inspect.getargspec(maybe)[0]) == 1: + ret[maybe_field] = _any(maybe()) + else: + ret[maybe_field] = _any(maybe) + else: + pass # TRC + #handler_f = getattr(handler or self.handler, maybe_field, None) + # + #if handler_f: + # ret[maybe_field] = _any(handler_f(data)) + + else: + # Add absolute_url if it exists + get_absolute_url = True + + # Add all the fields + for f in data._meta.fields: + if f.attname != 'id': + ret[f.attname] = _any(getattr(data, f.attname)) + + # Add all the propertiess + klass = data.__class__ + for attr in dir(klass): + if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): + #if attr.endswith('_url') or attr.endswith('_uri'): + # ret[attr] = self.make_absolute(_any(getattr(data, attr))) + #else: + ret[attr] = _any(getattr(data, attr)) + #fields = dir(data.__class__) + ret.keys() + #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] + #print add_ons + ###print dir(data.__class__) + #from django.db.models import Model + #model_fields = dir(Model) + + #for attr in dir(data): + ## #if attr.startswith('_'): + ## # continue + # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): + # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields + + #for k in add_ons: + # ret[k] = _any(getattr(data, k)) + + # TRC + # resouce uri + #if self.in_typemapper(type(data), self.anonymous): + # handler = self.in_typemapper(type(data), self.anonymous) + # if hasattr(handler, 'resource_uri'): + # url_id, fields = handler.resource_uri() + # ret['resource_uri'] = permalink( lambda: (url_id, + # (getattr(data, f) for f in fields) ) )() + + # TRC + #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: + # try: ret['resource_uri'] = data.get_api_url() + # except: pass + + # absolute uri + if hasattr(data, 'get_absolute_url') and get_absolute_url: + try: ret['absolute_url'] = data.get_absolute_url() + except: pass + + #for key, val in ret.items(): + # if key.endswith('_url') or key.endswith('_uri'): + # ret[key] = self.add_domain(val) + + return ret + + def _qs(data, fields=()): + """ + Querysets. + """ + return [ _any(v, fields) for v in data ] + + def _list(data): + """ + Lists. + """ + return [ _any(v) for v in data ] + + def _dict(data): + """ + Dictionaries. + """ + return dict([ (k, _any(v)) for k, v in data.iteritems() ]) + + # Kickstart the seralizin'. + return _any(obj, self.fields) + + +class FormResource(Resource): + """Validator class that uses forms for validation. + Also provides a get_bound_form() method which may be used by some renderers. + + The view class should provide `.form` attribute which specifies the form classmethod + to be used for validation. + + On calling validate() this validator may set a `.bound_form_instance` attribute on the + view, which may be used by some renderers.""" + + + def validate(self, data, files): + """ + Given some content as input return some cleaned, validated content. + Raises a 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 ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. + If the '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) + + + def _validate(self, data, files, allowed_extra_fields=()): + """ + Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses. + extra_fields is a list of fields which are not defined by the form, but which we still + expect to see on the input. + """ + bound_form = self.get_bound_form(data, files) + + if bound_form is None: + return data + + self.view.bound_form_instance = bound_form + + 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) + + # 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: + 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): + """Given some content return a Django form bound to that content. + If form validation is turned off (form class attribute is None) then returns None.""" + form_cls = getattr(self, 'form', None) + + if not form_cls: + return None + + if data is not None: + return form_cls(data, files) + + return form_cls() + + +class ModelResource(FormResource): + """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. + Also provides a get_bound_form() method which may be used by some renderers.""" + + """The form class that should be used for validation, or None to use model form validation.""" + form = None + + """The model class from which the model form should be constructed if no form is set.""" + model = None + + """The list of fields we expect to receive as input. Fields in this list will may be received with + raising non-existent field errors, even if they do not exist as fields on the ModelForm. + + Setting the fields class attribute causes the exclude_fields class attribute to be disregarded.""" + fields = None + + """The list of fields to exclude from the Model. This is only used if the fields class attribute is not set.""" + exclude_fields = ('id', 'pk') + + + # TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out + # TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.) + def validate(self, data, files): + """ + Given some content as input return some cleaned, validated content. + Raises a 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 'errors' and 'field-errors' keys. + If the '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): + """Given some content return a Django form bound to that content. + + If the form class attribute has been explicitly set then use that class to create a Form, + otherwise if model is set use that class to create a ModelForm, otherwise return None.""" + + form_cls = getattr(self, 'form', None) + model_cls = getattr(self, 'model', None) + + if form_cls: + # Use explict Form + return super(ModelFormValidator, self).get_bound_form(data, files) + + elif model_cls: + # Fall back to ModelForm which we create on the fly + class OnTheFlyModelForm(forms.ModelForm): + class Meta: + model = model_cls + #fields = tuple(self._model_fields_set) + + # Instantiate the ModelForm as appropriate + if content and isinstance(content, models.Model): + # Bound to an existing model instance + return OnTheFlyModelForm(instance=content) + elif not data is None: + return OnTheFlyModelForm(data, files) + return OnTheFlyModelForm() + + # Both form and model not set? Okay bruv, whatevs... + return None + + + @property + def _model_fields_set(self): + """Return a set containing the names of validated fields on the model.""" + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) + + model_fields = set(field.name for field in model._meta.fields) + + if fields: + return model_fields & set(as_tuple(fields)) + + return model_fields - set(as_tuple(exclude_fields)) + + @property + def _property_fields_set(self): + """Returns a set containing the names of validated properties on the model.""" + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) + + property_fields = set(attr for attr in dir(model) if + isinstance(getattr(model, attr, None), property) + and not attr.startswith('_')) + + if fields: + return property_fields & set(as_tuple(fields)) + + return property_fields - set(as_tuple(exclude_fields)) diff --git a/djangorestframework/tests/resource.py b/djangorestframework/tests/resource.py deleted file mode 100644 index 0ed41951..00000000 --- a/djangorestframework/tests/resource.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for the resource module""" -from django.test import TestCase -from djangorestframework.resource import _object_to_data - -import datetime -import decimal - -class TestObjectToData(TestCase): - """Tests for the _object_to_data function""" - - def test_decimal(self): - """Decimals need to be converted to a string representation.""" - self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5') - - def test_function(self): - """Functions with no arguments should be called.""" - def foo(): - return 1 - self.assertEquals(_object_to_data(foo), 1) - - def test_method(self): - """Methods with only a ``self`` argument should be called.""" - class Foo(object): - def foo(self): - return 1 - self.assertEquals(_object_to_data(Foo().foo), 1) - - def test_datetime(self): - """datetime objects are left as-is.""" - now = datetime.datetime.now() - self.assertEquals(_object_to_data(now), now) \ No newline at end of file diff --git a/djangorestframework/tests/resources.py b/djangorestframework/tests/resources.py new file mode 100644 index 00000000..6aa569d3 --- /dev/null +++ b/djangorestframework/tests/resources.py @@ -0,0 +1,31 @@ +"""Tests for the resource module""" +from django.test import TestCase +from djangorestframework.resources import _object_to_data + +import datetime +import decimal + +class TestObjectToData(TestCase): + """Tests for the _object_to_data function""" + + def test_decimal(self): + """Decimals need to be converted to a string representation.""" + self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5') + + def test_function(self): + """Functions with no arguments should be called.""" + def foo(): + return 1 + self.assertEquals(_object_to_data(foo), 1) + + def test_method(self): + """Methods with only a ``self`` argument should be called.""" + class Foo(object): + def foo(self): + return 1 + self.assertEquals(_object_to_data(Foo().foo), 1) + + def test_datetime(self): + """datetime objects are left as-is.""" + now = datetime.datetime.now() + self.assertEquals(_object_to_data(now), now) \ No newline at end of file diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 211dafca..315c25a9 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -56,9 +56,10 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): """ return [method.upper() for method in self.http_method_names if hasattr(self, method)] + def http_method_not_allowed(self, request, *args, **kwargs): """ - Return an HTTP 405 error if an operation is called which does not have a handler method. + Return an HTTP 405 error if an operation is called which does not have a handler method. """ raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) @@ -68,56 +69,48 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - try: - self.request = request - self.args = args - self.kwargs = kwargs - - # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. - prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) - set_script_prefix(prefix) - - try: - # Authenticate and check request is has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) + self.request = request + self.args = args + self.kwargs = kwargs + + # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. + prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) + set_script_prefix(prefix) + + try: + # Authenticate and check request is has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.object_to_data(response.raw_content) - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.object_to_data(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - except: - import traceback - traceback.print_exc() - raise - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) - except: - import traceback - traceback.print_exc() + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) -- cgit v1.2.3