Commits

Matthew Schinckel  committed 6393623 Merge

Merge

  • Participants
  • Parent commits b626a8f, f6f8110

Comments (0)

Files changed (24)

 syntax:glob
 
 test/venv
+dist
+recursive-include repose *
+include README.rst
+repose - a RESTful django framework
+===================================
+
+Why repose? Why another framework?
+----------------------------------
+
+This is not the first time I've written a framework for creating a
+RESTful interface from django. The first attempt, called ``rest_api``
+was modelled on the django admin interface (``django.contrib.admin``),
+and was used in a work project extensively.
+
+There were lots of bits I didn't like about it. Some of this I didn't
+even realize until I came across ``django-rest-framework``: which uses
+django forms for validation.
+
+I decided to take this even further.
+
+Repose is a form-based rest-api framework.
+
+That is, the intention is that you will use django's forms to create and
+render your views, even if they are JSON or XML.
+
+Installation
+------------
+
+You'll want to install repose into your virtualenv.
+
+Usage
+-----
+
+Repose uses the django class-based-views structure. In fact, the views
+within repose all inherit from ``django.views.generic.View``.
+
+Repose provides a handful of pre-built views, but the general idea is
+that you will probably want to sub-class them.
+
+The simplest way to use a repose view is to install it in your urls.py
+as follows:
+
+::
+
+    from repose.views import ModelDetailView
+
+    from models import ModelName
+
+    urlpatterns = patterns('',
+        url(r'^path/$', ModelDetailView.as_view(model=ModelName), name="modelname_detail"),
+    )
+
+This will automatically create a ModelForm (which also includes the 'id'
+field, allowing the client to know about relationships without having to
+embed them).
+
+You can supply your own Form, and/or View classes:
+
+::
+
+    from repose.views import ModelDetailView
+    from models import ModelName
+    from forms import ModelNameForm
+
+    class ModelNameView(ModelDetailView):
+        form = ModelNameForm
+
+You may also override some specific methods in your View class to change
+behaviour. The most common is likely to be ``get_queryset``, which
+enables you you change which subset of objects ``request.user`` is able
+to access. For the detail view classes, you can also override
+``get_object``.
+
+There are some useful Mixins that do this type of thing in ``mixins.py``
 You may also override some specific methods in your View class to change behaviour. The most common is likely to be ``get_queryset``, which enables you you change which subset of objects ``request.user`` is able to access. For the detail view classes, you can also override ``get_object``.
 
 
-If you have the desired url patterns `/api/model/` and `/api/model/:id/`, which should be mapped to a matching pair of index and detail views, you can use the following shortcut:
-
-    from repose.helpers import urlpatterns_for
-		from models import ModelName
-		from forms import ModelFormName
-		
-		urlpatterns = (
-				urlpatterns_for(r'^model-name', ModelName, form=ModelName)
-		)
-
-You may also pass in a `bases` argument, which contains a tuple or classes that should be mixed in to the api class.
+There are some useful Mixins that do this type of thing in `mixins.py`

File repose/VERSION

+1.0

File repose/__init__.py

+"""
+django-repose: a form-based RESTful framework for django.
+
+Please see the Readme.txt file.
+"""
+import os
+
+VERSION = open(os.path.join(os.path.dirname(__file__), 'VERSION')).read().strip()
+
+import repose.autourls
+
+class Site(object):
+    """
+    A wrapper to enable us to use the (urlpatterns, app, namespace)
+    form of urls.
+    """
+    @property
+    def urls(self):
+        "All of the auto-discovered repose-based urls."
+        return repose.autourls.autodiscover(), 'repose', 'repose'
+        
+site = Site()

File repose/autourls.py

+"""
+Based on the django.contrib.admin autodiscover pattern, this
+module looks through all of the INSTALLED_APPS, and attempts
+to add any api_urls.urlpatterns objects to our urlpatterns object.
+"""
+
 from django.conf.urls.defaults import patterns
 from django.utils.importlib import import_module
 
     LOADING = False
     
     return urlpatterns
-
-urlpatterns = autodiscover()

File repose/expose.py

 """
-Expose is a feature of repose that allows for more dynamic clients.
+Expose is a feature of repose that allows for more dynamic clients,
+and less setup of routes.
 
-Registering a rel-type with expose 
+Registering a rel-type with expose will do the following:
+
+* Create a route:
+    r'/types/(?P<rel_type>.+)/'
+    
+Accessing this resource will provide the following:
+
+    {
+        "name": "Nice name for this rel-type",
+        "description": "Extended description for this rel-type",
+        "fields": [
+            {
+                "name": "field-name",
+                "type": "field-type"
+            }
+        ]
+    }
+
 
 """
 

File repose/forms.py

     resource location (uri) should tell us the id.
     """
     # We set required=False, as if it is not present, we usually still want to proceed.
-    id = forms.IntegerField(required=False)
+    id = forms.IntegerField(required=False, widget=forms.widgets.HiddenInput)
     
     # Would like to stick these on Meta, but then it doesn't get attached to _meta.
-    readonly_fields = ()
-    _hidden_fields = ()
-    
-    def hidden_fields(self, *args, **kwargs):
-        """
-        Still a bit of a hack around _hidden_fields, so we need to add them
-        to the form class's hidden_fields.
-        """
-        fields = super(BaseApiModelForm, self).hidden_fields(*args, **kwargs)
-        return fields + list(self._hidden_fields)
+    readonly_fields = ('id',)
+    # _hidden_fields = ('id',)
+    # 
+    # def hidden_fields(self, *args, **kwargs):
+    #     """
+    #     Still a bit of a hack around _hidden_fields, so we need to add them
+    #     to the form class's hidden_fields.
+    #     """
+    #     fields = super(BaseApiModelForm, self).hidden_fields(*args, **kwargs)
+    #     return fields + list(self._hidden_fields)
     
     def get_changed_fields(self):
         """
     def updated_message(self):
         return _(u"Updated %s. Changed fields %s." % (
             self.instance, self.get_changed_fields()
-        ))
+        ))
+    
+                

File repose/helpers.py

 """
 Convenience functions.
 
-TODO: Allow for different forms for IndexView.
-
+This module may go away: it is fairly abstract, and the one
+time I was using it I had to stop as it was too general.
 """
 
 from django.conf.urls.defaults import patterns, url
         attributes['get_queryset'] = get_queryset
     
     if readonly:
-        BaseModelIndexView = ReadOnlyModelIndexView
-        BaseModelDetailView = ReadOnlyModelDetailView
+        IndexView = ReadOnlyModelIndexView
+        DetailView = ReadOnlyModelDetailView
     else:
-        BaseModelIndexView = ModelIndexView
-        BaseModelDetailView = ModelDetailView
+        IndexView = ModelIndexView
+        DetailView = ModelDetailView
     
-    index = type("%sIndexView" % name, bases + (BaseModelIndexView,), attributes)    
-    detail = type("%sDetailView" % name, bases + (BaseModelDetailView,), attributes)
+    index = type("%sIndexView" % name, bases + (IndexView,), attributes)    
+    detail = type("%sDetailView" % name, bases + (DetailView,), attributes)
     
     return index, detail
 
     classes (base), and/or a custom form.
     """
     index, detail = model_api_view_factory(model, bases=bases, form=form)
+    name = model.__name__.lower()
     return patterns('',
-        url(r'%s/$' % path, index.as_view(), name="%s_index" % path),
-        url(r'%s/(?P<pk>\d+)/$' % path, detail.as_view(), name="%s_detail" % path),
-    )
+        url(r'%s/$' % path,
+            index.as_view(),
+            name="%s_index" % name),
+        url(r'%s/(?P<pk>\d+)/$' % path,
+            detail.as_view(),
+            name="%s_detail" % name),
+    )
+
+
+class Links(list):
+    """
+    Allows access to a list of dicts by searching for the first one that matches rel=
+    """
+    def __getattr__(self, name):
+        for item in self:
+            if item['rel'] == name:
+                return item
+        return super(Links, self).__getattr__(name)

File repose/http.py

 import django.http
 from django.conf import settings
 from django.db.models.query import QuerySet
+from django.utils.translation import ugettext as _
 
 from repose import serializers
 
         if not self._is_string and self.has_header('Content-Type'):
             serializer = serializers.get_serializer(self['Content-Type'])
             if serializer:
-                return serializer.serialize(self._container)
+                data = serializer.serialize(self._container, response=self)
+                self['Content-Length'] = len(data)
+                return data
         return super(BaseHttpResponse, self)._get_content()
     
     def _set_content(self, value):
 class NoContentMixin(object):
     """Mixin to prevent a content body from being sent."""
     def __init__(self, *args, **kwargs):
-        err_msg = '\
-            Body provided on a response type that should not contain one.'
+        err_msg = _(
+            u'Body provided on a response type that should not contain one.'
+        )
         super(NoContentMixin, self).__init__(*args, **kwargs)
         if hasattr(self, '_container') and self._container:
             logger.warning(err_msg)
     """
     status_code = 503
     
-    def __init__(self, data="Server unavailable.", time=120):
+    def __init__(self, data=_(u"Server unavailable."), time=120):
         super(ServiceNotAvailable, self).__init__(data)
         self['Retry-After'] = time
 

File repose/mixins.py

 
 """
 from django.contrib.auth.decorators import login_required
+from django.dispatch import receiver
 from django.views.decorators.csrf import csrf_exempt
 from django.utils.decorators import method_decorator
+from django.utils.translation import ugettext as _
 
-from repose import http
+from repose import http, signals
 
 class LoginRequired(object):
     """
             raise http.Forbidden()
         obj.deleted = True
         obj.save()
-        return http.OK()
+        return http.NoContent()
 
-class UserFilterMixin(LoginRequired):
+###
+#
+#   Queryset filtering Mixin factories.
+#
+###
+
+def kwargs_filter_factory(name, kwarg, field):
     """
-    An (internally) complicated Mixin, that allows for arbitrary filtering of objects
-    based on something to do with the request.
+    Create a new Mixin class, that filters by kwargs that were supplied to the 
+    view. This is generally used for parts of the URL:
+        
+        FooMixin = kwargs_filter_factory('FooMixin', 'foo_id', 'foo')
+        
+        class View(ApiView, FooMixin):
+            pass
+        
+        url(r'^foo/(?P<foo_id>)/bar/$', View.as_view())
     
-    By default, it filters all objects and only returns objects who have
-    a `.user` field equal to the current `request.user`.
+    This does two things: it filters the queryset so that it only fetches
+    the `bar` objects that are related to the foo with foo_id supplied as
+    part of the URL. It also removes foo_id from the response.
     
-    It is intendet to be extended: you can subclass it and override
-    `_field` and `_target`, or you can create a class dynamically:
+    It also means that when you create a new bar at this URI, it overrides any
+    supplied foo_id in the data body with the one from the match in the URI.
+    """
+    Class = type(name, (object,), {})
+    def get_queryset(self):
+        return super(Class, self).get_queryset().filter(**{field: self.kwargs[kwarg]})
+    Class.get_queryset = get_queryset
+    # Listen for signals that the form of this class is about to be created.
+    # We can't just set the sender=Class, as it doesn't match subclasses.
+    @receiver(signals.post_deserialize, weak=False)
+    def update_form_data(sender, request, **kwargs):
+        if request.data and isinstance(sender, Class):
+            request.data[field] = sender.kwargs[kwarg]
     
-        UserFilterMixin.options(field='foo', target='request.user.attribute')
+    # We also want to remove 'field' from the response.
+    @receiver(signals.pre_serialize, weak=False)
+    def hide_hidden_fields(sender, request, data, **kwargs):
+        if not isinstance(sender, Class):
+            return
+        if isinstance(data, list):
+            for obj in data:
+                obj.pop(field, None)
+        else:
+            data.pop(field, None)
     
-    This actually creates a new class.
+    return Class
+
+
+def request_filter_factory(name, field, target, login_required=True):
+    """
+    Allows for filtering the queryset based on an attribute of self.request 
+    (or more correctly, the view object.)
     
+    For instance, UserFilterMixin (below) will filter the queryset so that
+    only objects that have a user=self.request.user
+    
+    It will also force created/edited objects to have user=request.user.
+    
+    If login_required is True (default), then it also requires the user to
+    be logged in, using the LoginRequired mixin from above.
     """
-    _field = 'user'
-    _target = 'request.user'
+    def _lookup(instance, arg):
+        if '__' in arg:
+            lookups = arg.split('__')
+        elif '.' in arg:
+            lookups = arg.split('.')
+        else:
+            lookups = [arg]
+        
+        value = instance
+        for val in lookups:
+            value = getattr(value, val)
     
-    @classmethod
-    def options(cls, field=None, target=None):
-        "Create a new subclass, based on these arguments."
-        return type('UserFilterMixin', (cls,), {
-            '_field': field or cls._field, 
-            '_target': target or cls._target
-        })
-        
-        # TODO: Change to the following form.
-        # return type('UserFilterMixin', (cls,), {
-        #     'get_queryset': filter_queryset_by_attribute(cls, field, target)
-        # })
+        return value
     
-    def _repose_get_value(self):
-        """
-        Private helper method, that calculates the value according to the
-        self._target and self._field, and self.kwargs/self.request
-        """
-        if self._target in self.kwargs:
-            return self.kwargs[self._target]
-        if '__' in self._target:
-            lookups = self._target.split('__')
-        elif '.' in self._target:
-            lookups = self._target.split('.')
+    if login_required:
+        bases = (LoginRequired,)
+    else:
+        bases = (object,)
+    
+    Class = type(name, bases, {})
+    
+    def get_queryset(self):
+        return super(Class, self).get_queryset().filter(**{field: _lookup(self, target)})
+    Class.get_queryset = get_queryset
+    
+    @receiver(signals.post_deserialize, weak=False)
+    def update_form_data(sender, request, **kwargs):
+        if '__' in field:
+            return
+        if request.data and isinstance(sender, Class):
+            request.data[field] = _lookup(sender, target)
+    
+    # We also want to remove 'field' from the response.
+    @receiver(signals.pre_serialize, weak=False)
+    def hide_hidden_fields(sender, request, data, **kwargs):
+        if not isinstance(sender, Class):
+            return
+        if '__' in field:
+            return
+        if isinstance(data, list):
+            for obj in data:
+                obj.pop(field, None)
         else:
-            lookups = [self._target]
-        
-        if lookups[0] == 'request':
-            value = self
-        else:
-            value = self.request
-        
-        for attr in lookups:
-            value = getattr(value, attr)
-            if not value:
-                break
-        
-        return value
-        
-    def get_queryset(self):
-        """
-        Override the queryset fetcher to filter the result.
-        """
-        qs = super(UserFilterMixin, self).get_queryset()
-        return qs.filter(**{self._field: self._repose_get_value()})
+            data.pop(field, None)
+    
+    return Class
 
-    def get_form(self, **kwargs):
-        """
-        Even if we were passed in a form, we still need to patch it,
-        just to get the _repose_get_value() bit working.
-        """
-        form = self.form or super(UserFilterMixin, self).get_form(**kwargs)
-        
-        if not hasattr(form, '_patched_for_%s' % self._field):
-            def __init__(obj, *args, **kwargs):
-                if len(args):
-                    args[0][self._field] = self._repose_get_value()
-                super(form, obj).__init__(*args, **kwargs)
-            
-            form.__init__ = __init__
-            
-            def hidden_fields(obj):
-                return super(form, obj).hidden_fields() + [self._field]
-            
-            form.hidden_fields = hidden_fields
-            
-            setattr(form, '_patched_for_%s' % self._field, True)
-        
-        return form
-
+UserFilterMixin = request_filter_factory('UserFilterMixin', 'user', 'request.user')
 
 def filter_queryset_by_parameters(cls, parameters, required=False):
     """
     """
     def get_filter(request):
         if required:
-            if not all([x in request.GET for x in parameters.keys()]):
-                data = self.generate_error_message(
-                    message="You must supply all required query attributes",
-                    detail=dict([(k,k) for k in missing_keys])
+            missing_keys = [x for x in parameters.keys() if x not in request.GET]
+            if missing_keys:
+                data = dict(
+                    message=_(u"You must supply all required query parameters"),
+                    detail=dict([(k, _(u"This parameter is required")) for k in missing_keys])
                 )
                 raise http.UnprocessableEntity(data)
         filter_parameters = {}
         for key, value in request.GET.iteritems():
             if key in parameters:
-                filter_parameters[parameters[key]] = value
+                # See if there were multiple instances of this parameter.
+                values = request.GET.getlist(key)
+                if len(values) > 1:
+                    filter_parameters[parameters[key] + "__in"] = values
+                else:
+                    filter_parameters[parameters[key]] = value
         return filter_parameters
     
     def get_queryset(self):
         return super(cls, self).get_queryset().filter(**get_filter(self.request))
         
     return get_queryset
+
+def parameter_filter_factory(name, **parameters):
+    Class = type(name, (object,), {})
+    Class.get_queryset = filter_queryset_by_parameters(Class, parameters)
+    return Class
+
+def required_parameter_filter_factory(name, **parameters):
+    Class = type(name, (object,), {})
+    Class.get_queryset = filter_queryset_by_parameters(Class, parameters, required=True)
+    return Class
     
-class ParameterMixin(object):
-    """
-    This Mixin allows us to filter items based on arguments passed in
-    through the request.GET keys.
-    
-    You will want to use this like:
-    
-        ParameterMixin.options(start='start__gte', finish='finish_lte')
-    
-    This creates a new class that filters objects and only selects those
-    whose start and finish are within the range supplied by the start and
-    finish GET parameters.
-    """
-        
-    @classmethod
-    def options(cls, **parameters):
-        "Create a new subclass, based on these arguments."
-        return type('ParameterMixin', (cls,), {
-            'get_queryset': filter_queryset_by_parameters(cls, parameters)
-        })
-
-class RequiredParameterMixin(object):
-    """
-    A ParameterMixin subclass that requires _all_ of its parameters to be
-    present in the request.GET, else it returns an UnprocessableEntity() error.
-    """    
-    @classmethod
-    def options(cls, **parameters):
-        "Create a new subclass, based on these arguments."
-        return type('RequiredParameterMixin', (cls,), {
-            'get_queryset': filter_queryset_by_parameters(cls, parameters, required=True)
-        })
-    
-DateRangeOverlapMixin = RequiredParameterMixin.options(start='finish__gte', finish='start__lte')
-DateRangeContainedMixin = RequiredParameterMixin.options(start='start__gte', finish='finish_lte')
+DateRangeOverlapMixin = parameter_filter_factory('DateRangeOverlapMixin', start='finish__gte', finish='start__lte')
+RequiredDateRangeOverlapMixin = required_parameter_filter_factory('RequiredDateRangeOverlapMixin', start='finish__gte', finish='start__lte')
+DateRangeContainedMixin = parameter_filter_factory('DateRangeContainedMixin', start='start__gte', finish='finish_lte')
+RequiredDateRangeContainedMixin = required_parameter_filter_factory('RequiredDateRangeContainedMixin', start='start__gte', finish='finish_lte')

File repose/serializers/__init__.py

-import json, post_encoded, xml, yaml
+import json, post_encoded, html, xml, yaml
 from base import get_incoming_content_type, get_preferred_content_type
 from registry import register, unregister, get_serializer
 

File repose/serializers/base.py

 from django.utils import simplejson
+from django import forms
+from django.db.models.query import QuerySet
 
 INVALID_CHARS = (
     ('\x96', '-'),
     parameters = []
     if data and ';' in data:
         data, parameters = data.split(';', 1)
+        if ',' in data:
+            data = data.split(',')[0]
         parameters = map(str.strip, parameters.split(';'))
+        
     return data, parameters
     
 def get_incoming_content_type(request):
     content_type, parameters = parse_content_type(request.META.get('HTTP_ACCEPT', ''))
     return content_type or get_incoming_content_type(request)
 
+
+def pre_serialize_form(form):
+    if isinstance(form, forms.BaseForm):        
+        result = {}
+        for name in form.fields.keys():
+            if name in form.hidden_fields():
+                continue
+            result[name] = form.data.get(name) or form.initial.get(name)
+            if not result[name] and hasattr(form.instance, name):
+                result[name] = getattr(form.instance, name)
+            elif not hasattr(form.instance, name):
+                # How to handle a non-model field?
+                raise Exception("Non-model field not handled.")
+            if callable(result[name]):
+                result[name] = result[name]()
+        
+        if hasattr(form, 'links'):
+            result['links'] = form.links
+        return result
+    
+    if isinstance(form, (list, tuple, QuerySet)):
+        return [pre_serialize_form(x) for x in form]
+    
+    return form
+    
+# Base class for serializers to inherit from.
 class BaseSerializer(object):
     def serialize(self, data, **kwargs):
         raise NotImplemented()

File repose/serializers/html.py

+from django.template import loader, RequestContext
+from django import forms
+
+from base import BaseSerializer
+from registry import register
+
+
+class HTMLSerializer(BaseSerializer):
+    content_type = "text/html"
+    uses_form = True
+    
+    def serialize(self, data, request=None, template_name=None, response=None, **kwargs):
+        context = {}
+        if isinstance(data, forms.BaseForm):
+            context['form'] = data
+        elif isinstance(data, (list, tuple)):
+            context['forms'] = data
+        
+        # Realistically, if we got an error, and we are rendering HTML, we want to
+        # re-render the same form...
+        
+        if response and not template_name:
+            if response.status_code >= 400:
+                template_name = ['%s.html' % response.status_code, 'repose/error.html']
+            if not context:
+                context = data
+        
+        output = loader.render_to_string(
+            template_name,
+            context,
+            context_instance=RequestContext(request) if request else {},
+            **kwargs
+        )
+        try:
+            return str(output)
+        except:
+            pass
+        return unicode(output)
+
+register(HTMLSerializer())

File repose/serializers/json.py

 except ImportError:
     pytz = None
 
-from base import BaseSerializer
+from base import BaseSerializer, pre_serialize_form
 from registry import register
 
 def default(obj):
     if isinstance(obj, datetime.datetime):
-        return obj.strftime("%Y-%m-%d %H:%M:%S")
+        return obj.strftime("%Y-%m-%d %H:%M:%S%z")
     if isinstance(obj, datetime.date):
         return obj.strftime("%Y-%m-%d")
     if isinstance(obj, datetime.time):
         seconds = obj.seconds % 60
         return "%i %02i:%02i:%02i" % (days, hours, minutes, seconds)
     if isinstance(obj, Decimal):
+        # Should this be unicode?
         return float(obj)
     if pytz and isinstance(obj, pytz.tzinfo.BaseTzInfo):
         return str(obj)
 class JSONSerializer(BaseSerializer):
     content_type = 'application/json'
     
-    def serialize(self, data, **kwargs):
+    def serialize(self, form=None, request=None, response=None, **kwargs):
+        data = pre_serialize_form(form)
+        kwargs.pop('template_name', None)
         if settings.DEBUG:
             kwargs['indent'] = 2
         return simplejson.dumps(data, default=default, **kwargs)

File repose/serializers/post_encoded.py

+from django.http import QueryDict
+
 from base import BaseSerializer
+from registry import register
 
 class PostEncodedSerializer(BaseSerializer):
     """
-    This serializer converts the data into a POST-encoded format. [NOT DONE YET]
-    
-    It also parses raw data into a QueryDict, then into a dict, and finally replaces
+    This serializer parses raw data into a QueryDict, then into a dict, and finally replaces
     any single-length list items into a single item.
     """
     content_type = 'application/x-www-form-urlencoded'
     
-    def serialize(data, **kwargs):
-        return data
-    
-    def deserialize(data):
+    def deserialize(self, data):
         data = dict(QueryDict(data))
         for k,v in data.items():
             if isinstance(v, list) and len(v) == 1:
                 data[k] = v[0]
         return data
+
+register(PostEncodedSerializer())

File repose/serializers/xml.py

-from base import BaseSerializer
+from base import BaseSerializer, pre_serialize_form
 from registry import register
 
 class XMLSerializer(BaseSerializer):
     content_type = 'application/xml'
     
     def serialize(self, data, **kwargs):
+        data = pre_serialize_form(data)
         return to_xml(data)
     
     def deserialize(self, data):

File repose/signals.py

+"""
+Signals created by repose framework.
+"""
+
+from django.dispatch import Signal
+
+# These signals sandwich the deserialization process.
+pre_deserialize = Signal(providing_args=["request"])
+post_deserialize = Signal(providing_args=["request"])
+
+# These signals are not yet run.
+pre_serialize = Signal(providing_args=["request", "response"])
+post_serialize = Signal(providing_args=["request", "response"])
+
+# Enable us to access just before rendering.
+pre_render = Signal(providing_args=["request", "response"])
+
+pre_validate_data = Signal(providing_args=["data", "form_class"])

File repose/templates/repose/detail.html

Empty file added.

File repose/templates/repose/error.html

+<html>
+  <head>
+    <title>An error occurred</title>
+  </head>
+  <body>
+    <h1>{{ message }}</h1>
+    <ul>
+      {% for field,error in detail.items %}
+        <li><em>{{ field }}</em>: {{ error }}</li>
+      {% endfor %}
+    </ul>
+  </body>
+</html>

File repose/templates/repose/index.html

+<html>
+<head>
+  <title>API View</title>
+</head>
+
+<body>
+  <table>
+    {% for form in forms %}
+      <tr>
+        <td>{{ form.instance }}</td>
+        <td><a href="{{ form.links.edit }}">Edit</a></td>
+        <td><a href="{{ form.links.delete }}">Delete</a></td>
+      </tr>
+    {% endfor %}
+  </table>
+</body>
+</html>

File repose/views.py

 import hashlib
 
 from django.views.generic import View
+from django.views.generic.base import TemplateResponseMixin
 from django.views.generic.list import MultipleObjectMixin
 from django.views.generic.detail import SingleObjectMixin
-from django.forms import ModelForm, Form
+from django.forms import ModelForm, Form, BaseForm
+from django.forms.formsets import formset_factory
 from django.forms.models import ModelFormMetaclass
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import IntegrityError, models
 from django.http import HttpResponse
 from django.utils.translation import ugettext as _
 
-from repose import http, serializers
+from repose import http, serializers, signals
 from repose.forms import BaseApiModelForm
 from repose.models import log_action, ADDITION, CHANGE, DELETION
 
     are dealing with a resource that has a model associated with it.
     """
     form = None
+    formset = None
     etag_function = None
+    template_name = 'repose/index.html'
     
     def get_form(self):
-        assert isinstance(self.form, Form), "Form must by provided to allow serialization, and must be a subclass of django.forms.Form"
+        assert isinstance(self.form, BaseForm) or issubclass(self.form, BaseForm), "Form must by provided to allow serialization, and must be a subclass of django.forms.Form"
         return self.form
     
     @method_decorator(csrf_exempt)
         self.kwargs = kwargs
         self.get_form()
         try:
+            # Whilst we generally have as little stuff in an Exception handler as possible, in this case we have a range of functions that all might raise an Exception.
+            signals.pre_deserialize.send(sender=self, request=request)
             request.data = serializers.deserialize(request)
-            response = super(ApiView, self).dispatch(request ,*args, **kwargs)
+            signals.post_deserialize.send(sender=self, request=request)
+            # Now use the already existing infrastructure to call the correct view based on the HTTP request method.
+            response = super(ApiView, self).dispatch(request, *args, **kwargs)
         except http.BaseHttpResponse, response:
+            # This sets the variable `response` to be the object that was raised, if it was a subclass of http.BaseHttpResponse. This enables us to throw a response from arbitrarily deep in the stack.
             pass
         except ObjectDoesNotExist, exc:
             # Do we turn this into a serializable structure, ie for JSON
                 raise
             return http.BadRequest(exc.args[0])
         
+        content_type = serializers.get_preferred_content_type(request)
         if isinstance(response, (http.BaseHttpResponse, HttpResponse)):
+            response['Content-Type'] = content_type
             # See if we have an explicitly added content-type.
             if hasattr(response, '_reset_content_type'):
-                response['Content-Type'] = serializers.get_preferred_content_type(request)
                 del response._reset_content_type
         else:
             if self.etag_function:
                 etag = self.etag_function(response)
-            response = self.pre_serialize(response)
-            response = http.OK(response, 
-                content_type=serializers.get_preferred_content_type(request))
+            form = self.pre_serialize(response)
+            serializer = serializers.get_serializer(content_type)
+            response = http.OK(
+                serializer.serialize(form, request=request, template_name=self.template_name), 
+                content_type=serializer.content_type
+            )
             if self.etag_function:
                 response['Etag'] = etag
         
-        # Do we add in a hook here to allow for post-processing of the response.
         return response
     
     def generate_error_message(self, message, detail=None):
         if detail:
             result['detail'] = detail
         return result
-
+    
     def pre_serialize(self, data):
-        """
-        Turn data into a serializable format, using a form.
-        """
-        # If the object is a queryset, list or tuple, then generate a form from each instance.
+        # TODO Remove the reliance on isinstance().
         if isinstance(data, (QuerySet, list, tuple)):
             return [self.pre_serialize(x) for x in data]
+        if isinstance(data, BaseForm):
+            form = data
+        elif isinstance(data, models.Model):
+            if hasattr(self, 'can_view') and not self.can_view(data):
+                raise http.Forbidden(_(u"You may not view all of the requested data"))
+            form = self.form(instance=data)
+        else:
+            signals.pre_validate_data.send(sender=self, data=data, form_class=self.form)
+            form = self.form(data)
         
-        if isinstance(data, (self.form, Form, ModelForm)):
-            form = data
-        # If the object is a django model, instantiate a form.
-        elif isinstance(data, models.Model):
-            form = self.form(instance=data)
-        elif isinstance(data, dict):
-            form = self.form(data)
-        else:
-            import pdb; pdb.set_trace()
-            # not sure what to do here!
-            return data
+        # Inject links into form.
+        if hasattr(self, 'links'):
+            form.links = self.links(form)
+        elif hasattr(form, 'instance') and hasattr(form.instance, 'links'):
+            form.links = form.instance.links
         
-        result = {}
-        for name in form.fields.keys():
-            if name in form.hidden_fields():
-                continue
-            result[name] = form.data.get(name) or form.initial.get(name) or getattr(form.instance, name)
-            if callable(result[name]):
-                result[name] = result[name]()
-        
-        # Not really that happy with having the 'links' attribute on the model.
-        # Should it be on the form, or on the ApiView?
-        if getattr(form.instance, 'links', None):
-            result['links'] = form.instance.links
-        
-        return result
+        return form
 
     def check_etag(self, data):
         """
         Compare this to the etag of the provided data, and depending upon the
         type of etag header, raise an exception if the condition requires it.
         """
-        # We can use the same method to test both types of etag, since the header
-        # field they use differs.
-        new_etag = self.etag_function(data)
+        # We can use the same method to test both types of etag, since the header field they use differs.
+        new_etag = self.etag_function and self.etag_function(data)
         if not new_etag:
             return
-        # If we have an 'If-None-Match' header, then we need to raise a NotModified
-        # exception if the object's header exactly matches the calculated etag.
+        # If we have an 'If-None-Match' header, then we need to raise a NotModified exception if the object's header exactly matches the calculated etag.
         if self.request.META.get('HTTP_IF_NONE_MATCH', None) == new_etag:
             raise http.NotModified()
-        # If we have an 'If-Match' header, then we need to raise a PreconditionFailed
-        # exception if the headers do not exactly match. We use new_etag as the default
-        # from META.get in case there is no such header.
+        # If we have an 'If-Match' header, then we need to raise a PreconditionFailed exception if the headers do not exactly match. We use new_etag as the default from META.get in case there is no such header.
         if self.request.META.get('HTTP_IF_MATCH', new_etag) != new_etag:
             raise http.PreconditionFailed()
 
         if hasattr(user, 'can_create'):
             return user.can_create(model)
         if hasattr(model, 'createable_by'):
-            return obj.createable_by(user)
+            return model.createable_by(user)
         
         return True
     
         error_count = len([x for x in errors if x])
         if error_count:
             return http.Conflict({
-                'message':_('There %(was)s %(count)i error%(plural)s' % {
-                    'was' : 'was' if error_count == 1 else 'were',
+                'message':_(u'There %(was)s %(count)i error%(plural)s' % {
+                    'was' : _(u'was') if error_count == 1 else _(u'were'),
                     'count': error_count,
                     'plural': 's' if error_count > 1 else ''
                 }),
-                'detail': errors,
+                'detail': errors[0] if len(errors) == 1 else errors,
             }, content_type=request.META.get('HTTP_ACCEPT'))
         
         for form in forms:
     
     You may override `get_object` if you need a way to prevent a user viewing an object.
     """
+    template = 'repose/detail.html'
+    
     def get_object(self, queryset=None):
         # Override this if you just need a different way of getting an object.
         # You can also override to prevent a person viewing an object: and raise an
         obj = self.get_object()
         if not self.can_update(obj):
             raise http.Forbidden()
-        # Get the current attribute values for this object. Only the ones in the form.
-        data = dict([(name, getattr(obj, name)) for name in self.form({}).fields])
-        # Now replace those that we were passed in.
-        data.update(**request.data)
-        form = self.form(data, instance=obj)
+        form = self.form(request.data, instance=obj)
         with log_action(request.user, obj, CHANGE, form.updated_message):
             self.save_form(form)
+        if getattr(self, 'redirect_after_post', True):
+            raise http.SeeOther(location='.')
         return form
     
     def patch(self, request, *args, **kwargs):
         """
         obj = self.get_object()
         if not self.can_delete(obj):
-            raise http.Forbidden()
+            raise http.Forbidden(_(u"You may not delete that object."))
         with log_action(request.user, obj, DELETION):
             obj.delete()
-        return http.OK()
+        return http.NoContent()
 from setuptools import setup
+from repose import VERSION
+
+try:
+    import subprocess
+    subprocess.call(["pandoc", "README.txt", "-t", "rst", "-o" "README.rst"])
+except:
+    pass
 
 setup(
     name = "repose",
-    version = "1.0",
-    description = "Form-based RESTful interface for Django sites, a la django.contrib.admin",
+    version = VERSION,
+    description = "Form-based RESTful interface for Django sites.",
+    long_description = open("README.rst").read(),
     url = "http://bitbucket.org/schinckel/repose/",
     author = "Matthew Schinckel",
     author_email = "matt@schinckel.net",