From b35fa0b61c28b0fbf726ff97c9c5e39ba7dd659b Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 22 Jun 2026 15:12:14 -0700 Subject: [PATCH 1/2] feat(admin): show thumbnail preview on artifact change form (#1380) Editors only saw a "Currently: " link on the Publication / Talk / Poster change forms, with no way to visually confirm the right PDF was attached. Add a read-only `thumbnail_preview` field on ArtifactAdmin that renders the auto-generated `thumbnail` as a ~220px-tall via easy_thumbnails (same pipeline as the changelist `get_display_thumbnail`). - Injected into the 'Files' fieldset via `get_fieldsets()` on ArtifactAdmin, so all three child admins get it without editing each `fieldsets`. Change view only (the Add form has no saved thumbnail yet). - Degrades to a text placeholder when there's no thumbnail or the source file is missing on disk (happens on the servers) rather than 500ing the page. - Regression tests: img/placeholder/missing-file paths, fieldset injection across all three admins (change only, not add), no class-level mutation, and a full change-form GET asserting the renders unescaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/admin/artifact_admin.py | 72 ++++++++++ website/tests/test_admin_thumbnail_preview.py | 127 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 website/tests/test_admin_thumbnail_preview.py diff --git a/website/admin/artifact_admin.py b/website/admin/artifact_admin.py index 2f119856..a5f2ef6f 100644 --- a/website/admin/artifact_admin.py +++ b/website/admin/artifact_admin.py @@ -1,7 +1,10 @@ from django.contrib import admin from website.models import Artifact from django.contrib.admin import widgets +from django.utils.html import format_html from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple +from easy_thumbnails.files import get_thumbnailer +import os import logging # This retrieves a Python logging instance (or creates it) @@ -19,6 +22,10 @@ class ArtifactAdmin(admin.ModelAdmin): # (Django auto-applies DISTINCT for the M2M join). Subclasses may extend this. search_fields = ['title', 'forum_name', 'authors__first_name', 'authors__last_name'] + # thumbnail_preview is a computed, read-only display (see below). It must be + # listed here so Django allows it in get_fieldsets() on the change form. + readonly_fields = ('thumbnail_preview',) + fieldsets = [ (None, {'fields': ['title', 'authors', 'date']}), ('Files', {'fields': ['pdf_file', 'raw_file']}), @@ -27,6 +34,71 @@ class ArtifactAdmin(admin.ModelAdmin): ('Keyword Info', {'fields': ['keywords']}), ] + # Height (px) of the change-form thumbnail preview image. + THUMBNAIL_PREVIEW_HEIGHT = 220 + + def thumbnail_preview(self, obj): + """ + Read-only image preview of the artifact's auto-generated ``thumbnail``, + shown on the change form so editors can confirm the correct PDF is + attached (the form otherwise only shows the "Currently: ..." filename). + + Renders an ```` (~220px tall) via easy_thumbnails — the same + pipeline as the changelist ``get_display_thumbnail`` in TalkAdmin / + PublicationAdmin. Degrades to a text placeholder when there is no + thumbnail yet or the source file is missing on disk (which happens on + the servers), rather than 500ing the whole change page. + """ + placeholder = format_html( + 'Save with a PDF attached to generate a thumbnail.' + ) + if obj is None or not obj.thumbnail: + return placeholder + try: + if not os.path.isfile(obj.thumbnail.path): + return placeholder + thumbnailer = get_thumbnailer(obj.thumbnail) + # (0, H) constrains height to H and lets width scale with the + # source aspect ratio (no crop — show the whole thumbnail). + thumbnail_url = thumbnailer.get_thumbnail( + {'size': (0, self.THUMBNAIL_PREVIEW_HEIGHT)} + ).url + except Exception: + _logger.exception( + "Could not render thumbnail preview for artifact=%s", + getattr(obj, 'pk', None), + ) + return placeholder + return format_html( + 'Thumbnail preview', + thumbnail_url, self.THUMBNAIL_PREVIEW_HEIGHT, + ) + + thumbnail_preview.short_description = 'Thumbnail preview' + + def get_fieldsets(self, request, obj=None): + """ + Inject the read-only ``thumbnail_preview`` into the 'Files' fieldset on + the change form only. Done here (rather than in each child admin's + ``fieldsets``) so Publication / Talk / Poster all get the preview. + On the Add form there is no saved thumbnail yet, so it is omitted. + """ + fieldsets = super().get_fieldsets(request, obj) + if obj is None: + return fieldsets + # Build new tuples/dicts rather than mutating the class-level fieldsets + # (ModelAdmin.get_fieldsets returns self.fieldsets by reference). + updated = [] + for name, opts in fieldsets: + if name == 'Files': + fields = list(opts.get('fields', [])) + if 'thumbnail_preview' not in fields: + fields = fields + ['thumbnail_preview'] + opts = {**opts, 'fields': fields} + updated.append((name, opts)) + return updated + def formfield_for_manytomany(self, db_field, request, **kwargs): """ Overrides the formfield_for_manytomany method of the parent ModelAdmin class to customize the widgets diff --git a/website/tests/test_admin_thumbnail_preview.py b/website/tests/test_admin_thumbnail_preview.py new file mode 100644 index 00000000..f97b7682 --- /dev/null +++ b/website/tests/test_admin_thumbnail_preview.py @@ -0,0 +1,127 @@ +""" +Regression tests for the artifact thumbnail preview on the admin *change form* +(#1380). + +``ArtifactAdmin.thumbnail_preview`` renders a read-only of the artifact's +auto-generated ``thumbnail`` so editors can confirm the right PDF is attached. +``get_fieldsets`` injects it into the 'Files' fieldset on the change form (only) +for all three artifact admins (Publication / Talk / Poster). + +Attaching a thumbnail: factory artifacts carry only a *stub* PDF, so +Artifact.save()'s ImageMagick step can't generate a real thumbnail. We write a +valid 1x1 GIF straight to storage and point the field at it (same trick as +test_thumbnail_preview.py for the #840 card), so easy_thumbnails has a real +source. +""" + +from django.contrib.auth.models import User +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.urls import reverse + +from website.models import Poster, Publication, Talk +from website.admin.admin_site import ml_admin_site +from website.admin.poster_admin import PosterAdmin +from website.admin.publication_admin import PublicationAdmin +from website.admin.talk_admin import TalkAdmin +from website.tests.base import DatabaseTestCase +from website.tests.factories import PosterFactory, _GIF_1PX + + +class ArtifactThumbnailPreviewTests(DatabaseTestCase): + def _attach_thumbnail(self, artifact): + """Give ``artifact`` a real (1x1 GIF) thumbnail without re-running + Artifact.save(): write the file via default storage at the artifact's + own thumbnail path, then persist the field name with an UPDATE.""" + rel_path = artifact.get_upload_thumbnail_dir(f"admin_preview_{artifact.pk}.gif") + saved_name = default_storage.save(rel_path, ContentFile(_GIF_1PX)) + type(artifact).objects.filter(pk=artifact.pk).update(thumbnail=saved_name) + artifact.refresh_from_db() + return artifact + + def test_preview_renders_img_when_thumbnail_present(self): + poster = self._attach_thumbnail(PosterFactory(authors=[self.make_person()])) + admin = PosterAdmin(Poster, ml_admin_site) + + html = admin.thumbnail_preview(poster) + + self.assertIn(" as real (unescaped) HTML, not as escaped text.""" + poster = self._attach_thumbnail(PosterFactory(authors=[self.make_person()])) + User.objects.create_superuser("admin", "admin@example.com", "pw") + self.client.force_login(User.objects.get(username="admin")) + + url = reverse("admin:website_poster_change", args=[poster.pk]) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + body = resp.content.decode() + self.assertIn('alt="Thumbnail preview"', body) + self.assertIn(" Date: Mon, 22 Jun 2026 15:55:52 -0700 Subject: [PATCH 2/2] style(admin): rename artifact thumbnail field label to "PDF thumbnail" (#1380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback, "Thumbnail preview" → "PDF thumbnail" on the change form (clearer that it's the PDF-derived thumbnail). Also updates the alt text and the regression test to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/admin/artifact_admin.py | 5 +++-- website/tests/test_admin_thumbnail_preview.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/website/admin/artifact_admin.py b/website/admin/artifact_admin.py index d8dee21b..e8ba06ce 100644 --- a/website/admin/artifact_admin.py +++ b/website/admin/artifact_admin.py @@ -89,12 +89,13 @@ def thumbnail_preview(self, obj): ) return placeholder return format_html( - 'Thumbnail preview', thumbnail_url, self.THUMBNAIL_PREVIEW_HEIGHT, ) - thumbnail_preview.short_description = 'Thumbnail preview' + # Django auto-appends the trailing colon in the admin label. + thumbnail_preview.short_description = 'PDF thumbnail' def get_fieldsets(self, request, obj=None): """ diff --git a/website/tests/test_admin_thumbnail_preview.py b/website/tests/test_admin_thumbnail_preview.py index f97b7682..89c3fd34 100644 --- a/website/tests/test_admin_thumbnail_preview.py +++ b/website/tests/test_admin_thumbnail_preview.py @@ -112,7 +112,7 @@ def test_change_form_get_renders_img_end_to_end(self): self.assertEqual(resp.status_code, 200) body = resp.content.decode() - self.assertIn('alt="Thumbnail preview"', body) + self.assertIn('alt="PDF thumbnail"', body) self.assertIn("