Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions website/admin/award_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class AwardAdmin(admin.ModelAdmin):

date_hierarchy = 'date' # Year/month/day drill-down (awards are browsed by year)

# Prefetch the M2M relations get_recipient_names / get_project_names walk, so
# they don't fire two queries per award on the changelist (#1346).
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('recipients', 'projects')

def get_fieldsets(self, request, obj=None):
# Built at request time so reverse() can resolve the Publications admin URL.
publications_url = reverse('admin:website_publication_changelist')
Expand Down
8 changes: 8 additions & 0 deletions website/admin/grant_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ class GrantAdmin(ArtifactAdmin):
ordering = ('-date',) # sort by date, most recent first
date_hierarchy = 'date' # Year/month/day drill-down by grant start date

# sponsor is a FK column -> list_select_related (Django applies it to the
# changelist query); the get_first_author_last_name column walks authors ->
# prefetch in get_queryset. Together these drop the per-row queries (#1346).
list_select_related = ('sponsor',)

def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('authors')

fieldsets = [
(None, {'fields': ['title', 'authors']}),
('Grant Info', {'fields': ['date', 'end_date', 'sponsor', 'funding_amount', 'forum_url', 'grant_id']}),
Expand Down
6 changes: 6 additions & 0 deletions website/admin/poster_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class PosterAdmin(ArtifactAdmin):
ordering = ('-date',) # Poster had no default sort; newest first like its siblings
date_hierarchy = 'date' # Year/month/day drill-down

# Prefetch authors so the inherited get_first_author_last_name column resolves
# from cache instead of querying authors per row (#1346). (Artifact.get_first_
# author_last_name reads list(self.authors.all()) so it uses this prefetch.)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('authors')

def get_changeform_initial_data(self, request):
"""
Pre-fills the poster form with data from a related publication.
Expand Down
18 changes: 13 additions & 5 deletions website/admin/publication_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ class Media:

list_filter = (PubVenueTypeListFilter, PubVenueListFilter)

# add in auto-complete fields
# Prefetch the M2M relations the changelist renders per row (display_authors,
# display_projects) so they don't fire 2-3 queries each per publication (#1346).
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('authors', 'projects')

# add in auto-complete fields
# this addresses: https://github.com/jonfroehlich/makeabilitylabwebsite/issues/553
#
# autocomplete_fields is a list of ForeignKey and/or ManyToManyField fields you would like
Expand All @@ -77,10 +82,13 @@ def display_projects(self, obj):
display_projects.short_description = 'Projects'

def display_authors(self, obj):
authors = [author.get_full_name() for author in obj.authors.all()[:5]]
if obj.authors.count() > 5:
authors.append("...")
return ", ".join(authors) if authors else 'No authors'
# list(...) reuses the list_prefetch_related('authors') cache; slicing the
# queryset ([:5]) or calling .count() would each re-query per row instead.
authors = list(obj.authors.all())
names = [author.get_full_name() for author in authors[:5]]
if len(authors) > 5:
names.append("...")
return ", ".join(names) if names else 'No authors'
display_authors.short_description = 'Authors (First 5)'

def get_display_thumbnail(self, obj):
Expand Down
4 changes: 4 additions & 0 deletions website/admin/talk_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ class TalkAdmin(ArtifactAdmin):

ordering = ('-date',) # Sort talks by date in descending order
list_filter = ('talk_type',) # Add a filter for the talk type

# Prefetch speakers so get_speakers_as_csv doesn't query authors per row (#1346).
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('authors')
date_hierarchy = 'date' # Year/month/day drill-down at the top of the list

def get_changeform_initial_data(self, request):
Expand Down
4 changes: 4 additions & 0 deletions website/admin/video_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class VideoAdmin(admin.ModelAdmin):
ordering = ('-date',)
date_hierarchy = 'date' # Year/month/day drill-down

# Prefetch projects so display_projects doesn't query them per row (#1346).
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('projects')

def display_projects(self, obj):
return ", ".join([project.name for project in obj.projects.all()])

Expand Down
16 changes: 11 additions & 5 deletions website/models/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,20 @@ def get_upload_thumbnail_dir(self, filename):
def get_first_author_last_name(self):
"""
Returns the last name of the first author of the artifact.

If the artifact has an ID and at least one author, it returns the last name of the first author.
Otherwise, it returns "Unknown".

Reads ``list(self.authors.all())`` rather than ``.exists()`` + ``.first()`` so an
admin changelist with ``list_prefetch_related('authors')`` resolves it from the
prefetch cache (no per-row query). ``authors`` is a SortedManyToManyField, so
``.all()`` preserves the editor-defined order — ``[0]`` is still the first author.
"""
if self.id and self.authors.exists():
return self.authors.first().last_name
else:
return "Unknown"
if self.id:
authors = list(self.authors.all())
if authors:
return authors[0].last_name
return "Unknown"

get_first_author_last_name.short_description = 'First Author (Last Name)' # used in the admin display for column header

Expand Down
113 changes: 113 additions & 0 deletions website/tests/test_admin_perf_artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""
Phase 3 of the admin changelist audit (#1346): prefetch the artifact-family
changelists so their per-row callable columns (author/speaker lists, sponsor,
recipients, projects) stop issuing per-row queries.

Coverage mirrors Phase 2:
1. Correctness — the rewritten ``Artifact.get_first_author_last_name`` (now
reads ``list(self.authors.all())`` so a prefetch resolves it) and the
rewritten ``PublicationAdmin.display_authors`` are behavior-preserving.
2. Regression — each changelist's steady-state query count stays flat as rows
grow (3 -> 6). Before this phase, columns like ``display_authors`` (a
``[:5]`` slice + ``.count()``) or ``get_speakers_as_csv`` fired 1-3 queries
per row.
"""

from website.models import Publication, Poster
from website.admin.admin_site import ml_admin_site
from website.admin.publication_admin import PublicationAdmin
from website.admin.talk_admin import TalkAdmin
from website.admin.poster_admin import PosterAdmin
from website.admin.video_admin import VideoAdmin
from website.admin.award_admin import AwardAdmin
from website.tests.factories import (PersonFactory, ProjectFactory,
PublicationFactory, TalkFactory,
PosterFactory, VideoFactory, AwardFactory)
from website.tests.test_admin_perf import _AdminPerfBase
from website.tests.base import DatabaseTestCase


class ArtifactFirstAuthorTests(DatabaseTestCase):
"""get_first_author_last_name (rewritten to use list(self.authors.all()))."""

def test_returns_first_author_in_sorted_order(self):
first = PersonFactory(last_name="Aaa")
second = PersonFactory(last_name="Bbb")
poster = PosterFactory(authors=[first, second])
self.assertEqual(poster.get_first_author_last_name(), "Aaa")

def test_unknown_when_no_authors(self):
poster = PosterFactory()
self.assertEqual(poster.get_first_author_last_name(), "Unknown")


class PublicationDisplayAuthorsTests(DatabaseTestCase):
"""PublicationAdmin.display_authors caps at five names and appends an ellipsis."""

def setUp(self):
self.admin = PublicationAdmin(Publication, ml_admin_site)

def test_six_authors_shows_five_plus_ellipsis(self):
people = [PersonFactory(first_name=f"A{i}", last_name=f"L{i}") for i in range(6)]
pub = PublicationFactory(authors=people)
rendered = self.admin.display_authors(pub)
self.assertIn("...", rendered)
self.assertEqual(rendered.count(","), 5) # 5 names + "..." => 5 commas

def test_no_authors(self):
pub = PublicationFactory()
self.assertEqual(self.admin.display_authors(pub), "No authors")


class ArtifactChangelistPerfTests(_AdminPerfBase):
"""Each artifact changelist's query count stays flat as rows grow (3 -> 6)."""

def _assert_flat(self, model_admin, params, make_fn):
make_fn(0, 3)
n1 = self._steady_state_query_count(model_admin, params)
make_fn(3, 6)
n2 = self._steady_state_query_count(model_admin, params)
self.assertEqual(
n1, n2,
f"{type(model_admin).__name__} N+1: {n1} -> {n2} queries as rows grew 3 -> 6")

def test_publications_flat(self):
def make(start, end):
for i in range(start, end):
pub = PublicationFactory(
title=f"Pub {i}",
authors=[PersonFactory(), PersonFactory()])
pub.projects.set([ProjectFactory()])
self._assert_flat(PublicationAdmin(Publication, ml_admin_site), {}, make)

def test_talks_flat(self):
from website.models import Talk
def make(start, end):
for i in range(start, end):
TalkFactory(title=f"Talk {i}",
authors=[PersonFactory(), PersonFactory()])
self._assert_flat(TalkAdmin(Talk, ml_admin_site), {}, make)

def test_posters_flat(self):
def make(start, end):
for i in range(start, end):
PosterFactory(title=f"Poster {i}",
authors=[PersonFactory(), PersonFactory()])
self._assert_flat(PosterAdmin(Poster, ml_admin_site), {}, make)

def test_videos_flat(self):
from website.models import Video
def make(start, end):
for i in range(start, end):
video = VideoFactory(title=f"Video {i}")
video.projects.set([ProjectFactory(), ProjectFactory()])
self._assert_flat(VideoAdmin(Video, ml_admin_site), {}, make)

def test_awards_flat(self):
from website.models import Award
def make(start, end):
for i in range(start, end):
award = AwardFactory(title=f"Award {i}",
recipients=[PersonFactory(), PersonFactory()])
award.projects.set([ProjectFactory()])
self._assert_flat(AwardAdmin(Award, ml_admin_site), {}, make)
Loading