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( + 'PDF thumbnail', + 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("