From 9f716580c4d8b237aa665bc528c51287478d0957 Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Fri, 20 Dec 2024 13:49:44 +0200 Subject: [PATCH] Refs #26220 -- Added SingleObjectMixin._get_model(). --- AUTHORS | 1 + django/views/generic/detail.py | 19 +++++++++---- django/views/generic/edit.py | 13 +-------- docs/releases/5.2.txt | 5 +++- tests/generic_views/test_detail.py | 45 ++++++++++++++++++++++++++++++ tests/generic_views/urls.py | 19 ++++++++++++- tests/generic_views/views.py | 14 ++++++++++ 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/AUTHORS b/AUTHORS index 573a030ea1..35c9089108 100644 --- a/AUTHORS +++ b/AUTHORS @@ -233,6 +233,7 @@ answer newbie questions, and generally made Django that much better: Chris Wilson Ciaran McCormick Claude Paroz + Clifford Gama Clint Ecker colin@owlfish.com Colin Wood diff --git a/django/views/generic/detail.py b/django/views/generic/detail.py index a5f604bf1a..05ae355e65 100644 --- a/django/views/generic/detail.py +++ b/django/views/generic/detail.py @@ -66,8 +66,8 @@ class SingleObjectMixin(ContextMixin): may not be called if get_object() is overridden. """ if self.queryset is None: - if self.model: - return self.model._default_manager.all() + if (model := self._get_model()) is not None: + return model._default_manager.all() else: raise ImproperlyConfigured( "%(cls)s is missing a QuerySet. Define " @@ -76,6 +76,13 @@ class SingleObjectMixin(ContextMixin): ) return self.queryset.all() + def _get_model(self): + """Return the model of the object the view is displaying.""" + model = self.model + if model is None and getattr(self, "object", None) is not None: + return self.object.__class__ + return model + def get_slug_field(self): """Get the name of a slug field to be used to look up by slug.""" return self.slug_field @@ -155,14 +162,14 @@ class SingleObjectTemplateResponseMixin(TemplateResponseMixin): self.template_name_suffix, ) ) - elif getattr(self, "model", None) is not None and issubclass( - self.model, models.Model + elif (model := getattr(self, "_get_model", lambda: None)()) and issubclass( + model, models.Model ): names.append( "%s/%s%s.html" % ( - self.model._meta.app_label, - self.model._meta.model_name, + model._meta.app_label, + model._meta.model_name, self.template_name_suffix, ) ) diff --git a/django/views/generic/edit.py b/django/views/generic/edit.py index ebd071cf00..ef8930650f 100644 --- a/django/views/generic/edit.py +++ b/django/views/generic/edit.py @@ -87,18 +87,7 @@ class ModelFormMixin(FormMixin, SingleObjectMixin): if self.form_class: return self.form_class else: - if self.model is not None: - # If a model has been explicitly provided, use it - model = self.model - elif getattr(self, "object", None) is not None: - # If this view is operating on a single object, use - # the class of that object - model = self.object.__class__ - else: - # Try to get a queryset and extract the model class - # from that - model = self.get_queryset().model - + model = self._get_model() or self.get_queryset().model if self.fields is None: raise ImproperlyConfigured( "Using ModelFormMixin (base class of %s) without " diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index e103de847b..9064c46ebe 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -226,7 +226,10 @@ Forms Generic Views ~~~~~~~~~~~~~ -* ... +* The new :meth:`~django.views.generic.detail.SingleObjectMixin._get_model` + method returns the model defined for the view, or a custom model if + overridden. If ``queryset`` is provided, it takes precedence as the source + of objects. Internationalization ~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/generic_views/test_detail.py b/tests/generic_views/test_detail.py index 7203100576..a7a3776402 100644 --- a/tests/generic_views/test_detail.py +++ b/tests/generic_views/test_detail.py @@ -219,3 +219,48 @@ class DetailViewTest(TestCase): res = self.client.get("/detail/nonmodel/1/") self.assertEqual(res.status_code, 200) self.assertEqual(res.context["object"].id, "non_model_1") + + def test_get_model_override(self): + res = self.client.get("/detail/author/getmodel/%s/" % self.author1.pk) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context["object"], self.author1) + self.assertTemplateUsed(res, "generic_views/author_detail.html") + + def test_invalid_model_configuration(self): + msg = ( + "MissingModelView is missing a QuerySet. Define MissingModelView.model, " + "MissingModelView.queryset, or override MissingModelView.get_queryset()." + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + self.client.get("/missing-model/") + + def test_conflicting_model_and_get_model(self): + """ + `_get_model` takes precedence over model. + """ + url = "/detail/conflicting-model-and-getmodel/%s/" % self.author1.pk + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context["object"], self.author1) + self.assertTemplateUsed(res, "generic_views/author_detail.html") + + def test_model_and_queryset_ignores_model(self): + """ + Queryset takes precedence over model as source of objects. + """ + url = "/detail/author/conflicting-model-and-queryset/%s/" % self.author1.pk + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context["object"], self.author1) + self.assertTemplateUsed(res, "generic_views/author_detail.html") + + def test_queryset_and_get_model_ignores_get_model(self): + """ + When queryset is defined, it takes precedence over `_get_model` as + source of model objects. + """ + url = "/detail/conflicting-queryset-and-getmodel/%s/" % self.artist1.pk + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.context["object"], self.artist1) + self.assertTemplateUsed(res, "generic_views/artist_detail.html") diff --git a/tests/generic_views/urls.py b/tests/generic_views/urls.py index 277b2c4c1b..a11c0828d0 100644 --- a/tests/generic_views/urls.py +++ b/tests/generic_views/urls.py @@ -5,7 +5,7 @@ from django.views.decorators.cache import cache_page from django.views.generic import TemplateView, dates from . import views -from .models import Book +from .models import Artist, Book urlpatterns = [ # TemplateView @@ -75,6 +75,23 @@ urlpatterns = [ path("detail/author/invalid/qs/", views.AuthorDetail.as_view(queryset=None)), path("detail/nonmodel/1/", views.NonModelDetail.as_view()), path("detail/doesnotexist//", views.ObjectDoesNotExistDetail.as_view()), + path( + "detail/author/getmodel//", + views.AuthorDetailOverridesGetModel.as_view(), + ), + path("missing-model/", views.MissingModelView.as_view()), + path( + "detail/conflicting-model-and-getmodel//", + views.AuthorDetailOverridesGetModel.as_view(model=Artist), + ), + path( + "detail/author/conflicting-model-and-queryset//", + views.AuthorDetailConflictingModelAndQueryset.as_view(), + ), + path( + "detail/conflicting-queryset-and-getmodel//", + views.AuthorDetailOverridesGetModel.as_view(queryset=Artist.objects.all()), + ), # FormView path("contact/", views.ContactView.as_view()), path("late-validation/", views.LateValidationView.as_view()), diff --git a/tests/generic_views/views.py b/tests/generic_views/views.py index 5348c67632..bd930ef9b4 100644 --- a/tests/generic_views/views.py +++ b/tests/generic_views/views.py @@ -43,6 +43,20 @@ class AuthorCustomDetail(generic.DetailView): return self.render_to_response(context) +class AuthorDetailOverridesGetModel(generic.DetailView): + def _get_model(self): + return Author + + +class AuthorDetailConflictingModelAndQueryset(generic.DetailView): + queryset = Author.objects.all() + model = Artist + + +class MissingModelView(generic.DetailView): + pass + + class PageDetail(generic.DetailView): queryset = Page.objects.all() template_name_field = "template"