Skip to content

Commit 954c10d

Browse files
committed
feat: Changelist performance improvement
1 parent 870b217 commit 954c10d

File tree

6 files changed

+141
-12
lines changed

6 files changed

+141
-12
lines changed

djangocms_versioning/admin.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from django.contrib.contenttypes.models import ContentType
2121
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied
2222
from django.db import models
23-
from django.db.models import OuterRef, Subquery
23+
from django.db.models import OuterRef, Prefetch, Subquery
2424
from django.db.models.functions import Cast, Lower
2525
from django.forms import MediaDefiningClass
2626
from django.http import (
@@ -50,6 +50,7 @@
5050
get_current_site,
5151
get_editable_url,
5252
get_latest_admin_viewable_content,
53+
get_latest_content_from_cache,
5354
get_object_live_url,
5455
get_preview_url,
5556
proxy_model,
@@ -182,20 +183,29 @@ def _extra_grouping_fields(self):
182183

183184
def get_indicator_column(self, request):
184185
def indicator(obj):
186+
versions = None
185187
if self._extra_grouping_fields is not None: # Grouper Model
186188
content_obj = get_latest_admin_viewable_content(
187189
obj,
188190
include_unpublished_archived=True,
189191
**{field: getattr(self, field) for field in self._extra_grouping_fields},
190192
)
193+
for prefetched in getattr(obj, "_prefetched_contents", []):
194+
prefetched._prefetched_versions[0].content = prefetched # Avoid fetching reverse
195+
versions = (
196+
[content._prefetched_versions[0] for content in obj._prefetched_contents]
197+
if hasattr(obj, "_prefetched_contents")
198+
else None
199+
)
191200
else: # Content Model
192201
content_obj = obj
193-
status = content_indicator(content_obj)
202+
203+
status = content_indicator(content_obj, versions)
194204
menu = (
195205
content_indicator_menu(
196206
request,
197207
status,
198-
content_obj._version,
208+
content_obj._versions,
199209
back=f"{request.path_info}?{request.GET.urlencode()}",
200210
)
201211
if status
@@ -318,8 +328,37 @@ def get_queryset(self, request: HttpRequest) -> models.QuerySet:
318328
# cast is necessary for mysql
319329
content_modified=Cast(Subquery(contents.values("content_modified")[:1]), models.DateTimeField()),
320330
)
331+
# Prefetching is not implemented here; you may want to use Prefetch for related objects if needed.
332+
# To get the reverse name for self.grouper_field_name:
333+
# It's usually: <related_model>_set or the related_name defined on the ForeignKey.
334+
# For a ForeignKey field, you can get the reverse accessor name like this:
335+
reverse_name = self.content_model._meta.get_field(self.grouper_field_name).remote_field.get_accessor_name()
336+
qs = qs.prefetch_related(
337+
Prefetch(
338+
reverse_name,
339+
to_attr="_prefetched_contents", # Needed for state indicators
340+
queryset=self.content_model.admin_manager.filter(**self.current_content_filters)
341+
.prefetch_related(Prefetch("versions", to_attr="_prefetched_versions"))
342+
.order_by("-pk"),
343+
)
344+
)
345+
qs = qs.prefetch_related(
346+
Prefetch(
347+
reverse_name,
348+
to_attr="_current_contents", # Service for get_content_obj
349+
queryset=self.content_model.admin_manager.current_content(**self.current_content_filters)
350+
.prefetch_related(Prefetch("versions", to_attr="_prefetched_versions"))
351+
.order_by("-pk"),
352+
)
353+
)
321354
return qs
322355

356+
def get_content_obj(self, obj: models.Model) -> models.Model:
357+
"""Returns the latest content object for the given grouper object."""
358+
if obj is None or self._is_content_obj(obj) or not hasattr(obj, "_prefetched_contents"):
359+
return super().get_content_obj(obj)
360+
return get_latest_content_from_cache(obj._prefetched_contents, include_unpublished_archived=True)
361+
323362
@admin.display(
324363
description=_("State"),
325364
ordering="content_state",

djangocms_versioning/cms_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ def get_indicator_menu(cls, request, page_content):
328328
status = page_content.content_indicator()
329329
if not status or status == "empty": # pragma: no cover
330330
return super().get_indicator_menu(request, page_content)
331-
versions = page_content._version # Cache from .content_indicator()
331+
versions = page_content._versions # Cache from .content_indicator()
332332
back = admin_reverse("cms_pagecontent_changelist") + f"?language={request.GET.get('language')}"
333333
menu = indicators.content_indicator_menu(request, status, versions, back=back)
334334
return menu_template if menu else "", menu

djangocms_versioning/helpers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from . import versionables
2424
from .conf import EMAIL_NOTIFICATIONS_FAIL_SILENTLY
25-
from .constants import DRAFT
25+
from .constants import DRAFT, PUBLISHED
2626

2727
try:
2828
from djangocms_internalsearch.helpers import emit_content_change
@@ -360,6 +360,11 @@ def get_latest_admin_viewable_content(
360360
f"Grouping field(s) {missing_fields} required for {versionable.grouper_model}."
361361
)
362362

363+
if hasattr(grouper, "_prefetched_contents"):
364+
return get_latest_content_from_cache(
365+
grouper._prefetched_contents, include_unpublished_archived
366+
)
367+
363368
# Get the name of the content_set (e.g., "pagecontent_set") from the versionable
364369
content_set = versionable.grouper_field.remote_field.get_accessor_name()
365370

@@ -373,6 +378,20 @@ def get_latest_admin_viewable_content(
373378
return qs.filter(**extra_grouping_fields).current_content().first()
374379

375380

381+
def get_latest_content_from_cache(cache, include_unpublished_archived: bool = False) -> models.Model | None:
382+
# Evaluate prefetched contents in python
383+
for content in cache:
384+
if content._prefetched_versions[0].state == DRAFT:
385+
return content
386+
for content in cache:
387+
if content._prefetched_versions[0].state == PUBLISHED:
388+
return content
389+
if include_unpublished_archived:
390+
order_by_date = sorted(cache, key=lambda v: v._prefetched_versions[0].created, reverse=True)
391+
return order_by_date[0] if order_by_date else None
392+
return None
393+
394+
376395
def get_latest_admin_viewable_page_content(
377396
page: Page, language: str
378397
) -> PageContent: # pragma: no cover

djangocms_versioning/indicators.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,20 @@ def content_indicator(
112112
}
113113
if DRAFT in signature and PUBLISHED not in signature:
114114
content_obj._indicator_status = "draft"
115-
content_obj._version = signature[DRAFT],
115+
content_obj._versions = signature[DRAFT],
116116
elif DRAFT in signature and PUBLISHED in signature:
117117
content_obj._indicator_status = "dirty"
118-
content_obj._version = (signature[DRAFT], signature[PUBLISHED])
118+
content_obj._versions = (signature[DRAFT], signature[PUBLISHED])
119119
elif PUBLISHED in signature:
120120
content_obj._indicator_status = "published"
121-
content_obj._version = signature[PUBLISHED],
121+
content_obj._versions = signature[PUBLISHED],
122122
elif versions[0].state == UNPUBLISHED:
123123
content_obj._indicator_status = "unpublished"
124-
content_obj._version = signature[UNPUBLISHED],
124+
content_obj._versions = signature[UNPUBLISHED],
125125
elif versions[0].state == ARCHIVED:
126126
content_obj._indicator_status = "archived"
127-
content_obj._version = signature[ARCHIVED],
127+
content_obj._versions = signature[ARCHIVED],
128128
else: # pragma: no cover
129129
content_obj._indicator_status = None
130-
content_obj._version = [None]
130+
content_obj._versions = [None]
131131
return content_obj._indicator_status

djangocms_versioning/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ def _has_permission(self, perm: str, user) -> bool:
493493
# Second fallback: placeholder change permissions - works for PageContent
494494
return self.content.has_placeholder_change_permission(user)
495495
# final fallback: Django perms
496-
return user.has_perm(f"{self.content_type.app_label}.change_{self.content_type.model}")
496+
return user.has_perm(f"{self.content._meta.app_label}.change_{self.content._meta.model_name}")
497497

498498
check_modify = Conditions(
499499
[

tests/test_admin.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3402,3 +3402,74 @@ def test_buttons_in_archived_changeview(self):
34023402
self.assertNotContains(response, "New Draft")
34033403
self.assertNotContains(response, "Publish")
34043404

3405+
3406+
class GrouperAdminPerformanceTestCase(CMSTestCase):
3407+
"""Test to ensure Poll GrouperAdmin changelist_view queries are optimized"""
3408+
3409+
def setUp(self):
3410+
super().setUp()
3411+
# Pre-cache site to get consistent query counts across tests
3412+
from django.contrib.sites.models import Site
3413+
Site.objects.get_current()
3414+
3415+
def test_poll_grouper_changelist_queries_are_optimized(self):
3416+
"""Pin the number of queries for Poll GrouperAdmin changelist to prevent regressions"""
3417+
# Create test data: 5 polls with 2 versions each (draft + published)
3418+
for _i in range(5):
3419+
# Create published version
3420+
published = PollVersionFactory(state=constants.PUBLISHED)
3421+
# Create draft version for same poll
3422+
PollVersionFactory(
3423+
content__poll=published.content.poll,
3424+
content__language=published.content.language,
3425+
state=constants.DRAFT,
3426+
)
3427+
3428+
from djangocms_versioning.test_utils.polls.admin import PollAdmin
3429+
from djangocms_versioning.test_utils.polls.models import Poll
3430+
3431+
poll_admin = PollAdmin(Poll, admin.site)
3432+
3433+
# Create request
3434+
factory = RequestFactory()
3435+
request = factory.get("/admin/polls/poll/")
3436+
request.user = self.get_superuser()
3437+
3438+
# Pin the number of queries
3439+
# Expected queries should remain constant due to prefetch optimization
3440+
# 1. Count query for pagination
3441+
# 2. Count query (duplicate from admin)
3442+
# 3. Main queryset with subqueries for content annotations
3443+
with self.assertNumQueries(3):
3444+
response = poll_admin.changelist_view(request)
3445+
# Force evaluation of queryset
3446+
list(response.context_data["cl"].result_list)
3447+
3448+
def test_poll_changelist_with_many_versions_scales_well(self):
3449+
"""Ensure query count doesn't increase with more versions per poll"""
3450+
# Create 1 poll with many versions
3451+
poll_version = PollVersionFactory(state=constants.PUBLISHED)
3452+
poll = poll_version.content.poll
3453+
3454+
# Add 10 more versions (archived)
3455+
for _ in range(10):
3456+
PollVersionFactory(
3457+
content__poll=poll,
3458+
content__language=poll_version.content.language,
3459+
state=constants.ARCHIVED,
3460+
)
3461+
3462+
from djangocms_versioning.test_utils.polls.admin import PollAdmin
3463+
from djangocms_versioning.test_utils.polls.models import Poll
3464+
3465+
poll_admin = PollAdmin(Poll, admin.site)
3466+
3467+
factory = RequestFactory()
3468+
request = factory.get("/admin/polls/poll/")
3469+
request.user = self.get_superuser()
3470+
3471+
# Query count should remain the same regardless of version count
3472+
# because of prefetch optimization
3473+
with self.assertNumQueries(3):
3474+
response = poll_admin.changelist_view(request)
3475+
list(response.context_data["cl"].result_list)

0 commit comments

Comments
 (0)