diff --git a/website/admin/artifact_admin.py b/website/admin/artifact_admin.py
index c9175597..e8ba06ce 100644
--- a/website/admin/artifact_admin.py
+++ b/website/admin/artifact_admin.py
@@ -1,8 +1,11 @@
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 website.utils.upload_validators import PDF_EXTENSIONS, RAW_FILE_EXTENSIONS
+from easy_thumbnails.files import get_thumbnailer
+import os
import logging
# This retrieves a Python logging instance (or creates it)
@@ -38,6 +41,10 @@ class Media:
# (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']}),
@@ -46,6 +53,72 @@ class Media:
('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_url, self.THUMBNAIL_PREVIEW_HEIGHT,
+ )
+
+ # Django auto-appends the trailing colon in the admin label.
+ thumbnail_preview.short_description = 'PDF thumbnail'
+
+ 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 get_form(self, request, obj=None, **kwargs):
"""
Seed the ``accept`` attribute on the file inputs from the same extension
diff --git a/website/tests/test_admin_thumbnail_preview.py b/website/tests/test_admin_thumbnail_preview.py
new file mode 100644
index 00000000..89c3fd34
--- /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="PDF thumbnail"', body)
+ self.assertIn("![]()