Commits

Matthew Schinckel committed 0b1b260

Initial import

  • Participants

Comments (0)

Files changed (22)

rest_api/__init__.py

+"""
+rest_api uses the same structure as django.contrib.admin to provide a
+RESTful API to a project's models.
+
+The simplest way to use it is to have the following in the project.urls:
+
+    import rest_api
+    rest_api.autodiscover()
+
+    urlpatterns += patterns('',
+        url('^api/', include(api.site.urls)),
+    )
+
+Inside files called api.py in each application a structure like this
+should be present:
+
+    import rest_api
+    from models import MyModel
+    
+    class MyModelApi(rest_api.ModelApi):
+        model = MyModel
+        require_auth = False
+    
+    api.site.register(MyModel, MyModelApi)
+    
+Similar to Admin, if you are happy with the default API, you can just use
+the following:
+
+    api.site.register(MyModel)
+
+
+"""
+
+from django.utils.importlib import import_module
+
+from sites import site, ApiSite
+from options import ModelApi, Api
+from helpers import dispatch
+
+# A flag to tell us if autodiscover is running.  autodiscover will set this to
+# True while running, and False when it finishes.
+LOADING = False
+
+def autodiscover():
+    """
+    Auto-discover INSTALLED_APPS api.py modules, and fail silently when
+    not present.  This forces an import on them to register any api bits
+    they may want.
+    """
+    # Bail out if autodiscover didn't finish loading from a previous call so
+    # that we avoid running autodiscover again when the URLconf is loaded by
+    # the exception handler to resolve the handler500 view.  This prevents an
+    # api.py module with errors from re-registering models and raising a
+    # spurious AlreadyRegistered exception (see #8245).
+    global LOADING
+    if LOADING:
+        return
+    LOADING = True
+
+    import imp
+    from django.conf import settings
+
+    for app in settings.INSTALLED_APPS:
+        # For each app, we need to look for an api.py inside that app's
+        # package. We can't use os.path here -- recall that modules may be
+        # imported different ways (think zip files) -- so we need to get
+        # the app's __path__ and look for api.py on that path.
+
+        # Step 1: find out the app's __path__ Import errors here will (and
+        # should) bubble up, but a missing __path__ (which is legal, but weird)
+        # fails silently -- apps that do weird things with __path__ might
+        # need to roll their own admin registration.
+        try:
+            app_path = import_module(app).__path__
+        except AttributeError:
+            continue
+
+        # Step 2: use imp.find_module to find the app's api.py. For some
+        # reason imp.find_module raises ImportError if the app can't be found
+        # but doesn't actually try to import the module. So skip this app if
+        # its api.py doesn't exist
+        try:
+            imp.find_module('api', app_path)
+        except ImportError:
+            continue
+
+        # Step 3: import the app's api file. If this has errors we want them
+        # to bubble up.
+        import_module("%s.api" % app)
+    # autodiscover was successful, reset loading flag.
+    LOADING = False
+    
+from django.conf import settings
+from django.http import HttpResponse
+from django.contrib.auth import authenticate
+
+import base64
+
+def _http_auth_helper(request):
+    "This is the part that does all of the work"
+    try:
+        if not settings.FORCE_HTTP_AUTH:
+            # If we don't mind if django's session auth is used, see if the
+            # user is already logged in, and use that user.
+            if request.user:
+                return None
+    except AttributeError:
+        pass
+        
+    # At this point, the user is either not logged in, or must log in using
+    # http auth.  If they have a header that indicates a login attempt, then
+    # use this to try to login.
+    if request.META.has_key('HTTP_AUTHORIZATION'):
+        auth = request.META['HTTP_AUTHORIZATION'].split()
+        if len(auth) == 2:
+            if auth[0].lower() == 'basic':
+                # Currently, only basic http auth is used.
+                uname, passwd = base64.b64decode(auth[1]).split(':')
+                user = authenticate(username=uname, password=passwd)
+                if user:
+                    if user.is_active:
+                        # If the user successfully logged in, then add/overwrite
+                        # the user object of this request.
+                        request.user = user
+                        return None
+    
+    # The username/password combo was incorrect, or not provided.
+    # Challenge the user for a username/password.
+    resp = HttpResponse()
+    resp.status_code = 401
+    try:
+        # If we have a realm in our settings, use this for the challenge.
+        realm = settings.HTTP_AUTH_REALM
+    except AttributeError:
+        realm = ""
+    
+    resp['WWW-Authenticate'] = 'Basic realm="%s"' % realm
+    return resp
+
+def _authenticate(request):
+    if request.META.has_key('HTTP_AUTHORIZATION'):
+        auth = request.META['HTTP_AUTHORIZATION'].split()
+        if len(auth) == 2:
+            if auth[0].lower() == 'basic':
+                # Currently, only basic http auth is used.
+                uname, passwd = base64.b64decode(auth[1]).split(':')
+                user = authenticate(username=uname, password=passwd)
+                if user and user.is_active:
+                    # If the user successfully logged in, then add/overwrite
+                    # the user object of this request.
+                    request.user = user
+    return None
+
+class http_auth(object):
+    """
+    A decorator to force the authorization of a Resource.
+    """
+    def __init__(self, func):
+        self.func = func
+
+    def __get__(self, obj, cls=None):
+        return self.__class__(self.func.__get__(obj, cls))
+
+    def __call__(self, request, *args, **kwargs):
+        result = _http_auth_helper(request)
+        if result is not None:
+            return result
+        return self.func(request, *args, **kwargs)

rest_api/decorators.py

+import http
+
+from functools import wraps
+class methods(object):
+    """
+    This decorator will only allow HttpRequest methods of the passed type to
+    be processed.  It uses HttpResponseMethodNotAllowed to flag an incorrect
+    method.
+    
+    This decorator can be used on either functions:
+    
+    @methods("GET", "POST")
+    def index(request, *args):
+        pass
+    
+    or with methods on a class:
+    
+    class ModelApi(object):
+        @methods("GET", "POST")
+        def index(self, request, *args):
+            pass
+    
+    We always add "HEAD" and "OPTIONS".  These will be handled automatically
+    by the decorator, if they haven't been handled by the app.
+    """
+    def __init__(self, *_methods):
+        self.methods = _methods
+        self._methods = list(set(_methods + ("HEAD", "OPTIONS")))
+
+    def __call__(self, func): 
+        @wraps(func)
+        def inner(*args, **kwargs):
+            try:
+                method = args[0].method
+                request = args[0]
+            except AttributeError:
+                method = args[1].method
+                request = args[1]
+
+            if method in self.methods:
+                return func(*args, **kwargs)
+            elif method == "OPTIONS":
+                # Return a list of available methods.
+                # Add HEAD and OPTIONS, if they aren't already there.
+                resp = http.OK()
+                resp['Allowed'] = self._methods
+                return resp
+            elif method == "HEAD":
+                # If we have a HEAD request that wasn't explicitly allowed,
+                # then call the GET method, and discard the content.
+                request.method = "GET"
+                resp = func(*args, **kwargs)
+                if resp.status_code == 200:
+                    resp.content = ""
+            return http.MethodNotAllowed(self._methods)
+        return inner
+"""
+These HttpResponse subclasses also inherit from Exception, so that we can
+easily bail out of a request, and still bubble up the required data as
+part of the HttpResponse.
+"""
+
+import django.http
+from serializers import serialize
+from django.db.models.query import QuerySet
+from django.conf import settings
+
+class BaseHttpResponse(django.http.HttpResponse, Exception):
+    def __init__(self, *args, **kwargs):
+        super(BaseHttpResponse, self).__init__(*args, **kwargs)
+        self['Vary'] = "Authentication"
+
+
+class Continue(BaseHttpResponse):
+    status_code = 100
+
+class SwitchingProtocols(BaseHttpResponse):
+    status_code = 101
+
+class HttpResponseSuccess(BaseHttpResponse):
+    pass
+    
+class OK(HttpResponseSuccess):
+    status_code = 200
+    
+    def __init__(self, data=None, fields=None, **kwargs):
+        self.raw_data = data
+        ser_kwargs = {}
+        if settings.DEBUG:
+            ser_kwargs['indent'] = 4
+        data = serialize(data, fields=fields, **ser_kwargs)
+        super(OK, self).__init__(data, **kwargs)
+        self['Content-Type'] = 'application/json'
+        try:
+            self['Last-Modified'] = self.raw_data.order_by('-modified')[0].modified.strftime('%a, %d %b %Y %X +1030')
+        except IndexError:
+            pass
+        except AttributeError:
+            pass
+
+        
+class Deleted(OK):
+    status_code = 200
+    
+    
+class Created(HttpResponseSuccess):
+    status_code = 201
+    def __init__(self, data, **kwargs):
+        super(Created, self).__init__("\n".join(map(lambda x:x.href, data)),**kwargs)
+        self['Location'] = data[0].href
+
+class Accepted(HttpResponseSuccess):
+    status_code = 202
+    # Body should indicate status
+
+class NonAuthoritativeInformation(HttpResponseSuccess):
+    status_code = 203
+
+class NoContent(HttpResponseSuccess):
+    status_code = 204
+    # Must NOT contain a Body
+
+class ResetContent(HttpResponseSuccess):
+    status_code = 205
+    # Must NOT contain a Body
+
+class PartialContent(HttpResponseSuccess):
+    status_code = 206
+    # Must have a Header of Content-Range
+    # Must have a Date header
+    # Must have ETag and/or Content-Location header, if it would have come
+    # with the 200 OK
+
+class HttpResponseRedirect(BaseHttpResponse):
+    pass
+    # Set the Location header to the URI of the object (or one repr of it)
+
+class MultipleChoices(HttpResponseRedirect):
+    status_code = 300
+    # Body should contain a list of URI/Characteristics
+    
+class MovedPermanently(HttpResponseRedirect):
+    status_code = 301
+    # New URI should be in Location header.
+    # Body should contain description, plus link to URI
+
+class Found(HttpResponseRedirect):
+    status_code = 302
+    # Temporary URI should be in Location header.
+    
+class SeeOther(HttpResponseRedirect):
+    status_code = 303
+    def __init__(self, obj):
+        super(HttpResponseRedirect, self).__init__('')
+        self['Location'] = obj.href
+
+class NotModified(HttpResponseRedirect):
+    status_code = 304
+    # Must include Date header.
+    # Must not include body
+
+class UseProxy(HttpResponseRedirect):
+    status_code = 305
+
+class TemporaryRedirect(HttpResponseRedirect):
+    status_code = 307
+
+class HttpResponseError(BaseHttpResponse):
+    pass
+
+class BadRequest(HttpResponseError):
+    status_code = 400
+
+class Unauthorized(HttpResponseError):
+    status_code = 401
+    # Must include WWW-Authenticate header.
+
+class PaymentRequired(HttpResponseError):
+    status_code = 402
+
+class Forbidden(HttpResponseError):
+    status_code = 403
+
+class NotFound(HttpResponseError):
+    status_code = 404
+
+class MethodNotAllowed(HttpResponseError):
+    status_code = 405
+    # Allow header required
+    def __init__(self, data):
+        super(HttpResponseError, self).__init__('')
+        self['Allow'] = str(data)
+
+class NotAcceptable(HttpResponseError):
+    status_code = 406
+
+class ProxyAuthenticationRequired(HttpResponseError):
+    status_code = 407
+
+class RequestTimeout(HttpResponseError):
+    status_code = 408
+
+class Conflict(HttpResponseError):
+    status_code = 409
+    # body should indicate what the conflict was
+
+class Gone(HttpResponseError):
+    status_code = 410
+
+class LengthRequired(HttpResponseError):
+    status_code = 411
+
+class PreconditionFailed(HttpResponseError):
+    status_code = 412
+
+class RequestEntityTooLarge(HttpResponseError):
+    status_code = 413
+
+class RequestURITooLong(HttpResponseError):
+    status_code = 414
+
+class UnsupportedMediaType(HttpResponseError):
+    status_code = 415
+    
+class RequestedRangeNotSatisfiable(HttpResponseError):
+    status_code = 416
+
+class ExpectationFailed(HttpResponseError):
+    status_code = 417
+

rest_api/middleware.py

+         
+from auth import _authenticate
+from django.contrib.auth.models import AnonymousUser
+
+class HttpAuthMiddleware(object):
+    """
+    Use HTTP Authorization to log in to django site.
+
+    If you use the FORCE_HTTP_AUTH=True in your settings.py, then ONLY
+    Http Auth will be used, if you don't then either http auth or 
+    django's session-based auth will be used.
+
+    If you provide a HTTP_AUTH_REALM in your settings, that will be used as
+    the realm for the challenge.
+    """
+
+    def process_request(self, request):
+#         import pdb; pdb.set_trace()
+        print "HAM", request.user
+        if isinstance(request.user, AnonymousUser):
+            print "MID", request.user
+            return _authenticate(request)

rest_api/models.py

+from django.contrib.admin import models
+
+ADDITION = models.ADDITION
+CHANGE = models.CHANGE
+DELETION = models.DELETION
+
+class LogEntry(models.LogEntry):
+    objects = models.LogEntryManager()
+    
+    class Meta:
+        db_table = 'django_api_log'
+    

rest_api/options.py

+"""
+
+"""
+
+from django.utils.functional import update_wrapper
+from django.utils.text import capfirst
+from django.core.urlresolvers import reverse, resolve
+from django.db import IntegrityError
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.utils.encoding import force_unicode
+from django.conf.urls.defaults import patterns, url
+        
+from decorators import methods
+import http
+from helpers import dispatch
+
+import datetime
+
+from models import LogEntry, ADDITION, CHANGE, DELETION
+
+class BaseApi(object):
+    # Do we require authentication on every API call?
+    require_auth = False
+    allowed_methods = ('GET','PUT','DELETE')
+    allowed_methods_index = ('GET','POST')
+    
+    # Set root path if you want it to be different to the lowercase name of
+    # the model. You will need to set this if you have two models in
+    # different apps with the same name.
+    root_path = None
+    
+    def urls(self):
+        return self.get_urls()
+    urls = property(urls)
+
+    def wrap(self, view, fields=None):
+        def wrapper(*args, **kwargs):
+            return self.api_site.api_view(view, fields=fields)(*args, **kwargs)
+        return update_wrapper(wrapper, view)
+    
+    def dispatch(self, fields=None, **methods):
+        lc_methods = dict( (method.lower(), handler) for (method, handler) in methods.iteritems() )
+
+        def __dispatch(request, *args, **kwargs):
+            handler = lc_methods.get(request.method.lower())
+            if handler:
+                return handler(request, *args, **kwargs)
+            else:
+                return http.MethodNotAllowed(m.upper() for m in lc_methods.keys())
+        return self.wrap(__dispatch, fields=fields)
+        
+class BaseModelApi(BaseApi):
+    """
+    Functionality common to ModelApi, and InlineApi.
+    """
+    # Change the default ordering key for this API model
+    ordering = ()
+    # What fields do we want to show in this model? None -> all
+    fields = None
+    # Which fields in the index view for this model?
+    index_fields = ('name', 'href', 'id')
+    # And which ones do we want to exclude?
+    exclude = ('created', 'modified')
+    
+class Api(BaseApi):
+    def __init__(self, api_site):
+        self.api_site = api_site
+        self.root_path = self.root_path or self.__class__.__name__.lower()
+        super(Api, self).__init__()
+        
+    def get_urls(self):
+        return patterns('', url(r'^$', self.wrap(self.index)))
+
+class ModelApi(BaseModelApi):
+    
+    def __init__(self, model, api_site):
+        self.model = model
+        self.opts = model._meta
+        self.api_site = api_site
+        self.fields = self.fields or [field.name for field in self.opts.fields if field.name not in self.exclude]
+        if api_site.index_provides_data:
+            self.index_fields = self.fields
+        model.href = property(lambda x: reverse('api:%s_%s_object' % (model._meta.app_label, model._meta.module_name), args=(x.pk,)))
+        self.root_path = self.root_path or model._meta.module_name
+        super(ModelApi, self).__init__()
+        
+    def get_urls(self):
+        info = self.opts.app_label, self.opts.module_name
+        
+        urlpatterns = patterns('',
+        url(r'^$',
+                self.dispatch(get=self.index, 
+                              post=self.create, 
+                              fields=self.index_fields),
+                name='%s_%s_index' % info),
+        url(r'^(\d+)/$',
+                self.dispatch(get=self.read, 
+                              put=self.update, 
+                              delete=self.delete,
+                              fields=self.fields),
+                name='%s_%s_object' % info),
+        )
+        
+        return urlpatterns
+    
+    def has_add_permission(self, request):
+        """
+        Returns True if the given request has permission to add objects of
+        this Django model.
+        """
+        opts = self.opts
+        return request.user.has_perm(opts.app_label + '.' + opts.get_add_permission())
+    
+    def has_view_permission(self, request, obj=None):
+        """
+        Returns True if the given request has permission to view the given
+        Django model instance.
+        
+        If `obj` is None, this should return True if the given request has
+        permission to view *any* object of the given type.
+        """
+        opts = self.opts
+        if not opts.get_view_permission():
+            return True
+        return request.user.has_perm(opts.app_label + '.' + opts.get_view_permission())
+    
+    def has_change_permission(self, request, obj=None):
+        """
+        Returns True if the given request has permission to change the given
+        Django model instance.
+        
+        If `obj` is None, this should return True if the given request has
+        permission to change *any* object of the given type.
+        """
+        opts = self.opts
+        return request.user.has_perm(opts.app_label + '.' + opts.get_change_permission()) \
+            and self.has_view_permission(request, obj)
+        
+    def has_delete_permission(self, request, obj=None):
+        """
+        Returns True if the given request has permission to delete the given
+        Django model instance.
+
+        If `obj` is None, this should return True if the given request has
+        permission to delete *any* object of the given type.
+        """
+        opts = self.opts
+        return request.user.has_perm(opts.app_label + '.' + opts.get_delete_permission()) \
+            and self.has_view_permission(request, obj)
+    
+    def get_model_perms(self, request):
+        return {
+            'view':self.has_view_permission(request),
+            'add':self.has_add_permission(request),
+            'change':self.has_change_permission(request),
+            'delete':self.has_delete_permission(request),
+        }
+
+    def queryset(self, request):
+        """
+        Returns a QuerySet of all model instances that can be viewed by the
+        given request.
+        """
+        qs = self.model._default_manager.get_query_set()
+        
+        ordering = self.ordering or ()
+        if ordering:
+            qs = qs.order_by(*ordering)
+        
+        # print request.META
+        # 
+        # if request.META.has_key('HTTP_IF_MODIFIED_SINCE'):
+        #     lm = datetime.datetime.strptime(request.META['HTTP_IF_MODIFIED_SINCE'],
+        #         '%a, %d %b %Y %X')
+        #     qs = qs.filter(modified__gt=lm)
+            
+        return qs
+
+
+        
+    def log_addition(self, request, object):
+        """
+        Log that an object has been added.
+        """
+        LogEntry.objects.log_action(
+            user_id         = _get_user_pk(request),
+            content_type_id = ContentType.objects.get_for_model(object).pk,
+            object_id       = object.pk,
+            object_repr     = force_unicode(object),
+            action_flag     = ADDITION
+        )
+    
+    def log_change(self, request, object, message):
+        """
+        Log that an object has been changed.
+        """
+        LogEntry.objects.log_action(
+            user_id         = _get_user_pk(request),
+            content_type_id = ContentType.objects.get_for_model(object).pk,
+            object_id       = object.pk,
+            object_repr     = force_unicode(object),
+            action_flag     = CHANGE,
+            change_message  = message
+        )
+    
+    def log_deletion(self, request, object, object_repr):
+        """
+        Log that an object has been deleted.
+        """
+        LogEntry.objects.log_action(
+            user_id         = _get_user_pk(request),
+            content_type_id = ContentType.objects.get_for_model(self.model).pk,
+            object_id       = object.pk,
+            object_repr     = object_repr,
+            action_flag     = DELETION
+        )
+            
+    def index(self, request):
+        return self.queryset(request)
+        
+    def read(self, request, pk):
+        qs = self.model._default_manager.filter(pk=pk)
+        if self.has_view_permission(request, qs.get()):
+            return qs
+        raise http.Forbidden()
+    
+    def create(self, request):
+        """
+        We should be able to handle a single object, or a list of objects.
+        
+        If you want it to only succeed if they all succeed, then we need to
+        have Transactions enabled in the MIDDLEWARE_CLASSES.
+        """
+        if not self.has_add_permission(request):
+            raise http.Forbidden()
+        
+        # Ensure that we have a list of objects, in the case we only recvd
+        # a single object.
+        data = request.data
+        if getattr(data, 'keys', False):
+            data = [data]
+        
+        created = []
+        for incoming in data:            
+            # We now need to ensure that any foreign key fields are objects,
+            # not the keys.
+            for f in self.model._meta.fields:
+                if getattr(f, 'related', False):
+                    if f.name in incoming.keys():
+                        incoming[f.name] = getattr(f, 'related').parent_model.objects.get(pk=incoming[f.name])
+            instance = self.model(**incoming)
+            try:
+                instance.save()
+                created.append(instance.pk)
+            except IntegrityError:
+                raise http.Conflict
+        objects = self.model.objects.filter(pk__in=created)
+        for obj in objects:
+            self.log_addition(request, obj)
+        return http.Created(objects)
+
+    
+    def update(self, request, pk):
+        # By getting a queryset, we can update later, and it will be nicer.
+        qs = self.model._default_manager.filter(pk=pk)
+        # If we didn't find any objects, hand off to create()
+        if qs.count() == 0:
+            return self.create(request)
+        
+        if not hasattr(request.data, 'keys'):
+            # Might be a list
+            if len(request.data) == 1:
+                request.data = request.data[0]
+            else:
+                raise http.BadRequest('Cannot update an object with more than one item.')
+                
+        instance = qs.get()
+        if self.has_change_permission(request, instance):
+            try:
+                message = differences(instance, request.data)
+                # TODO: find the differences.
+                qs.update(**request.data)
+                instance = qs.get() # Get again so we have fresh data
+                instance.save()     # So post_save() signal fires.
+            except IntegrityError:
+                raise http.Conflict
+            self.log_change(request, instance, message)
+            return qs
+        raise http.Forbidden()
+    
+    def delete(self, request, pk):
+        qs = self.model._default_manager.filter(pk=pk)
+        if qs.count() == 0:
+            return http.Deleted()
+        instance = qs.get()
+        if self.has_delete_permission(request, instance):
+            instance.delete()
+            self.log_deletion(request, instance, unicode(instance))
+            return qs
+        raise http.Forbidden()
+
+# Monkey-patch to add in get_view_permissions().
+from django.db.models.options import Options
+
+def get_view_permission(self):
+    if 'view_%s' % self.object_name.lower() in map(lambda x: x[0], 
+        self.permissions):
+        return 'view_%s' % self.object_name.lower()
+    return None
+
+Options.get_view_permission = get_view_permission
+
+def _get_user_pk(request):
+    return getattr(request, 'real_user', request.user).pk
+
+def differences(obj, incoming):
+    diffs = []
+    for key, value in incoming.iteritems():
+        field = obj._meta.get_field(key)
+        if field.value_from_object(obj) != field.to_python(value):
+            diffs.append(key)
+    if len(diffs) == 0:
+        return "No fields changed."
+    return "Changed %s." % ", ".join(diffs)

rest_api/serializers.py

+"""
+Implemented a custom serializer, as the django one didn't quite do what I
+needed it to do, and using templates wasn't that useful either.
+"""
+
+from django.core.serializers.json import simplejson, DjangoJSONEncoder
+from django.core.exceptions import FieldError
+from datetime import date, time, datetime
+from decimal import Decimal
+
+def serialize(queryset, fields=None, **kwargs):
+    """
+    Serialize an object or list or objects.
+    """
+    # We cannot just set the default fields to a list, as then it would
+    # be stored between calls.
+
+    if hasattr(queryset, 'model') and fields is None:
+        fields = [x.name for x in queryset.model._meta.fields]
+
+    if not fields:
+       fields = []
+    
+    try:
+        if hasattr(queryset, 'values'):
+            data = list(queryset.values(*fields))
+        else:
+            data = queryset
+    except FieldError:
+        # We must have some attributes that are not in the query.
+        data = []
+        for obj in queryset:
+            this = {}
+            for field in fields:
+                temp = getattr(obj, field, None)
+                if hasattr(temp, 'pk'):
+                    temp = temp.pk
+                if hasattr(temp, 'all'):
+                    temp = list(temp.values_list('pk', flat=True))
+                this[field] = temp
+            data.append(this)
+    return Encoder(**kwargs).encode(data)
+
+
+class Encoder(DjangoJSONEncoder):
+    pass
+
+        
+def deserialize(stream):
+    # We should be able to just use the straight loads, as django ORM will
+    # handle datetime as a string, and Decimal as a float.
+    return simplejson.loads(stream)

rest_api/sites.py

+from rest_api import http
+from rest_api.options import ModelApi
+
+from django import template
+from django.db.models.base import ModelBase
+from django.utils.functional import update_wrapper
+from django.views.decorators.cache import never_cache
+from django.conf import settings
+from django.utils.translation import ugettext as _
+from django.utils.text import capfirst
+from django.utils.safestring import mark_safe
+from django.core.serializers.json import simplejson
+from django.core.exceptions import ObjectDoesNotExist
+from django.conf.urls.defaults import patterns, url, include
+
+import operator
+
+from auth import http_auth
+
+class AlreadyRegistered(Exception):
+    pass
+
+class NotRegistered(Exception):
+    pass
+
+class ApiSite(object):
+    """
+    An ApiSite object encapsulates an instance of this api application,
+    ready to be hooked into your URLconf. Models are registered with the
+    ApiSite using the register() method, and the root() method can then be
+    used as a Django view function that presents a full API for the
+    collection of registered models.
+    """
+    
+    try:
+        http_realm = settings.HTTP_AUTH_REALM
+    except AttributeError:
+        http_realm = "Unknown Realm"
+    
+    require_auth = True
+    # Allow for situations where the app_label is not used.
+    use_app_label = True
+    
+    try:
+        index_provides_data = settings.API_INDEX_PROVIDES_DATA
+    except AttributeError:
+        index_provides_data = False
+    
+    def __init__(self, name=None, app_name='api'):
+        self._registry = {} # model_class class -> api_class instance
+        self._registry_extra = [] # api_class, ...
+        self.root_path = None
+        if name is None:
+            self.name = 'api'
+        else:
+            self.name = name
+        
+        self.app_name = app_name
+    
+    def register(self, model_or_iterable, api_class=None, **options):
+        """
+        Registers the given model(s) with the given api class.
+        
+        The model(s) should be Model classes, not instances.
+        
+        If an api class is not given, it will use ModelApi (the default api
+        options). If keyword arguments are given, they will be used as
+        options to the api class.
+        
+        If a model is already registered, this will raise AlreadyRegistered.
+        """
+        
+        if not api_class:
+            api_class = ModelApi
+        
+        if api_class and settings.DEBUG:
+            from rest_api.validation import validate
+        else:
+            validate = lambda model, api_class: None
+        
+        if isinstance(model_or_iterable, ModelBase):
+            model_or_iterable = [model_or_iterable]
+        
+        for model in model_or_iterable:
+            if model in self._registry:
+                raise AlreadyRegistered('The model %s is already registered' % model.__name__)
+            
+            if options:
+                options['__module__'] = __name__
+                api_class = type("%sApi" % model.__name, (api_class,), options)
+            
+            validate(api_class, model)
+            
+            self._registry[model] = api_class(model, self)
+    
+    def register_extra(self, api_class, **options):
+        """
+        Register a non-model-based api.
+        """
+        for api in self._registry_extra:
+            if api.__class__ == api_class:
+                raise AlreadyRegistered('The class %s is already registered' % api_class.__name__)
+        for model, api in self._registry.iteritems():
+            if api.__class__ == api_class:
+                raise AlreadyRegistered('The class %s is registered with the model %s' % (api_class.__name__, model.__name__))
+        self._registry_extra.append(api_class(self))
+        
+    def unregister(self, model_or_iterable):
+        """
+        Unregisters the given model(s).
+        
+        If a model isn't already registered, this will raise NotRegistered.
+        """
+        
+        if isinstance(model_or_iterable, ModelBase):
+            model_or_iterable = [model_or_iterable]
+        
+        for model in model_or_iterable:
+            if model not in self._registry:
+                raise NotRegistered('The model %s is not registered' % model.__name__)
+    
+    def has_permission(self, request):
+        """
+        Returns True if the given HttpRequest has permission to access
+        *at least one* object from the api site.
+        """
+        # Check to see if we require authentication.
+        return request.user.is_authenticated() and request.user.is_active
+    
+    def check_dependencies(self):
+        """
+        Check that all things needed to run the api have been correctly
+        installed.
+        
+        
+        """
+        pass
+    
+    def api_view(self, view, cacheable=False, fields=None):
+        """
+        Decorator to create an api_view attached to this ``ApiSite``.
+        This wraps the view and provides permission checking by calling
+        ``self.has_permission``.
+        
+        You'll want to use this from within ``ApiSite.get_urls()``:
+        
+            class MyApiSite(ApiSite):
+                def get_urls(self):
+                    from django.conf.urls.defaults import patterns, url
+                    
+                    urls = super(MyApiSite, self).get_urls()
+                    urls += patterns('',
+                        url(r'^my_view/$', self.api_view(some_view)),
+                    )
+                    return urls
+        
+        By default, api_views are marked as non-cacheable, using the
+        ``never_cache`` decorator.  If the view can be safely cached, set
+        cacheable=True.
+        
+        """
+        
+        def inner(request, *args, **kwargs):
+            try:
+                # See if the user requesting has permission to access this
+                # resource. We raise the exception so we can handle it in
+                # the one place, along with any http.Unauthorized that may
+                # have been raised by the actual view call.
+                if not self.has_permission(request):
+                    raise http.Unauthorized()
+                # See if the object can be deserialized.
+                if request.raw_post_data:
+                    request.data = simplejson.loads(request.raw_post_data)
+                # We now call the actual view. Because it may or may not
+                # return an HttpResponse, we can wrap the response in one
+                # if it returns something else (probably a queryset).
+                response = view(request, *args, **kwargs)
+                if isinstance(response, http.BaseHttpResponse):
+                    return response
+                else:
+                    return http.OK(response, fields=fields)
+            except http.Unauthorized, response:
+                # Ensure we have the WWW-Authenticate header.
+                response['WWW-Authenticate'] = "Basic realm=%s" % self.http_realm
+                return response
+            except http.BaseHttpResponse, response:
+                # If we raise another subclass of http.BaseHttpResponse,
+                # other than Unauthorized, then we need to just return the
+                # thing that was raised. This means we can raise a redirect
+                # at any call depth, and know it will actually redirect.
+                return response
+            except ObjectDoesNotExist, data:
+                return http.NotFound(data.args[0])
+                
+        # This is based on the contrib.admin code.
+        if not cacheable:
+            inner = never_cache(inner)
+        # Check for http authentication if this view, if applicable.   
+        if self.require_auth and 'rest_api.middleware.HttpAuthMiddleware' not in settings.MIDDLEWARE_CLASSES:
+            inner = http_auth(inner)
+        return update_wrapper(inner, view)
+    
+    def get_urls(self):
+        def wrap(view, cacheable=False):
+            def wrapper(*args, **kwargs):
+                return self.api_view(view, cacheable)(*args, **kwargs)
+            return update_wrapper(wrapper, view)
+        
+        # Api-site-wide views.
+        urlpatterns = patterns('',
+            url(r'^$', wrap(self.index), name='index'),
+            )
+    
+        # add in each model's views
+        for model, model_api in self._registry.iteritems():
+            urlpatterns += patterns('',
+                url(r'^%s/' % model_api.root_path,
+                    include(model_api.urls))
+            )
+        
+        for api_class in self._registry_extra:
+            urlpatterns += patterns('',
+                url(r'^%s/' % api_class.root_path,
+                    include(api_class.urls))
+            )
+        return urlpatterns
+    
+    def urls(self):
+        return self.get_urls(), self.app_name, self.name
+    urls = property(urls)
+    
+    ## The views used by the base site.
+
+    def index(self, request):
+        user = request.user
+        model_list = []
+        for model, model_api in self._registry.items():
+            if user.has_module_perms(model._meta.app_label):
+                perms = model_api.get_model_perms(request)
+                
+                if True in perms.values():
+                    model_list.append({
+                        'name':unicode(capfirst(model._meta.verbose_name_plural)),
+                        'href': '%s/' % model_api.root_path,
+                        'perms': perms,
+                    })
+        model_list.sort(key=operator.itemgetter('name'))
+        return http.OK(model_list)
+        
+site = ApiSite()

rest_api/templates/api/index.json

+[
+  "object-type":"api:roster:Collection",
+  "start-index":{{ start }},
+  "max-results": {{ max_results }},
+  "total-results": {{ objects.count }},
+  "links": [
+    {
+      "object-type":"api:roster:Link",
+      "allowed-methods": {{ model_api.allowed_methods_index }},
+      "href": "{{ model_api.href }}",
+      "type": "application/json",
+      "rel": "self"
+    }
+  ],
+  "entries": [
+    {% for object in objects %}
+      {
+        "object-type":"api:class_name:Link",
+        "allowed-methods": {{ model_api.allowed_methods }},
+        "href": "{{ object.href }}",
+        "type": "application/json",
+        "rel": "self"
+      }{% if forloop.last %}{% else %},{% endif %}
+    {% endfor %}
+    ]
+]

rest_api/tests.py

Empty file added.

rest_api/validation.py

+def validate(cls, model):
+    pass
+from setuptools import setup
+
+setup(
+    name = "rest_api",
+    version = "0.1dev",
+    description = "RESTful interface for Django sites, a la django.contrib.admin",
+    url = "http://bitbucket.org/schinckel/django-rest-api/",
+    author = "Matthew Schinckel",
+    author_email = "matt@schinckel.net",
+    packages = [
+        "rest_api",
+    ],
+    setup_requires = [
+        "setuptools_hg"
+    ],
+)

tests/test_rest_api/__init__.py

Empty file added.

tests/test_rest_api/manage.py

+#!/usr/bin/env python
+from django.core.management import execute_manager
+try:
+    import settings # Assumed to be in the same directory.
+except ImportError:
+    import sys
+    sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+    sys.exit(1)
+
+if __name__ == "__main__":
+    execute_manager(settings)

tests/test_rest_api/settings.py

+# Django settings for test_django_api project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+    # ('Your Name', 'your_email@domain.com'),
+)
+
+MANAGERS = ADMINS
+
+DATABASE_ENGINE = 'sqlite3'           # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+DATABASE_NAME = 'test_rest_api.db'             # Or path to database file if using sqlite3.
+DATABASE_USER = ''             # Not used with sqlite3.
+DATABASE_PASSWORD = ''         # Not used with sqlite3.
+DATABASE_HOST = ''             # Set to empty string for localhost. Not used with sqlite3.
+DATABASE_PORT = ''             # Set to empty string for default. Not used with sqlite3.
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Australia/Adelaide'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-au'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = 'http://localhost/admin-media/'
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/media/'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = '94p_%r2ej4m%^4e3dw8o^rrbydji9%i(gzsf0w2_2g#gu=i7wt'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.load_template_source',
+    'django.template.loaders.app_directories.load_template_source',
+    'django.template.loaders.eggs.load_template_source',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    # 'django_api.HttpAuthMiddleware',
+)
+
+ROOT_URLCONF = 'test_rest_api.urls'
+
+TEMPLATE_DIRS = (
+    # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+)
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.admin',
+    
+    'simple',
+    
+)
+
+API_REQUIRE_AUTH = True
+HTTP_AUTH_REALM = "Test System"

tests/test_rest_api/simple/__init__.py

Empty file added.

tests/test_rest_api/simple/api.py

+import rest_api
+import models
+
+
+rest_api.site.register(models.Simple)

tests/test_rest_api/simple/models.py

+from django.db import models
+
+class Simple(models.Model):
+    name = models.CharField(max_length=16)
+    

tests/test_rest_api/simple/tests.py

+
+from django.test import TestCase
+from django.test.client import Client
+
+class SimpleTest(TestCase):
+    def test_api_enabled(self):
+        c = Client()
+        
+        resp = c.get('/api/')
+        
+        self.assertEqual(401, resp.status_code)
+        
+    
+    def test_default_settings(self):
+        c = Client()
+        resp = c.get('/api/simple/')
+        
+        self.assertEqual(401, resp.status_code)
+        

tests/test_rest_api/simple/views.py

+# Create your views here.

tests/test_rest_api/urls.py

+from django.conf.urls.defaults import *
+
+# Uncomment the next two lines to enable the admin:
+from django.contrib import admin
+admin.autodiscover()
+
+import rest_api
+rest_api.autodiscover()
+
+urlpatterns = patterns('',
+    # Example:
+    # (r'^test_django_api/', include('test_django_api.foo.urls')),
+
+    # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 
+    # to INSTALLED_APPS to enable admin documentation:
+    # (r'^admin/doc/', include('django.contrib.admindocs.urls')),
+
+    # Uncomment the next line to enable the admin:
+    (r'^admin/', include(admin.site.urls)),
+    
+    (r'^api/', include(rest_api.site.urls)),
+)
+
+urlpatterns += patterns('django.contrib.auth',
+    (r'^accounts/login/', 'views.login', {'template_name':'admin/login.html'}),
+)