Skip to content

Commit f66f10a

Browse files
authored
Merge pull request #1381 from makeabilitylab/1380-artifact-thumbnail-preview
Show PDF thumbnail on artifact admin change form (#1380)
2 parents 92a6a35 + aa12c22 commit f66f10a

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

website/admin/artifact_admin.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from django.contrib import admin
22
from website.models import Artifact
33
from django.contrib.admin import widgets
4+
from django.utils.html import format_html
45
from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple
56
from website.utils.upload_validators import PDF_EXTENSIONS, RAW_FILE_EXTENSIONS
7+
from easy_thumbnails.files import get_thumbnailer
8+
import os
69
import logging
710

811
# This retrieves a Python logging instance (or creates it)
@@ -38,6 +41,10 @@ class Media:
3841
# (Django auto-applies DISTINCT for the M2M join). Subclasses may extend this.
3942
search_fields = ['title', 'forum_name', 'authors__first_name', 'authors__last_name']
4043

44+
# thumbnail_preview is a computed, read-only display (see below). It must be
45+
# listed here so Django allows it in get_fieldsets() on the change form.
46+
readonly_fields = ('thumbnail_preview',)
47+
4148
fieldsets = [
4249
(None, {'fields': ['title', 'authors', 'date']}),
4350
('Files', {'fields': ['pdf_file', 'raw_file']}),
@@ -46,6 +53,72 @@ class Media:
4653
('Keyword Info', {'fields': ['keywords']}),
4754
]
4855

56+
# Height (px) of the change-form thumbnail preview image.
57+
THUMBNAIL_PREVIEW_HEIGHT = 220
58+
59+
def thumbnail_preview(self, obj):
60+
"""
61+
Read-only image preview of the artifact's auto-generated ``thumbnail``,
62+
shown on the change form so editors can confirm the correct PDF is
63+
attached (the form otherwise only shows the "Currently: ..." filename).
64+
65+
Renders an ``<img>`` (~220px tall) via easy_thumbnails — the same
66+
pipeline as the changelist ``get_display_thumbnail`` in TalkAdmin /
67+
PublicationAdmin. Degrades to a text placeholder when there is no
68+
thumbnail yet or the source file is missing on disk (which happens on
69+
the servers), rather than 500ing the whole change page.
70+
"""
71+
placeholder = format_html(
72+
'<span style="color:#666;">Save with a PDF attached to generate a thumbnail.</span>'
73+
)
74+
if obj is None or not obj.thumbnail:
75+
return placeholder
76+
try:
77+
if not os.path.isfile(obj.thumbnail.path):
78+
return placeholder
79+
thumbnailer = get_thumbnailer(obj.thumbnail)
80+
# (0, H) constrains height to H and lets width scale with the
81+
# source aspect ratio (no crop — show the whole thumbnail).
82+
thumbnail_url = thumbnailer.get_thumbnail(
83+
{'size': (0, self.THUMBNAIL_PREVIEW_HEIGHT)}
84+
).url
85+
except Exception:
86+
_logger.exception(
87+
"Could not render thumbnail preview for artifact=%s",
88+
getattr(obj, 'pk', None),
89+
)
90+
return placeholder
91+
return format_html(
92+
'<img src="{}" alt="PDF thumbnail" '
93+
'style="height:{}px; width:auto; border:1px solid #ddd;" />',
94+
thumbnail_url, self.THUMBNAIL_PREVIEW_HEIGHT,
95+
)
96+
97+
# Django auto-appends the trailing colon in the admin label.
98+
thumbnail_preview.short_description = 'PDF thumbnail'
99+
100+
def get_fieldsets(self, request, obj=None):
101+
"""
102+
Inject the read-only ``thumbnail_preview`` into the 'Files' fieldset on
103+
the change form only. Done here (rather than in each child admin's
104+
``fieldsets``) so Publication / Talk / Poster all get the preview.
105+
On the Add form there is no saved thumbnail yet, so it is omitted.
106+
"""
107+
fieldsets = super().get_fieldsets(request, obj)
108+
if obj is None:
109+
return fieldsets
110+
# Build new tuples/dicts rather than mutating the class-level fieldsets
111+
# (ModelAdmin.get_fieldsets returns self.fieldsets by reference).
112+
updated = []
113+
for name, opts in fieldsets:
114+
if name == 'Files':
115+
fields = list(opts.get('fields', []))
116+
if 'thumbnail_preview' not in fields:
117+
fields = fields + ['thumbnail_preview']
118+
opts = {**opts, 'fields': fields}
119+
updated.append((name, opts))
120+
return updated
121+
49122
def get_form(self, request, obj=None, **kwargs):
50123
"""
51124
Seed the ``accept`` attribute on the file inputs from the same extension
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Regression tests for the artifact thumbnail preview on the admin *change form*
3+
(#1380).
4+
5+
``ArtifactAdmin.thumbnail_preview`` renders a read-only <img> of the artifact's
6+
auto-generated ``thumbnail`` so editors can confirm the right PDF is attached.
7+
``get_fieldsets`` injects it into the 'Files' fieldset on the change form (only)
8+
for all three artifact admins (Publication / Talk / Poster).
9+
10+
Attaching a thumbnail: factory artifacts carry only a *stub* PDF, so
11+
Artifact.save()'s ImageMagick step can't generate a real thumbnail. We write a
12+
valid 1x1 GIF straight to storage and point the field at it (same trick as
13+
test_thumbnail_preview.py for the #840 card), so easy_thumbnails has a real
14+
source.
15+
"""
16+
17+
from django.contrib.auth.models import User
18+
from django.core.files.base import ContentFile
19+
from django.core.files.storage import default_storage
20+
from django.urls import reverse
21+
22+
from website.models import Poster, Publication, Talk
23+
from website.admin.admin_site import ml_admin_site
24+
from website.admin.poster_admin import PosterAdmin
25+
from website.admin.publication_admin import PublicationAdmin
26+
from website.admin.talk_admin import TalkAdmin
27+
from website.tests.base import DatabaseTestCase
28+
from website.tests.factories import PosterFactory, _GIF_1PX
29+
30+
31+
class ArtifactThumbnailPreviewTests(DatabaseTestCase):
32+
def _attach_thumbnail(self, artifact):
33+
"""Give ``artifact`` a real (1x1 GIF) thumbnail without re-running
34+
Artifact.save(): write the file via default storage at the artifact's
35+
own thumbnail path, then persist the field name with an UPDATE."""
36+
rel_path = artifact.get_upload_thumbnail_dir(f"admin_preview_{artifact.pk}.gif")
37+
saved_name = default_storage.save(rel_path, ContentFile(_GIF_1PX))
38+
type(artifact).objects.filter(pk=artifact.pk).update(thumbnail=saved_name)
39+
artifact.refresh_from_db()
40+
return artifact
41+
42+
def test_preview_renders_img_when_thumbnail_present(self):
43+
poster = self._attach_thumbnail(PosterFactory(authors=[self.make_person()]))
44+
admin = PosterAdmin(Poster, ml_admin_site)
45+
46+
html = admin.thumbnail_preview(poster)
47+
48+
self.assertIn("<img", html)
49+
self.assertIn(default_storage.url(poster.thumbnail.name).rsplit("/", 1)[0], html)
50+
self.assertIn("height:220px", html)
51+
52+
def test_preview_is_placeholder_when_no_thumbnail(self):
53+
poster = PosterFactory(authors=[self.make_person()]) # stub PDF → no thumbnail
54+
admin = PosterAdmin(Poster, ml_admin_site)
55+
56+
html = admin.thumbnail_preview(poster)
57+
58+
self.assertNotIn("<img", html)
59+
self.assertIn("Save with a PDF", html)
60+
61+
def test_preview_degrades_when_source_file_missing(self):
62+
"""Thumbnail field set but the file is gone from disk (happens on the
63+
servers) → placeholder, not a 500."""
64+
poster = self._attach_thumbnail(PosterFactory(authors=[self.make_person()]))
65+
default_storage.delete(poster.thumbnail.name)
66+
67+
admin = PosterAdmin(Poster, ml_admin_site)
68+
html = admin.thumbnail_preview(poster)
69+
70+
self.assertNotIn("<img", html)
71+
self.assertIn("Save with a PDF", html)
72+
73+
def test_preview_handles_none_obj(self):
74+
admin = PosterAdmin(Poster, ml_admin_site)
75+
# get_fieldsets passes the saved obj, but be defensive.
76+
self.assertIn("Save with a PDF", admin.thumbnail_preview(None))
77+
78+
def _files_fields(self, admin, obj):
79+
for name, opts in admin.get_fieldsets(request=None, obj=obj):
80+
if name == "Files":
81+
return list(opts.get("fields", []))
82+
return []
83+
84+
def test_get_fieldsets_injects_preview_on_change_for_all_admins(self):
85+
cases = (
86+
(PublicationAdmin, Publication, self.make_publication()),
87+
(TalkAdmin, Talk, self.make_talk()),
88+
(PosterAdmin, Poster, PosterFactory(authors=[self.make_person()])),
89+
)
90+
for admin_cls, model, obj in cases:
91+
admin = admin_cls(model, ml_admin_site)
92+
change_fields = self._files_fields(admin, obj)
93+
self.assertIn(
94+
"thumbnail_preview", change_fields,
95+
f"{admin_cls.__name__} change form should show the preview",
96+
)
97+
98+
def test_get_fieldsets_omits_preview_on_add(self):
99+
admin = PublicationAdmin(Publication, ml_admin_site)
100+
add_fields = self._files_fields(admin, obj=None)
101+
self.assertNotIn("thumbnail_preview", add_fields)
102+
103+
def test_change_form_get_renders_img_end_to_end(self):
104+
"""Full change-form GET through the admin: the readonly preview must
105+
render the <img> as real (unescaped) HTML, not as escaped text."""
106+
poster = self._attach_thumbnail(PosterFactory(authors=[self.make_person()]))
107+
User.objects.create_superuser("admin", "admin@example.com", "pw")
108+
self.client.force_login(User.objects.get(username="admin"))
109+
110+
url = reverse("admin:website_poster_change", args=[poster.pk])
111+
resp = self.client.get(url)
112+
113+
self.assertEqual(resp.status_code, 200)
114+
body = resp.content.decode()
115+
self.assertIn('alt="PDF thumbnail"', body)
116+
self.assertIn("<img", body)
117+
# The tag is real markup, not escaped into visible text.
118+
self.assertNotIn("&lt;img", body)
119+
120+
def test_get_fieldsets_does_not_mutate_class_fieldsets(self):
121+
"""Injecting the preview must not leak into the shared class-level
122+
fieldsets (which would compound across requests)."""
123+
admin = TalkAdmin(Talk, ml_admin_site)
124+
admin.get_fieldsets(request=None, obj=self.make_talk())
125+
for name, opts in TalkAdmin.fieldsets:
126+
if name == "Files":
127+
self.assertNotIn("thumbnail_preview", opts["fields"])

0 commit comments

Comments
 (0)