diff --git a/AUTHORS b/AUTHORS index f39c8f4366..487d948445 100644 --- a/AUTHORS +++ b/AUTHORS @@ -290,6 +290,7 @@ answer newbie questions, and generally made Django that much better: hambaloney Hannes Struß Hawkeye + Helen Sherwood-Taylor Henrique Romano hipertracker@gmail.com Hiroki Kiyohara diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index 15f0c7fb3b..5fa3c46a8e 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -13,6 +13,7 @@ from django.db import models from django.http import Http404 from django.template.engine import Engine from django.urls import get_mod_func, get_resolver, get_urlconf, reverse +from django.utils import six from django.utils.decorators import method_decorator from django.utils.inspect import ( func_accepts_kwargs, func_accepts_var_args, func_has_no_args, @@ -126,13 +127,23 @@ class TemplateFilterIndexView(BaseAdminDocsView): class ViewIndexView(BaseAdminDocsView): template_name = 'admin_doc/view_index.html' + @staticmethod + def _get_full_name(func): + mod_name = func.__module__ + if six.PY3: + return '%s.%s' % (mod_name, func.__qualname__) + else: + # PY2 does not support __qualname__ + func_name = getattr(func, '__name__', func.__class__.__name__) + return '%s.%s' % (mod_name, func_name) + def get_context_data(self, **kwargs): views = [] urlconf = import_module(settings.ROOT_URLCONF) view_functions = extract_views_from_urlpatterns(urlconf.urlpatterns) for (func, regex, namespace, name) in view_functions: views.append({ - 'full_name': '%s.%s' % (func.__module__, getattr(func, '__name__', func.__class__.__name__)), + 'full_name': self._get_full_name(func), 'url': simplify_regex(regex), 'url_name': ':'.join((namespace or []) + (name and [name] or [])), 'namespace': ':'.join((namespace or [])), @@ -145,13 +156,34 @@ class ViewIndexView(BaseAdminDocsView): class ViewDetailView(BaseAdminDocsView): template_name = 'admin_doc/view_detail.html' - def get_context_data(self, **kwargs): - view = self.kwargs['view'] + @staticmethod + def _get_view_func(view): urlconf = get_urlconf() if get_resolver(urlconf)._is_callback(view): mod, func = get_mod_func(view) - view_func = getattr(import_module(mod), func) - else: + try: + # Separate the module and function, e.g. + # 'mymodule.views.myview' -> 'mymodule.views', 'myview'). + return getattr(import_module(mod), func) + except ImportError: + # Import may fail because view contains a class name, e.g. + # 'mymodule.views.ViewContainer.my_view', so mod takes the form + # 'mymodule.views.ViewContainer'. Parse it again to separate + # the module and class. + mod, klass = get_mod_func(mod) + return getattr(getattr(import_module(mod), klass), func) + except AttributeError: + # PY2 generates incorrect paths for views that are methods, + # e.g. 'mymodule.views.ViewContainer.my_view' will be + # listed as 'mymodule.views.my_view' because the class name + # can't be detected. This causes an AttributeError when + # trying to resolve the view. + return None + + def get_context_data(self, **kwargs): + view = self.kwargs['view'] + view_func = self._get_view_func(view) + if view_func is None: raise Http404 title, body, metadata = utils.parse_docstring(view_func.__doc__) if title: diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py index 6af847caa0..cec960dc1b 100644 --- a/django/urls/resolvers.py +++ b/django/urls/resolvers.py @@ -143,7 +143,10 @@ class RegexURLPattern(LocaleRegexProvider): callback = callback.func if not hasattr(callback, '__name__'): return callback.__module__ + "." + callback.__class__.__name__ + elif six.PY3: + return callback.__module__ + "." + callback.__qualname__ else: + # PY2 does not support __qualname__ return callback.__module__ + "." + callback.__name__ diff --git a/docs/releases/1.10.1.txt b/docs/releases/1.10.1.txt index 0094f8137e..e8dc05c7eb 100644 --- a/docs/releases/1.10.1.txt +++ b/docs/releases/1.10.1.txt @@ -58,3 +58,6 @@ Bugfixes * Fixed ``makemigrations`` crash if a database is read-only (:ticket:`27054`). * Removed duplicated managers in ``Model._meta.managers`` (:ticket:`27073`). + +* Fixed ``contrib.admindocs`` crash when a view is in a class, such as some of + the admin views (:ticket:`27018`). diff --git a/tests/admin_docs/tests.py b/tests/admin_docs/tests.py index 82384a4bb8..f57a9c8117 100644 --- a/tests/admin_docs/tests.py +++ b/tests/admin_docs/tests.py @@ -9,6 +9,7 @@ from django.contrib.sites.models import Site from django.test import TestCase, modify_settings, override_settings from django.test.utils import captured_stderr from django.urls import reverse +from django.utils import six from .models import Company, Person @@ -82,6 +83,18 @@ class AdminDocViewTests(TestDataMixin, AdminDocsTestCase): self.assertContains(response, 'Views by namespace test') self.assertContains(response, 'Name: test:func.') + @unittest.skipIf(six.PY2, "Python 2 doesn't support __qualname__.") + def test_view_index_with_method(self): + """ + Views that are methods are listed correctly. + """ + response = self.client.get(reverse('django-admindocs-views-index')) + self.assertContains( + response, + '

/admin/

', + html=True + ) + def test_view_detail(self): url = reverse('django-admindocs-views-detail', args=['django.contrib.admindocs.views.BaseAdminDocsView']) response = self.client.get(url) @@ -103,6 +116,14 @@ class AdminDocViewTests(TestDataMixin, AdminDocsTestCase): self.assertEqual(response.status_code, 404) self.assertNotIn("urlpatterns_reverse.nonimported_module", sys.modules) + def test_view_detail_as_method(self): + """ + Views that are methods can be displayed. + """ + url = reverse('django-admindocs-views-detail', args=['django.contrib.admin.sites.AdminSite.index']) + response = self.client.get(url) + self.assertEqual(response.status_code, 200 if six.PY3 else 404) + def test_model_index(self): response = self.client.get(reverse('django-admindocs-models-index')) self.assertContains( diff --git a/tests/urlpatterns_reverse/method_view_urls.py b/tests/urlpatterns_reverse/method_view_urls.py new file mode 100644 index 0000000000..d2f24ecffa --- /dev/null +++ b/tests/urlpatterns_reverse/method_view_urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url + + +class ViewContainer(object): + def method_view(self, request): + pass + + @classmethod + def classmethod_view(cls, request): + pass + +view_container = ViewContainer() + + +urlpatterns = [ + url(r'^$', view_container.method_view, name='instance-method-url'), + url(r'^$', ViewContainer.classmethod_view, name='instance-method-url'), +] diff --git a/tests/urlpatterns_reverse/tests.py b/tests/urlpatterns_reverse/tests.py index 67a4370e78..522899e886 100644 --- a/tests/urlpatterns_reverse/tests.py +++ b/tests/urlpatterns_reverse/tests.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals import sys import threading +import unittest from admin_scripts.tests import AdminScriptTestCase @@ -430,6 +431,13 @@ class ResolverTests(SimpleTestCase): self.assertTrue(resolver._is_callback('urlpatterns_reverse.nested_urls.View3')) self.assertFalse(resolver._is_callback('urlpatterns_reverse.nested_urls.blub')) + @unittest.skipIf(six.PY2, "Python 2 doesn't support __qualname__.") + def test_view_detail_as_method(self): + # Views which have a class name as part of their path. + resolver = get_resolver('urlpatterns_reverse.method_view_urls') + self.assertTrue(resolver._is_callback('urlpatterns_reverse.method_view_urls.ViewContainer.method_view')) + self.assertTrue(resolver._is_callback('urlpatterns_reverse.method_view_urls.ViewContainer.classmethod_view')) + def test_populate_concurrency(self): """ RegexURLResolver._populate() can be called concurrently, but not more