diff --git a/djadmin2/apiviews.py b/djadmin2/apiviews.py new file mode 100644 index 0000000..d1d09d1 --- /dev/null +++ b/djadmin2/apiviews.py @@ -0,0 +1,85 @@ +from django.utils.encoding import force_str +from rest_framework import fields, generics, serializers +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.views import APIView +from .views import Admin2Mixin + +API_VERSION = '0.1' + + +class Admin2APISerializer(serializers.HyperlinkedModelSerializer): + _default_view_name = 'admin2:%(app_label)s_%(model_name)s_api-detail' + + pk = fields.Field(source='pk') + __str__ = fields.Field(source='__unicode__') + + +class Admin2APIMixin(Admin2Mixin): + def get_serializer_class(self): + if self.serializer_class is None: + model_class = self.get_model() + + class ModelAPISerilizer(Admin2APISerializer): + # we need to reset this here, since we don't know anything + # about the name of the admin instance when declaring the + # Admin2APISerializer base class + _default_view_name = ':'.join(( + self.modeladmin.admin.name, + '%(app_label)s_%(model_name)s_api-detail')) + + class Meta: + model = model_class + + return ModelAPISerilizer + return super(Admin2APIMixin, self).get_serializer_class() + + +class IndexAPIView(Admin2APIMixin, APIView): + apps = None + registry = None + + def get_model_data(self, model): + modeladmin = self.registry[model] + opts = { + 'current_app': modeladmin.admin.name, + 'app_label': model._meta.app_label, + 'model_name': model._meta.object_name.lower(), + } + model_url = reverse( + '%(current_app)s:%(app_label)s_%(model_name)s_api-list' % opts, + request=self.request, + format=self.kwargs.get('format')) + return { + 'url': model_url, + 'verbose_name': force_str(model._meta.verbose_name), + 'verbose_name_plural': force_str(model._meta.verbose_name_plural), + } + + def get_app_data(self, app_label, models): + model_data = [] + for model in models: + model_data.append(self.get_model_data(model)) + return { + 'app_label': app_label, + 'models': model_data, + } + + def get(self, request): + app_data = [] + for app_label, registry in self.apps.items(): + models = registry.keys() + app_data.append(self.get_app_data(app_label, models)) + index_data = { + 'version': API_VERSION, + 'apps': app_data, + } + return Response(index_data) + + +class ListCreateAPIView(Admin2APIMixin, generics.ListCreateAPIView): + pass + + +class RetrieveUpdateDestroyAPIView(Admin2APIMixin, generics.RetrieveUpdateDestroyAPIView): + pass diff --git a/djadmin2/core.py b/djadmin2/core.py index 3109ce5..91c7b01 100644 --- a/djadmin2/core.py +++ b/djadmin2/core.py @@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.importlib import import_module +from . import apiviews from . import models from . import views @@ -17,6 +18,7 @@ class Admin2(object): It also provides an index view that serves as an entry point to the admin site. """ index_view = views.IndexView + api_index_view = apiviews.IndexAPIView def __init__(self, name='admin2'): self.registry = {} @@ -41,7 +43,7 @@ class Admin2(object): raise ImproperlyConfigured('%s is already registered in django-admin2' % model) if not modeladmin: modeladmin = models.ModelAdmin2 - self.registry[model] = modeladmin(model, **kwargs) + self.registry[model] = modeladmin(model, admin=self, **kwargs) # Add the model to the apps registry app_label = model._meta.app_label @@ -77,6 +79,7 @@ class Admin2(object): Autodiscovers all admin2.py modules for apps in INSTALLED_APPS by trying to import them. """ + apps = [] for app_name in [x for x in settings.INSTALLED_APPS]: try: import_module("%s.admin2" % app_name) @@ -91,20 +94,32 @@ class Admin2(object): 'apps': self.apps, } + def get_api_index_kwargs(self): + return { + 'registry': self.registry, + 'apps': self.apps, + } + def get_urls(self): urlpatterns = patterns('', url(r'^$', self.index_view.as_view(**self.get_index_kwargs()), name='dashboard'), + url(r'^api/v0/$', + self.api_index_view.as_view(**self.get_api_index_kwargs()), name='api-index'), ) for model, modeladmin in self.registry.iteritems(): - app_label = model._meta.app_label - model_name = model._meta.object_name.lower() - urlpatterns += patterns('', - url('^{}/{}/'.format(app_label, model_name), + url('^{}/{}/'.format( + model._meta.app_label, + model._meta.object_name.lower()), include(modeladmin.urls)), + url('^api/v0/{}/{}/'.format( + model._meta.app_label, + model._meta.object_name.lower()), + include(modeladmin.api_urls)), ) return urlpatterns @property def urls(self): + # We set the application and instance namespace here return self.get_urls(), self.name, self.name diff --git a/djadmin2/models.py b/djadmin2/models.py index 60787d5..a0b40a5 100644 --- a/djadmin2/models.py +++ b/djadmin2/models.py @@ -5,12 +5,12 @@ synonymous with the django.contrib.admin.sites model. """ - from django.core.urlresolvers import reverse from django.conf.urls import patterns, url from django.contrib.auth import models as auth_app from django.db.models import get_models, signals +from djadmin2 import apiviews from djadmin2 import views try: @@ -48,10 +48,10 @@ class BaseAdmin2(object): readonly_fields = () ordering = None - def __init__(self, model): + def __init__(self, model, admin): super(BaseAdmin2, self).__init__() - self.model = model + self.admin = admin def _user_has_permission(self, user, permission_type, obj=None): """ Generic method for checking whether the user has permission of specified type for the model. @@ -108,8 +108,13 @@ class ModelAdmin2(BaseAdmin2): detail_view = views.ModelDetailView delete_view = views.ModelDeleteView - def __init__(self, model, **kwargs): + # API Views + api_list_view = apiviews.ListCreateAPIView + api_detail_view = apiviews.RetrieveUpdateDestroyAPIView + + def __init__(self, model, admin, **kwargs): self.model = model + self.admin = admin self.app_label = model._meta.app_label self.model_name = model._meta.object_name.lower() @@ -152,9 +157,25 @@ class ModelAdmin2(BaseAdmin2): def get_delete_kwargs(self): return self.get_default_view_kwargs() + def get_api_index_kwargs(self): + return self.get_default_view_kwargs() + + def get_api_detail_kwargs(self): + return self.get_default_view_kwargs() + def get_index_url(self): return reverse('admin2:{}'.format(self.get_prefixed_view_name('index'))) + def get_api_list_kwargs(self): + kwargs = self.get_default_view_kwargs() + kwargs.update({ + 'paginate_by': self.list_per_page, + }) + return kwargs + + def get_api_detail_kwargs(self): + return self.get_default_view_kwargs() + def get_urls(self): return patterns('', url( @@ -184,11 +205,29 @@ class ModelAdmin2(BaseAdmin2): ), ) + def get_api_urls(self): + return patterns('', + url( + regex=r'^$', + view=self.api_list_view.as_view(**self.get_api_list_kwargs()), + name=self.get_prefixed_view_name('api-list'), + ), + url( + regex=r'^(?P[0-9]+)/$', + view=self.api_detail_view.as_view(**self.get_api_detail_kwargs()), + name=self.get_prefixed_view_name('api-detail'), + ), + ) + @property def urls(self): # We set the application and instance namespace here return self.get_urls(), None, None + @property + def api_urls(self): + return self.get_api_urls(), None, None + def create_extra_permissions(app, created_models, verbosity, **kwargs): """ Creates 'view' permissions for all models. diff --git a/djadmin2/templates/admin2/bootstrap/base.html b/djadmin2/templates/admin2/bootstrap/base.html index 546f26f..df3dc27 100644 --- a/djadmin2/templates/admin2/bootstrap/base.html +++ b/djadmin2/templates/admin2/bootstrap/base.html @@ -16,6 +16,7 @@ Django-Admin2 @@ -31,4 +32,4 @@ {% block extrajs %}{% endblock %} - \ No newline at end of file + diff --git a/djadmin2/tests/test_core.py b/djadmin2/tests/test_core.py index bd9397b..15d0881 100644 --- a/djadmin2/tests/test_core.py +++ b/djadmin2/tests/test_core.py @@ -1,7 +1,6 @@ -import unittest - from django.db import models from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase from ..models import ModelAdmin2 from ..core import Admin2 @@ -11,7 +10,7 @@ class Thing(models.Model): pass -class Admin2Test(unittest.TestCase): +class Admin2Test(TestCase): def setUp(self): self.admin2 = Admin2() @@ -33,4 +32,4 @@ class Admin2Test(unittest.TestCase): def test_get_urls(self): self.admin2.register(Thing) - self.assertEquals(2, len(self.admin2.get_urls())) + self.assertEquals(4, len(self.admin2.get_urls())) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..78ae92c --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,16 @@ +API +=== + +**django-admin2** comes with a builtin REST-API for accessing all the +resources you can get from the frontend via JSON. + +The API can be found at the URL you choose for the admin2 and then append +``api/v0/``. + +If the API has changed in a backwards-incompatible way we will increase the +API version to the next number. So you can be sure that you're frontend code +should keep working even between updates to more recent django-admin2 +versions. + +However currently we are still in heavy development, so we are using ``v0`` +for the API, which means is subject to change and being broken at any time. diff --git a/docs/index.rst b/docs/index.rst index 503c556..c5e2db7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,7 @@ Content :maxdepth: 2 contributing + api design meta @@ -71,4 +72,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/example/blog/admin2.py b/example/blog/admin2.py index e2cbad3..8367587 100644 --- a/example/blog/admin2.py +++ b/example/blog/admin2.py @@ -2,7 +2,7 @@ # Import your custom models from .models import Post, Comment from django.contrib.auth.forms import UserCreationForm, UserChangeForm -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, Permission, User import djadmin2 @@ -18,3 +18,5 @@ class UserAdmin2(ModelAdmin2): djadmin2.default.register(Post) djadmin2.default.register(Comment) djadmin2.default.register(User, UserAdmin2) +djadmin2.default.register(Permission) +djadmin2.default.register(Group) diff --git a/example/blog/tests/__init__.py b/example/blog/tests/__init__.py new file mode 100644 index 0000000..9ccb8bb --- /dev/null +++ b/example/blog/tests/__init__.py @@ -0,0 +1,2 @@ +from test_views import * +from test_apiviews import * diff --git a/example/blog/tests/test_apiviews.py b/example/blog/tests/test_apiviews.py new file mode 100644 index 0000000..8893ba5 --- /dev/null +++ b/example/blog/tests/test_apiviews.py @@ -0,0 +1,79 @@ +from django.test import TestCase +from django.test.client import RequestFactory +from django.core.urlresolvers import reverse +from django.utils import simplejson as json + + +from djadmin2 import apiviews +from djadmin2 import default +from djadmin2.models import ModelAdmin2 +from ..models import Post + + +class ViewTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def get_model_admin(self, model): + return ModelAdmin2(model, default) + + +class IndexAPIViewTest(ViewTest): + + def test_response_ok(self): + request = self.factory.get(reverse('admin2:api-index')) + view = apiviews.IndexAPIView.as_view(**default.get_api_index_kwargs()) + response = view(request) + self.assertEqual(response.status_code, 200) + + +class ListCreateAPIViewTest(ViewTest): + + def test_response_ok(self): + request = self.factory.get(reverse('admin2:blog_post_api-list')) + modeladmin = self.get_model_admin(Post) + view = apiviews.ListCreateAPIView.as_view( + **modeladmin.get_api_list_kwargs()) + response = view(request) + self.assertEqual(response.status_code, 200) + + def test_list_includes_unicode_field(self): + Post.objects.create(title='Foo', body='Bar') + request = self.factory.get(reverse('admin2:blog_post_api-list')) + modeladmin = self.get_model_admin(Post) + view = apiviews.ListCreateAPIView.as_view( + **modeladmin.get_api_list_kwargs()) + response = view(request) + response.render() + + self.assertIn('"__str__": "Foo"', response.content) + + def test_pagination(self): + request = self.factory.get(reverse('admin2:blog_post_api-list')) + modeladmin = self.get_model_admin(Post) + view = apiviews.ListCreateAPIView.as_view( + **modeladmin.get_api_list_kwargs()) + response = view(request) + response.render() + data = json.loads(response.content) + self.assertEqual(data['count'], 0) + # next and previous fields exist, but are null because we have no + # content + self.assertTrue('next' in data) + self.assertEqual(data['next'], None) + self.assertTrue('previous' in data) + self.assertEqual(data['previous'], None) + + +class RetrieveUpdateDestroyAPIViewTest(ViewTest): + + def test_response_ok(self): + post = Post.objects.create(title='Foo', body='Bar') + request = self.factory.get( + reverse('admin2:blog_post_api-detail', + kwargs={'pk': post.pk})) + modeladmin = self.get_model_admin(Post) + view = apiviews.RetrieveUpdateDestroyAPIView.as_view( + **modeladmin.get_api_detail_kwargs()) + response = view(request, pk=post.pk) + self.assertEqual(response.status_code, 200) diff --git a/example/blog/tests.py b/example/blog/tests/test_views.py similarity index 99% rename from example/blog/tests.py rename to example/blog/tests/test_views.py index e2d7a14..7c8e20e 100644 --- a/example/blog/tests.py +++ b/example/blog/tests/test_views.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.test import TestCase, Client -from .models import Post +from ..models import Post class BaseIntegrationTest(TestCase): diff --git a/example/example/settings.py b/example/example/settings.py index 6de83ad..d2ffcb4 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -121,6 +121,7 @@ INSTALLED_APPS = ( # Uncomment the next line to enable admin documentation: # 'django.contrib.admindocs', 'django_coverage', + 'rest_framework', 'djadmin2', 'blog', )