diff --git a/.travis.yml b/.travis.yml index 27d50bf..de5b777 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "2.6" - "2.7" before_install: - export PIP_USE_MIRRORS=true diff --git a/README.rst b/README.rst index 39e7556..742666c 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,14 @@ Contributing Yes please! Please read our formal contributing document at: https://github.com/pydanny/django-admin2/blob/master/docs/contributing.rst +Requirements +============= + +* Django 1.5+ +* Python 2.7+ (Python 3.3+ support is pending) +* django-braces +* django-rest-framework +* Sphinx (for documentation) Basic Pattern ============== @@ -23,20 +31,25 @@ Our goal is to make this API work: .. code-block:: python - # myapp/admin2.py + # Import your custom models + from .models import Post, Comment + from django.contrib.auth.forms import UserCreationForm, UserChangeForm + from django.contrib.auth.models import User - # Import the Admin2 base class - from admin2.models import Admin2 + import djadmin2 + from djadmin2.models import ModelAdmin2 - # Import your custom models - from blog.models import Post - # Instantiate the Admin2 class - # Then attach the admin2 object to your model - Post.admin2 = Admin2() + class UserAdmin2(ModelAdmin2): + create_form_class = UserCreationForm + update_form_class = UserChangeForm + + + # Register each model with the admin + djadmin2.default.register(Post) + djadmin2.default.register(Comment) + djadmin2.default.register(User, UserAdmin2) - -.. note:: You will notice a difference between how and django.contrib.admin and django-admin2 do configuration. The former associates the configuration class with the model object via a registration utility, and the latter does so by adding the configuration class as an attribute of the model object. Themes ======== diff --git a/djadmin2/__init__.py b/djadmin2/__init__.py index 5b32917..c6030e9 100644 --- a/djadmin2/__init__.py +++ b/djadmin2/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.1.1' +__version__ = '0.2.0' __author__ = 'Daniel Greenfeld' @@ -7,7 +7,7 @@ VERSION = __version__ # synonym # Default datetime input and output formats ISO_8601 = 'iso-8601' -from . import core +from . import core default = core.Admin2() diff --git a/djadmin2/core.py b/djadmin2/core.py index 58e2050..e9a7e49 100644 --- a/djadmin2/core.py +++ b/djadmin2/core.py @@ -9,14 +9,29 @@ from . import views class Admin2(object): + """ + The base Admin2 object. + It keeps a registry of all registered Models and collects the urls of their + related ModelAdmin2 instances. + + It also provides an index view that serves as an entry point to the admin site. + """ index_view = views.IndexView - def __init__(self, name='admin2', app_name='admin2'): + def __init__(self, name='admin2'): self.registry = {} self.name = name - self.app_name = app_name def register(self, model, modeladmin=None, **kwargs): + """ + Registers the given model with the given admin class. + + If no modeladmin is passed, it will use ModelAdmin2. If keyword + arguments are given they will be passed to the admin class on + instantiation. + + If a model is already registered, this will raise ImproperlyConfigured. + """ if model in self.registry: raise ImproperlyConfigured if not modeladmin: @@ -24,13 +39,21 @@ class Admin2(object): self.registry[model] = modeladmin(model, **kwargs) def deregister(self, model): + """ + Deregisters the given model. + + If the model is not already registered, this will raise ImproperlyConfigured. + """ try: del self.registry[model] except KeyError: raise ImproperlyConfigured def autodiscover(self): - apps = [] + """ + Autodiscovers all admin2.py modules for apps in INSTALLED_APPS by + trying to import them. + """ for app_name in [x for x in settings.INSTALLED_APPS]: try: import_module("%s.admin2" % app_name) @@ -46,11 +69,11 @@ class Admin2(object): def get_urls(self): urlpatterns = patterns('', - url(r'^$', self.index_view.as_view(**self.get_index_kwargs()), name='index'), + url(r'^$', self.index_view.as_view(**self.get_index_kwargs()), name='dashboard'), ) for model, modeladmin in self.registry.iteritems(): app_label = model._meta.app_label - model_name = model._meta.object_name.lower() + model_name = model._meta.object_name.lower() urlpatterns += patterns('', url('^{}/{}/'.format(app_label, model_name), @@ -60,4 +83,4 @@ class Admin2(object): @property def urls(self): - return self.get_urls(), self.app_name, self.name + return self.get_urls(), self.name, self.name diff --git a/djadmin2/models.py b/djadmin2/models.py index e844dca..60787d5 100644 --- a/djadmin2/models.py +++ b/djadmin2/models.py @@ -4,7 +4,10 @@ For wont of a better name, this module is called 'models'. It's role is synonymous with the django.contrib.admin.sites model. """ -from django.conf.urls import patterns, include, url + + +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 @@ -16,7 +19,6 @@ except ImportError: from django import forms - class BaseAdmin2(object): search_fields = [] @@ -46,13 +48,11 @@ class BaseAdmin2(object): readonly_fields = () ordering = None - def __init__(self, model): super(BaseAdmin2, self).__init__() self.model = model - 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. Type can be one of view, add, change, delete. @@ -110,6 +110,8 @@ class ModelAdmin2(BaseAdmin2): def __init__(self, model, **kwargs): self.model = model + self.app_label = model._meta.app_label + self.model_name = model._meta.object_name.lower() if self.verbose_name is None: self.verbose_name = self.model._meta.verbose_name @@ -118,10 +120,15 @@ class ModelAdmin2(BaseAdmin2): def get_default_view_kwargs(self): return { + 'app_label': self.app_label, 'model': self.model, + 'model_name': self.model_name, 'modeladmin': self, } + def get_prefixed_view_name(self, view_name): + return '{}_{}_{}'.format(self.app_label, self.model_name, view_name) + def get_index_kwargs(self): return self.get_default_view_kwargs() @@ -145,32 +152,35 @@ class ModelAdmin2(BaseAdmin2): def get_delete_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_urls(self): return patterns('', url( regex=r'^$', view=self.index_view.as_view(**self.get_index_kwargs()), - name='index' + name=self.get_prefixed_view_name('index') ), url( regex=r'^create/$', view=self.create_view.as_view(**self.get_create_kwargs()), - name='create' + name=self.get_prefixed_view_name('create') ), url( regex=r'^(?P[0-9]+)/$', view=self.detail_view.as_view(**self.get_detail_kwargs()), - name='detail' + name=self.get_prefixed_view_name('detail') ), url( regex=r'^(?P[0-9]+)/update/$', view=self.update_view.as_view(**self.get_update_kwargs()), - name='update' + name=self.get_prefixed_view_name('update') ), url( regex=r'^(?P[0-9]+)/delete/$', view=self.delete_view.as_view(**self.get_delete_kwargs()), - name='delete' + name=self.get_prefixed_view_name('delete') ), ) @@ -179,9 +189,7 @@ class ModelAdmin2(BaseAdmin2): # We set the application and instance namespace here return self.get_urls(), None, None - - -def create_permissions(app, created_models, verbosity, **kwargs): +def create_extra_permissions(app, created_models, verbosity, **kwargs): """ Creates 'view' permissions for all models. django.contrib.auth only creates add, change and delete permissions. Since we also support read-only views, we need @@ -190,9 +198,6 @@ def create_permissions(app, created_models, verbosity, **kwargs): """ from django.contrib.contenttypes.models import ContentType - def _get_permission_codename(action, opts): - return u'%s_%s' % (action, opts.object_name.lower()) - app_models = get_models(app) # This will hold the permissions we're looking for as @@ -205,10 +210,10 @@ def create_permissions(app, created_models, verbosity, **kwargs): ctypes.add(ctype) opts = klass._meta - perm = (_get_permission_codename('view', opts), u'Can view %s' % opts.verbose_name_raw) + perm = ('view_%s' % opts.object_name.lower(), u'Can view %s' % opts.verbose_name_raw) searched_perms.append((ctype, perm)) - # Find all the Permissions that have a context_type for a model we're + # Find all the Permissions that have a content_type for a model we're # looking for. We don't need to check for codenames since we already have # a list of the ones we're going to create. all_perms = set(auth_app.Permission.objects.filter( @@ -217,16 +222,16 @@ def create_permissions(app, created_models, verbosity, **kwargs): "content_type", "codename" )) - objs = [ + perms = [ auth_app.Permission(codename=codename, name=name, content_type=ctype) for ctype, (codename, name) in searched_perms if (ctype.pk, codename) not in all_perms ] - auth_app.Permission.objects.bulk_create(objs) + auth_app.Permission.objects.bulk_create(perms) if verbosity >= 2: - for obj in objs: - print "Adding permission '%s'" % obj + for perm in perms: + print "Adding permission '%s'" % perm -signals.post_syncdb.connect(create_permissions, - dispatch_uid = "django-admin2.djadmin2.models.create_permissions") +signals.post_syncdb.connect(create_extra_permissions, + dispatch_uid = "django-admin2.djadmin2.models.create_extra_permissions") diff --git a/djadmin2/templates/admin2/bootstrap/base.html b/djadmin2/templates/admin2/bootstrap/base.html index 3ed6f0c..231b9a8 100644 --- a/djadmin2/templates/admin2/bootstrap/base.html +++ b/djadmin2/templates/admin2/bootstrap/base.html @@ -1,19 +1,33 @@ - - django-admin2 - - - - - -
- {% block content %}{% endblock %} -
+ + - - - {% block extrajs %}{% endblock %} + django-admin2 + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + {% block extrajs %}{% endblock %} + + \ No newline at end of file diff --git a/djadmin2/templates/admin2/bootstrap/index.html b/djadmin2/templates/admin2/bootstrap/index.html index c9fc3ad..ac8d0e8 100644 --- a/djadmin2/templates/admin2/bootstrap/index.html +++ b/djadmin2/templates/admin2/bootstrap/index.html @@ -4,7 +4,7 @@

Index

{% for modeladmin in registry.values %} - + {% endfor %}
{{ modeladmin.verbose_name_plural }}
{{ modeladmin.verbose_name_plural }}
{% endblock content %} diff --git a/djadmin2/templates/admin2/bootstrap/model_delete_form.html b/djadmin2/templates/admin2/bootstrap/model_confirm_delete.html similarity index 100% rename from djadmin2/templates/admin2/bootstrap/model_delete_form.html rename to djadmin2/templates/admin2/bootstrap/model_confirm_delete.html diff --git a/djadmin2/templates/admin2/bootstrap/model_list.html b/djadmin2/templates/admin2/bootstrap/model_list.html index 8121584..d8280e9 100644 --- a/djadmin2/templates/admin2/bootstrap/model_list.html +++ b/djadmin2/templates/admin2/bootstrap/model_list.html @@ -1,4 +1,5 @@ {% extends "admin2/bootstrap/base.html" %} +{% load admin2_urls %} {% block content %}
@@ -6,9 +7,9 @@

Select {{ model }} to change

- {% if has_add_permission %} - Add {{ model|title }} - {% endif %} + {# if has_add_permission #} + add + {# endif #}
@@ -16,6 +17,14 @@
+ Action: + + Go + TODO of {{ object_list|length }} selected + @@ -25,17 +34,19 @@ {% for obj in object_list %} {% endfor %}
- {{ obj }} detail - {% if has_edit_permission %} - edit - {% endif %} - {% if has_delete_permission %} - delete - {% endif %} + {{ obj }} detail + {# if has_edit_permission #} + edit + {# endif #} + {# if has_delete_permission #} + delete + {# endif #}
+ + {{ object_list|length }} {{ model }}{{ object_list|pluralize }}
diff --git a/djadmin2/mixins.py b/djadmin2/templatetags/__init__.py similarity index 100% rename from djadmin2/mixins.py rename to djadmin2/templatetags/__init__.py diff --git a/djadmin2/templatetags/admin2_urls.py b/djadmin2/templatetags/admin2_urls.py new file mode 100644 index 0000000..e5e5395 --- /dev/null +++ b/djadmin2/templatetags/admin2_urls.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + + +@register.filter +def admin2_urlname(value, arg): + return 'admin2:%s_%s_%s' % (value.app_label, value.model_name, arg) diff --git a/djadmin2/tests/test_core.py b/djadmin2/tests/test_core.py index be17459..bd9397b 100644 --- a/djadmin2/tests/test_core.py +++ b/djadmin2/tests/test_core.py @@ -6,9 +6,11 @@ from django.core.exceptions import ImproperlyConfigured from ..models import ModelAdmin2 from ..core import Admin2 + class Thing(models.Model): pass + class Admin2Test(unittest.TestCase): def setUp(self): self.admin2 = Admin2() @@ -28,7 +30,7 @@ class Admin2Test(unittest.TestCase): def test_deregister_error(self): self.assertRaises(ImproperlyConfigured, self.admin2.deregister, Thing) - + def test_get_urls(self): self.admin2.register(Thing) self.assertEquals(2, len(self.admin2.get_urls())) diff --git a/djadmin2/views.py b/djadmin2/views.py index d0250ad..6d95db6 100644 --- a/djadmin2/views.py +++ b/djadmin2/views.py @@ -1,22 +1,36 @@ import os +from django.core.urlresolvers import reverse from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.core.exceptions import PermissionDenied from django.forms.models import modelform_factory from django.views import generic -from django.db import models - -from braces.views import LoginRequiredMixin, StaffuserRequiredMixin, AccessMixin +from braces.views import AccessMixin ADMIN2_THEME_DIRECTORY = getattr(settings, "ADMIN2_THEME_DIRECTORY", "admin2/bootstrap") class Admin2Mixin(object): + modeladmin = None + model_name = None + app_label = None + def get_template_names(self): return [os.path.join(ADMIN2_THEME_DIRECTORY, self.default_template_name)] + def get_model(self): + return self.model + + def get_queryset(self): + return self.get_model()._default_manager.all() + + def get_form_class(self): + if self.form_class is not None: + return self.form_class + return modelform_factory(self.get_model()) + class AdminModel2Mixin(Admin2Mixin, AccessMixin): modeladmin = None @@ -70,7 +84,9 @@ class IndexView(Admin2Mixin, generic.TemplateView): }) return data -class ModelListView(AdminModel2Mixin, generic.ListView): + +class ModelListView(Admin2Mixin, generic.ListView): + default_template_name = "model_list.html" permission_type = 'view' @@ -80,6 +96,10 @@ class ModelListView(AdminModel2Mixin, generic.ListView): context['model_pluralized'] = self.get_model()._meta.verbose_name_plural return context + def get_success_url(self): + view_name = 'admin2:{}_{}_detail'.format(self.app_label, self.model_name) + return reverse(view_name, kwargs={'pk': self.object.pk}) + class ModelDetailView(AdminModel2Mixin, generic.DetailView): default_template_name = "model_detail.html" @@ -99,8 +119,12 @@ class ModelAddFormView(AdminModel2Mixin, generic.CreateView): default_template_name = "model_add_form.html" permission_type = 'add' + def get_success_url(self): + view_name = 'admin2:{}_{}_detail'.format(self.app_label, self.model_name) + return reverse(view_name, kwargs={'pk': self.object.pk}) + class ModelDeleteView(AdminModel2Mixin, generic.DeleteView): success_url = "../../" - default_template_name = "model_delete.html" + default_template_name = "model_confirm_delete.html" permission_type = 'delete' diff --git a/docs/index.rst b/docs/index.rst index f9b5cd7..503c556 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,30 @@ Welcome to django-admin2's documentation! ========================================= -**django-admin2** aims to replace django's builtin admin that lives in +**django-admin2** aims to replace django's built-in admin that lives in ``django.contrib.admin``. Come and help us, have a look at the :doc:`contributing` page and see our `GitHub`_ page. +This project is intentionally backwards-incompatible with ``django.contrib.admin``. + +Features +========== + +* Easy-to-extend API that follows similar patterns to ``django.contrib.admin``. +* Built-in RESTFUL API powered by ``django-rest-framework`` +* Default theme built on Twitter Bootstrap +* Easy to implement theme system. + +Requirements +============= + +* Django 1.5+ +* Python 2.7+ (Python 3.3+ support is pending) +* django-braces +* django-rest-framework +* Sphinx (for documentation) + + Basic API ============== @@ -44,6 +64,7 @@ Content design meta + Indices and tables ================== diff --git a/example/blog/admin2.py b/example/blog/admin2.py index 2c5c38f..e2cbad3 100644 --- a/example/blog/admin2.py +++ b/example/blog/admin2.py @@ -17,4 +17,4 @@ class UserAdmin2(ModelAdmin2): # Register each model with the admin djadmin2.default.register(Post) djadmin2.default.register(Comment) -djadmin2.default.register(User, UserAdmin2) \ No newline at end of file +djadmin2.default.register(User, UserAdmin2) diff --git a/example/blog/models.py b/example/blog/models.py index cc845d8..cf41af3 100644 --- a/example/blog/models.py +++ b/example/blog/models.py @@ -14,4 +14,4 @@ class Comment(models.Model): body = models.TextField() def __unicode__(self): - return self.body \ No newline at end of file + return self.body diff --git a/example/blog/tests.py b/example/blog/tests.py index 683f350..e2d7a14 100644 --- a/example/blog/tests.py +++ b/example/blog/tests.py @@ -1,36 +1,70 @@ -from django.utils import unittest -from django.test.client import RequestFactory +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from django.test import TestCase, Client -from djadmin2 import views +from .models import Post -class ViewTest(unittest.TestCase): + +class BaseIntegrationTest(TestCase): + """ + Base TestCase for integration tests. + """ def setUp(self): - self.factory = RequestFactory() + self.client = Client() + self.user = get_user_model()(username='user', is_staff=True, + is_superuser=True) + self.user.set_password("password") + self.user.save() + self.client.login(username='user', password='password') -class IndexViewTest(ViewTest): - def test_response_ok(self): - request = self.factory.get('/admin/blog/post/') - response = views.IndexView.as_view()(request) +class AdminIndexTest(BaseIntegrationTest): + def test_view_ok(self): + response = self.client.get(reverse("admin2:dashboard")) + self.assertContains(response, reverse("admin2:blog_post_index")) + + +class PostListTest(BaseIntegrationTest): + def test_view_ok(self): + post = Post.objects.create(title="a_post_title", body="body") + response = self.client.get(reverse("admin2:blog_post_index")) + self.assertContains(response, post.title) + + +class PostDetailViewTest(BaseIntegrationTest): + def test_view_ok(self): + post = Post.objects.create(title="a_post_title", body="body") + response = self.client.get(reverse("admin2:blog_post_detail", + args=(post.pk, ))) + self.assertContains(response, post.title) + + +class PostCreateViewTest(BaseIntegrationTest): + def test_view_ok(self): + response = self.client.get(reverse("admin2:blog_post_create")) self.assertEqual(response.status_code, 200) - -class ModelListViewTest(ViewTest): - pass + def test_create_post(self): + response = self.client.post(reverse("admin2:blog_post_create"), + {"title": "a_post_title", + "body": "a_post_body"}, + follow=True) + self.assertTrue(Post.objects.filter(title="a_post_title").exists()) + post = Post.objects.get(title="a_post_title") + self.assertRedirects(response, reverse("admin2:blog_post_detail", + args=(post.pk, ))) -class ModelDetailViewTest(ViewTest): - pass - - -class ModelEditFormViewTest(ViewTest): - pass - - -class ModelAddFormViewTest(ViewTest): - pass - - -class ModelDeleteViewTest(ViewTest): - pass +class PostDeleteViewTest(BaseIntegrationTest): + def test_view_ok(self): + post = Post.objects.create(title="a_post_title", body="body") + response = self.client.get(reverse("admin2:blog_post_delete", + args=(post.pk, ))) + self.assertContains(response, post.title) + def test_delete_post(self): + post = Post.objects.create(title="a_post_title", body="body") + response = self.client.post(reverse("admin2:blog_post_delete", + args=(post.pk, ))) + self.assertRedirects(response, reverse("admin2:blog_post_index")) + self.assertFalse(Post.objects.filter(pk=post.pk).exists())