\w+)/$',
+ wrap(self.app_index),
+ name='%sadmin_app_list' % self.name),
+ )
+
+ # Add in each model's views.
+ for model, model_admin in self._registry.iteritems():
+ urlpatterns += patterns('',
+ url(r'^%s/%s/' % (model._meta.app_label, model._meta.module_name),
+ include(model_admin.urls))
+ )
+ return urlpatterns
+
+ def urls(self):
+ return self.get_urls()
+ urls = property(urls)
+
def password_change(self, request):
"""
Handles the "change password" task -- both form display and validation.
@@ -183,18 +199,18 @@ class AdminSite(object):
from django.contrib.auth.views import password_change
return password_change(request,
post_change_redirect='%spassword_change/done/' % self.root_path)
-
+
def password_change_done(self, request):
"""
Displays the "success" page after a password change.
"""
from django.contrib.auth.views import password_change_done
return password_change_done(request)
-
+
def i18n_javascript(self, request):
"""
Displays the i18n JavaScript that the Django admin requires.
-
+
This takes into account the USE_I18N setting. If it's set to False, the
generated JavaScript will be leaner and faster.
"""
@@ -203,23 +219,23 @@ class AdminSite(object):
else:
from django.views.i18n import null_javascript_catalog as javascript_catalog
return javascript_catalog(request, packages='django.conf')
-
+
def logout(self, request):
"""
Logs out the user for the given HttpRequest.
-
+
This should *not* assume the user is already logged in.
"""
from django.contrib.auth.views import logout
return logout(request)
logout = never_cache(logout)
-
+
def login(self, request):
"""
Displays the login form for the given HttpRequest.
"""
from django.contrib.auth.models import User
-
+
# If this isn't already the login page, display it.
if not request.POST.has_key(LOGIN_FORM_KEY):
if request.POST:
@@ -227,14 +243,14 @@ class AdminSite(object):
else:
message = ""
return self.display_login_form(request, message)
-
+
# Check that the user accepts cookies.
if not request.session.test_cookie_worked():
message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
return self.display_login_form(request, message)
else:
request.session.delete_test_cookie()
-
+
# Check the password.
username = request.POST.get('username', None)
password = request.POST.get('password', None)
@@ -254,7 +270,7 @@ class AdminSite(object):
else:
message = _("Usernames cannot contain the '@' character.")
return self.display_login_form(request, message)
-
+
# The user data is correct; log in the user in and continue.
else:
if user.is_active and user.is_staff:
@@ -263,7 +279,7 @@ class AdminSite(object):
else:
return self.display_login_form(request, ERROR_MESSAGE)
login = never_cache(login)
-
+
def index(self, request, extra_context=None):
"""
Displays the main admin index page, which lists all of the installed
@@ -274,14 +290,14 @@ class AdminSite(object):
for model, model_admin in self._registry.items():
app_label = model._meta.app_label
has_module_perms = user.has_module_perms(app_label)
-
+
if has_module_perms:
perms = {
'add': model_admin.has_add_permission(request),
'change': model_admin.has_change_permission(request),
'delete': model_admin.has_delete_permission(request),
}
-
+
# Check whether user has any perm for this module.
# If so, add the module to the model_list.
if True in perms.values():
@@ -299,15 +315,15 @@ class AdminSite(object):
'has_module_perms': has_module_perms,
'models': [model_dict],
}
-
+
# Sort the apps alphabetically.
app_list = app_dict.values()
app_list.sort(lambda x, y: cmp(x['name'], y['name']))
-
+
# Sort the models alphabetically within each app.
for app in app_list:
app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
-
+
context = {
'title': _('Site administration'),
'app_list': app_list,
@@ -318,7 +334,7 @@ class AdminSite(object):
context_instance=template.RequestContext(request)
)
index = never_cache(index)
-
+
def display_login_form(self, request, error_message='', extra_context=None):
request.session.set_test_cookie()
context = {
@@ -331,7 +347,7 @@ class AdminSite(object):
return render_to_response(self.login_template or 'admin/login.html', context,
context_instance=template.RequestContext(request)
)
-
+
def app_index(self, request, app_label, extra_context=None):
user = request.user
has_module_perms = user.has_module_perms(app_label)
@@ -377,6 +393,81 @@ class AdminSite(object):
return render_to_response(self.app_index_template or 'admin/app_index.html', context,
context_instance=template.RequestContext(request)
)
+
+ def root(self, request, url):
+ """
+ DEPRECATED. This function is the old way of handling URL resolution, and
+ is deprecated in favor of real URL resolution -- see ``get_urls()``.
+
+ This function still exists for backwards-compatibility; it will be
+ removed in Django 1.3.
+ """
+ import warnings
+ warnings.warn(
+ "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
+ PendingDeprecationWarning
+ )
+
+ #
+ # Again, remember that the following only exists for
+ # backwards-compatibility. Any new URLs, changes to existing URLs, or
+ # whatever need to be done up in get_urls(), above!
+ #
+
+ if request.method == 'GET' and not request.path.endswith('/'):
+ return http.HttpResponseRedirect(request.path + '/')
+
+ if settings.DEBUG:
+ self.check_dependencies()
+
+ # Figure out the admin base URL path and stash it for later use
+ self.root_path = re.sub(re.escape(url) + '$', '', request.path)
+
+ url = url.rstrip('/') # Trim trailing slash, if it exists.
+
+ # The 'logout' view doesn't require that the person is logged in.
+ if url == 'logout':
+ return self.logout(request)
+
+ # Check permission to continue or display login form.
+ if not self.has_permission(request):
+ return self.login(request)
+
+ if url == '':
+ return self.index(request)
+ elif url == 'password_change':
+ return self.password_change(request)
+ elif url == 'password_change/done':
+ return self.password_change_done(request)
+ elif url == 'jsi18n':
+ return self.i18n_javascript(request)
+ # URLs starting with 'r/' are for the "View on site" links.
+ elif url.startswith('r/'):
+ from django.contrib.contenttypes.views import shortcut
+ return shortcut(request, *url.split('/')[1:])
+ else:
+ if '/' in url:
+ return self.model_page(request, *url.split('/', 2))
+ else:
+ return self.app_index(request, url)
+
+ raise http.Http404('The requested admin page does not exist.')
+
+ def model_page(self, request, app_label, model_name, rest_of_url=None):
+ """
+ DEPRECATED. This is the old way of handling a model view on the admin
+ site; the new views should use get_urls(), above.
+ """
+ from django.db import models
+ model = models.get_model(app_label, model_name)
+ if model is None:
+ raise http.Http404("App %r, model %r, not found." % (app_label, model_name))
+ try:
+ admin_obj = self._registry[model]
+ except KeyError:
+ raise http.Http404("This model exists but has not been registered with the admin site.")
+ return admin_obj(request, rest_of_url)
+ model_page = never_cache(model_page)
# This global object represents the default admin site, for the common case.
# You can instantiate AdminSite in your own code to create a custom admin site.
diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
index 0900b4e3d9..4164c8ad9c 100644
--- a/django/contrib/admin/util.py
+++ b/django/contrib/admin/util.py
@@ -6,7 +6,6 @@ from django.utils.text import capfirst
from django.utils.encoding import force_unicode
from django.utils.translation import ugettext as _
-
def quote(s):
"""
Ensure that primary key values do not confuse the admin URLs by escaping
diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py
index 805ca328e1..c5326b7fae 100644
--- a/django/contrib/auth/admin.py
+++ b/django/contrib/auth/admin.py
@@ -40,6 +40,12 @@ class UserAdmin(admin.ModelAdmin):
if url.endswith('password'):
return self.user_change_password(request, url.split('/')[0])
return super(UserAdmin, self).__call__(request, url)
+
+ def get_urls(self):
+ from django.conf.urls.defaults import patterns
+ return patterns('',
+ (r'^(\d+)/password/$', self.admin_site.admin_view(self.user_change_password))
+ ) + super(UserAdmin, self).get_urls()
def add_view(self, request):
# It's an error for a user to have add permission but NOT change
diff --git a/docs/intro/tutorial02.txt b/docs/intro/tutorial02.txt
index 1144167276..fb6794b46c 100644
--- a/docs/intro/tutorial02.txt
+++ b/docs/intro/tutorial02.txt
@@ -57,7 +57,7 @@ activate the admin site for your installation, do these three things:
# (r'^admin/doc/', include('django.contrib.admindocs.urls')),
# Uncomment the next line to enable the admin:
- **(r'^admin/(.*)', admin.site.root),**
+ **(r'^admin/', include(admin.site.urls)),**
)
(The bold lines are the ones that needed to be uncommented.)
diff --git a/docs/ref/contrib/admin.txt b/docs/ref/contrib/admin.txt
index f24dc46bf5..a50aa13da9 100644
--- a/docs/ref/contrib/admin.txt
+++ b/docs/ref/contrib/admin.txt
@@ -632,6 +632,49 @@ model instance::
instance.save()
formset.save_m2m()
+``get_urls(self)``
+~~~~~~~~~~~~~~~~~~~
+
+The ``get_urls`` method on a ``ModelAdmin`` returns the URLs to be used for
+that ModelAdmin in the same way as a URLconf. Therefore you can extend them as
+documented in :ref:`topics-http-urls`::
+
+ class MyModelAdmin(admin.ModelAdmin):
+ def get_urls(self):
+ urls = super(MyModelAdmin, self).get_urls()
+ my_urls = patterns('',
+ (r'^my_view/$', self.my_view)
+ )
+ return my_urls + urls
+
+.. note::
+
+ Notice that the custom patterns are included *before* the regular admin
+ URLs: the admin URL patterns are very permissive and will match nearly
+ anything, so you'll usually want to prepend your custom URLs to the built-in
+ ones.
+
+Note, however, that the ``self.my_view`` function registered above will *not*
+have any permission check done; it'll be accessible to the general public. Since
+this is usually not what you want, Django provides a convience wrapper to check
+permissions. This wrapper is :meth:`AdminSite.admin_view` (i.e.
+``self.admin_site.admin_view`` inside a ``ModelAdmin`` instance); use it like
+so::
+
+ class MyModelAdmin(admin.ModelAdmin):
+ def get_urls(self):
+ urls = super(MyModelAdmin, self).get_urls()
+ my_urls = patterns('',
+ (r'^my_view/$', self.admin_site.admin_view(self.my_view))
+ )
+ return my_urls + urls
+
+Notice the wrapped view in the fifth line above::
+
+ (r'^my_view/$', self.admin_site.admin_view(self.my_view))
+
+This wrapping will protect ``self.my_view`` from unauthorized access.
+
``ModelAdmin`` media definitions
--------------------------------
@@ -1027,7 +1070,7 @@ In this example, we register the default ``AdminSite`` instance
admin.autodiscover()
urlpatterns = patterns('',
- ('^admin/(.*)', admin.site.root),
+ ('^admin/', include(admin.site.urls)),
)
Above we used ``admin.autodiscover()`` to automatically load the
@@ -1041,15 +1084,13 @@ In this example, we register the ``AdminSite`` instance
from myproject.admin import admin_site
urlpatterns = patterns('',
- ('^myadmin/(.*)', admin_site.root),
+ ('^myadmin/', include(admin_site.urls)),
)
There is really no need to use autodiscover when using your own ``AdminSite``
instance since you will likely be importing all the per-app admin.py modules
in your ``myproject.admin`` module.
-Note that the regular expression in the URLpattern *must* group everything in
-the URL that comes after the URL root -- hence the ``(.*)`` in these examples.
Multiple admin sites in the same URLconf
----------------------------------------
@@ -1068,6 +1109,29 @@ respectively::
from myproject.admin import basic_site, advanced_site
urlpatterns = patterns('',
- ('^basic-admin/(.*)', basic_site.root),
- ('^advanced-admin/(.*)', advanced_site.root),
+ ('^basic-admin/', include(basic_site.urls)),
+ ('^advanced-admin/', include(advanced_site.urls)),
)
+
+Adding views to admin sites
+---------------------------
+
+It possible to add additional views to the admin site in the same way one can
+add them to ``ModelAdmins``. This by using the ``get_urls()`` method on an
+AdminSite in the same way as `described above`__
+
+__ `get_urls(self)`_
+
+Protecting Custom ``AdminSite`` and ``ModelAdmin``
+--------------------------------------------------
+
+By default all the views in the Django admin are protected so that only staff
+members can access them. If you add your own views to either a ``ModelAdmin``
+or ``AdminSite`` you should ensure that where necessary they are protected in
+the same manner. To do this use the ``admin_perm_test`` decorator provided in
+``django.contrib.admin.utils.admin_perm_test``. It can be used in the same way
+as the ``login_requied`` decorator.
+
+.. note::
+ The ``admin_perm_test`` decorator can only be used on methods which are on
+ ``ModelAdmins`` or ``AdminSites``, you cannot use it on arbitrary functions.
diff --git a/tests/regressiontests/admin_views/customadmin.py b/tests/regressiontests/admin_views/customadmin.py
new file mode 100644
index 0000000000..c812eab98b
--- /dev/null
+++ b/tests/regressiontests/admin_views/customadmin.py
@@ -0,0 +1,30 @@
+"""
+A second, custom AdminSite -- see tests.CustomAdminSiteTests.
+"""
+from django.conf.urls.defaults import patterns
+from django.contrib import admin
+from django.http import HttpResponse
+
+import models
+
+class Admin2(admin.AdminSite):
+ login_template = 'custom_admin/login.html'
+ index_template = 'custom_admin/index.html'
+
+ # A custom index view.
+ def index(self, request, extra_context=None):
+ return super(Admin2, self).index(request, {'foo': '*bar*'})
+
+ def get_urls(self):
+ return patterns('',
+ (r'^my_view/$', self.admin_view(self.my_view)),
+ ) + super(Admin2, self).get_urls()
+
+ def my_view(self, request):
+ return HttpResponse("Django is a magical pony!")
+
+site = Admin2(name="admin2")
+
+site.register(models.Article, models.ArticleAdmin)
+site.register(models.Section, inlines=[models.ArticleInline])
+site.register(models.Thing, models.ThingAdmin)
diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
index 391d1ffa3e..39daf116ab 100644
--- a/tests/regressiontests/admin_views/tests.py
+++ b/tests/regressiontests/admin_views/tests.py
@@ -14,6 +14,11 @@ from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey
class AdminViewBasicTest(TestCase):
fixtures = ['admin-views-users.xml', 'admin-views-colors.xml']
+ # Store the bit of the URL where the admin is registered as a class
+ # variable. That way we can test a second AdminSite just by subclassing
+ # this test case and changing urlbit.
+ urlbit = 'admin'
+
def setUp(self):
self.client.login(username='super', password='secret')
@@ -24,20 +29,20 @@ class AdminViewBasicTest(TestCase):
"""
If you leave off the trailing slash, app should redirect and add it.
"""
- request = self.client.get('/test_admin/admin/admin_views/article/add')
+ request = self.client.get('/test_admin/%s/admin_views/article/add' % self.urlbit)
self.assertRedirects(request,
- '/test_admin/admin/admin_views/article/add/'
+ '/test_admin/%s/admin_views/article/add/' % self.urlbit, status_code=301
)
def testBasicAddGet(self):
"""
A smoke test to ensure GET on the add_view works.
"""
- response = self.client.get('/test_admin/admin/admin_views/section/add/')
+ response = self.client.get('/test_admin/%s/admin_views/section/add/' % self.urlbit)
self.failUnlessEqual(response.status_code, 200)
def testAddWithGETArgs(self):
- response = self.client.get('/test_admin/admin/admin_views/section/add/', {'name': 'My Section'})
+ response = self.client.get('/test_admin/%s/admin_views/section/add/' % self.urlbit, {'name': 'My Section'})
self.failUnlessEqual(response.status_code, 200)
self.failUnless(
'value="My Section"' in response.content,
@@ -48,7 +53,7 @@ class AdminViewBasicTest(TestCase):
"""
A smoke test to ensureGET on the change_view works.
"""
- response = self.client.get('/test_admin/admin/admin_views/section/1/')
+ response = self.client.get('/test_admin/%s/admin_views/section/1/' % self.urlbit)
self.failUnlessEqual(response.status_code, 200)
def testBasicAddPost(self):
@@ -61,7 +66,7 @@ class AdminViewBasicTest(TestCase):
"article_set-TOTAL_FORMS": u"3",
"article_set-INITIAL_FORMS": u"0",
}
- response = self.client.post('/test_admin/admin/admin_views/section/add/', post_data)
+ response = self.client.post('/test_admin/%s/admin_views/section/add/' % self.urlbit, post_data)
self.failUnlessEqual(response.status_code, 302) # redirect somewhere
def testBasicEditPost(self):
@@ -106,7 +111,7 @@ class AdminViewBasicTest(TestCase):
"article_set-5-date_0": u"",
"article_set-5-date_1": u"",
}
- response = self.client.post('/test_admin/admin/admin_views/section/1/', post_data)
+ response = self.client.post('/test_admin/%s/admin_views/section/1/' % self.urlbit, post_data)
self.failUnlessEqual(response.status_code, 302) # redirect somewhere
def testChangeListSortingCallable(self):
@@ -114,7 +119,7 @@ class AdminViewBasicTest(TestCase):
Ensure we can sort on a list_display field that is a callable
(column 2 is callable_year in ArticleAdmin)
"""
- response = self.client.get('/test_admin/admin/admin_views/article/', {'ot': 'asc', 'o': 2})
+ response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'asc', 'o': 2})
self.failUnlessEqual(response.status_code, 200)
self.failUnless(
response.content.index('Oldest content') < response.content.index('Middle content') and
@@ -127,7 +132,7 @@ class AdminViewBasicTest(TestCase):
Ensure we can sort on a list_display field that is a Model method
(colunn 3 is 'model_year' in ArticleAdmin)
"""
- response = self.client.get('/test_admin/admin/admin_views/article/', {'ot': 'dsc', 'o': 3})
+ response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'dsc', 'o': 3})
self.failUnlessEqual(response.status_code, 200)
self.failUnless(
response.content.index('Newest content') < response.content.index('Middle content') and
@@ -140,7 +145,7 @@ class AdminViewBasicTest(TestCase):
Ensure we can sort on a list_display field that is a ModelAdmin method
(colunn 4 is 'modeladmin_year' in ArticleAdmin)
"""
- response = self.client.get('/test_admin/admin/admin_views/article/', {'ot': 'asc', 'o': 4})
+ response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'asc', 'o': 4})
self.failUnlessEqual(response.status_code, 200)
self.failUnless(
response.content.index('Oldest content') < response.content.index('Middle content') and
@@ -150,7 +155,7 @@ class AdminViewBasicTest(TestCase):
def testLimitedFilter(self):
"""Ensure admin changelist filters do not contain objects excluded via limit_choices_to."""
- response = self.client.get('/test_admin/admin/admin_views/thing/')
+ response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit)
self.failUnlessEqual(response.status_code, 200)
self.failUnless(
'' in response.content,
@@ -163,11 +168,30 @@ class AdminViewBasicTest(TestCase):
def testIncorrectLookupParameters(self):
"""Ensure incorrect lookup parameters are handled gracefully."""
- response = self.client.get('/test_admin/admin/admin_views/thing/', {'notarealfield': '5'})
- self.assertRedirects(response, '/test_admin/admin/admin_views/thing/?e=1')
- response = self.client.get('/test_admin/admin/admin_views/thing/', {'color__id__exact': 'StringNotInteger!'})
- self.assertRedirects(response, '/test_admin/admin/admin_views/thing/?e=1')
-
+ response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'notarealfield': '5'})
+ self.assertRedirects(response, '/test_admin/%s/admin_views/thing/?e=1' % self.urlbit)
+ response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'color__id__exact': 'StringNotInteger!'})
+ self.assertRedirects(response, '/test_admin/%s/admin_views/thing/?e=1' % self.urlbit)
+
+class CustomModelAdminTest(AdminViewBasicTest):
+ urlbit = "admin2"
+
+ def testCustomAdminSiteLoginTemplate(self):
+ self.client.logout()
+ request = self.client.get('/test_admin/admin2/')
+ self.assertTemplateUsed(request, 'custom_admin/login.html')
+ self.assert_('Hello from a custom login template' in request.content)
+
+ def testCustomAdminSiteIndexViewAndTemplate(self):
+ request = self.client.get('/test_admin/admin2/')
+ self.assertTemplateUsed(request, 'custom_admin/index.html')
+ self.assert_('Hello from a custom index template *bar*' in request.content)
+
+ def testCustomAdminSiteView(self):
+ self.client.login(username='super', password='secret')
+ response = self.client.get('/test_admin/%s/my_view/' % self.urlbit)
+ self.assert_(response.content == "Django is a magical pony!", response.content)
+
def get_perm(Model, perm):
"""Return the permission object, for the Model"""
ct = ContentType.objects.get_for_model(Model)
@@ -432,44 +456,6 @@ class AdminViewPermissionsTest(TestCase):
self.client.get('/test_admin/admin/logout/')
- def testCustomAdminSiteTemplates(self):
- from django.contrib import admin
- self.assertEqual(admin.site.index_template, None)
- self.assertEqual(admin.site.login_template, None)
-
- self.client.get('/test_admin/admin/logout/')
- request = self.client.get('/test_admin/admin/')
- self.assertTemplateUsed(request, 'admin/login.html')
- self.client.post('/test_admin/admin/', self.changeuser_login)
- request = self.client.get('/test_admin/admin/')
- self.assertTemplateUsed(request, 'admin/index.html')
-
- self.client.get('/test_admin/admin/logout/')
- admin.site.login_template = 'custom_admin/login.html'
- admin.site.index_template = 'custom_admin/index.html'
- request = self.client.get('/test_admin/admin/')
- self.assertTemplateUsed(request, 'custom_admin/login.html')
- self.assert_('Hello from a custom login template' in request.content)
- self.client.post('/test_admin/admin/', self.changeuser_login)
- request = self.client.get('/test_admin/admin/')
- self.assertTemplateUsed(request, 'custom_admin/index.html')
- self.assert_('Hello from a custom index template' in request.content)
-
- # Finally, using monkey patching check we can inject custom_context arguments in to index
- original_index = admin.site.index
- def index(*args, **kwargs):
- kwargs['extra_context'] = {'foo': '*bar*'}
- return original_index(*args, **kwargs)
- admin.site.index = index
- request = self.client.get('/test_admin/admin/')
- self.assertTemplateUsed(request, 'custom_admin/index.html')
- self.assert_('Hello from a custom index template *bar*' in request.content)
-
- self.client.get('/test_admin/admin/logout/')
- del admin.site.index # Resets to using the original
- admin.site.login_template = None
- admin.site.index_template = None
-
def testDeleteView(self):
"""Delete view should restrict access and actually delete items."""
diff --git a/tests/regressiontests/admin_views/urls.py b/tests/regressiontests/admin_views/urls.py
index 4e5da48e13..f3f1fbd43a 100644
--- a/tests/regressiontests/admin_views/urls.py
+++ b/tests/regressiontests/admin_views/urls.py
@@ -1,9 +1,11 @@
from django.conf.urls.defaults import *
from django.contrib import admin
import views
+import customadmin
urlpatterns = patterns('',
(r'^admin/doc/', include('django.contrib.admindocs.urls')),
(r'^admin/secure-view/$', views.secure_view),
- (r'^admin/(.*)', admin.site.root),
+ (r'^admin/', include(admin.site.urls)),
+ (r'^admin2/', include(customadmin.site.urls)),
)