diff --git a/website/admin/banner_admin.py b/website/admin/banner_admin.py
index fb1a9622..225c1890 100644
--- a/website/admin/banner_admin.py
+++ b/website/admin/banner_admin.py
@@ -1,37 +1,131 @@
-from django.contrib import admin
-from website.models import Banner
-from django.utils.html import format_html
-from image_cropping import ImageCroppingMixin
-from website.admin.admin_site import ml_admin_site
-
-@admin.register(Banner, site=ml_admin_site)
-class BannerAdmin(ImageCroppingMixin, admin.ModelAdmin):
-
- # In Django, you can specify the order of fields using one of two methods:
- # - fields, a list of fields you want to display in order
- # - fieldsets, allows you to organize fields into sets
- fieldsets = [
- ('Banner Title and Caption', {'fields': ["title", "caption", "link"]}),
- ('Banner Video', {'fields': ["video"]}),
- ('Banner Image', {'fields': ["image", "alt_text", "cropping"]}),
- ('Banner Pages', {'fields': ["landing_page", "project"]}),
- ('Banner Properties', {'fields': ["favorite", "date_added"]})
- # ('Image', {'fields': ["image", "image_preview"]})
- # ('Image', {'fields': ["image", "cropping"]})
- ]
-
- # The list display lets us control what is shown in the default persons table at Home > Website > Banners
- # info on displaying multiple entries comes from http://stackoverflow.com/questions/9164610/custom-columns-using-django-admin
- list_display = ('title', 'project', 'landing_page', 'favorite', 'get_media_url')
-
- autocomplete_fields = ['project']
- readonly_fields = ('date_added',)
-
- # readonly_fields = ["image_preview"]
-
- def get_media_url(self, obj):
- """Either returns the video url or the image url, if specified"""
- media_url = obj.image.url if obj.image else obj.video.url if obj.video else None
- return format_html('{}', media_url, media_url) if media_url else None
-
- get_media_url.short_description = 'Media'
\ No newline at end of file
+from django.contrib import admin
+from django.db.models import Q
+from django.utils.html import format_html
+from easy_thumbnails.files import get_thumbnailer
+from image_cropping import ImageCroppingMixin
+
+from website.models import Banner
+from website.admin.admin_site import ml_admin_site
+
+
+class MediaTypeFilter(admin.SimpleListFilter):
+ """Right-sidebar filter to narrow banners by what media they carry.
+
+ With ~200 banners, it's useful to isolate, e.g., the ones still missing
+ an image/video ("No media") or the video banners. A banner can have both
+ an image and a video, so "Has image" / "Has video" are not mutually
+ exclusive; "No media" means neither is set.
+ """
+ title = 'media type'
+ parameter_name = 'media_type'
+
+ def lookups(self, request, model_admin):
+ return (
+ ('image', 'Has image'),
+ ('video', 'Has video'),
+ ('none', 'No media'),
+ )
+
+ def queryset(self, request, queryset):
+ return self.filter_queryset(queryset, self.value())
+
+ @staticmethod
+ def filter_queryset(queryset, value):
+ """Apply the media-type filter. Split out so it's unit-testable
+ without constructing a full admin request."""
+ no_image = Q(image='') | Q(image__isnull=True)
+ no_video = Q(video='') | Q(video__isnull=True)
+ if value == 'image':
+ return queryset.exclude(no_image)
+ if value == 'video':
+ return queryset.exclude(no_video)
+ if value == 'none':
+ return queryset.filter(no_image & no_video)
+ return queryset
+
+
+@admin.register(Banner, site=ml_admin_site)
+class BannerAdmin(ImageCroppingMixin, admin.ModelAdmin):
+
+ # In Django, you can specify the order of fields using one of two methods:
+ # - fields, a list of fields you want to display in order
+ # - fieldsets, allows you to organize fields into sets
+ fieldsets = [
+ ('Banner Title and Caption', {'fields': ["title", "caption", "link"]}),
+ ('Banner Video', {'fields': ["video"]}),
+ ('Banner Image', {'fields': ["image", "alt_text", "cropping"]}),
+ ('Banner Pages', {'fields': ["landing_page", "project"]}),
+ ('Banner Properties', {'fields': ["favorite", "date_added"]})
+ ]
+
+ # The list page is the main tool for managing the ~200 banners on prod, so
+ # it leads with a visual thumbnail and exposes inline toggles (#1082).
+ list_display = ('thumbnail', 'title', 'project', 'landing_page', 'favorite',
+ 'date_added', 'get_media_url')
+ list_display_links = ('title',)
+ list_editable = ('landing_page', 'favorite')
+
+ # Right-sidebar filters + date drill-down + search (the asks in #1082).
+ list_filter = ('landing_page', 'favorite', MediaTypeFilter, 'project')
+ search_fields = ('title', 'caption', 'project__name', 'alt_text', 'link')
+ date_hierarchy = 'date_added'
+ ordering = ('-date_added',)
+ list_per_page = 50
+
+ autocomplete_fields = ['project']
+ readonly_fields = ('date_added',)
+
+ actions = ('add_to_landing_page', 'remove_from_landing_page',
+ 'mark_favorite', 'unmark_favorite')
+
+ # 1600x500 is the banner's native aspect ratio; keep the list thumbnail
+ # proportional so editors can recognize a banner at a glance.
+ _THUMB_SIZE = (160, 50)
+
+ def thumbnail(self, obj):
+ """Small, cheap preview for the changelist. Uses easy_thumbnails so we
+ don't ship 200 full-size banner images to the admin list page, and
+ respects the editor-defined crop box."""
+ if obj.image:
+ try:
+ thumb = get_thumbnailer(obj.image).get_thumbnail({
+ 'size': self._THUMB_SIZE,
+ 'box': obj.cropping,
+ 'crop': True,
+ 'detail': True,
+ })
+ return format_html(
+ '
', thumb.url)
+ except Exception:
+ return format_html('image error')
+ if obj.video:
+ return format_html('🎥 video')
+ return format_html('—')
+ thumbnail.short_description = 'Preview'
+
+ def get_media_url(self, obj):
+ """Either returns the video url or the image url, if specified"""
+ media_url = obj.image.url if obj.image else obj.video.url if obj.video else None
+ return format_html('{}', media_url, media_url) if media_url else None
+ get_media_url.short_description = 'Media'
+
+ @admin.action(description='Add selected banners to the landing page')
+ def add_to_landing_page(self, request, queryset):
+ updated = queryset.update(landing_page=True)
+ self.message_user(request, f'{updated} banner(s) added to the landing page.')
+
+ @admin.action(description='Remove selected banners from the landing page')
+ def remove_from_landing_page(self, request, queryset):
+ updated = queryset.update(landing_page=False)
+ self.message_user(request, f'{updated} banner(s) removed from the landing page.')
+
+ @admin.action(description='Mark selected banners as favorite')
+ def mark_favorite(self, request, queryset):
+ updated = queryset.update(favorite=True)
+ self.message_user(request, f'{updated} banner(s) marked as favorite.')
+
+ @admin.action(description='Unmark selected banners as favorite')
+ def unmark_favorite(self, request, queryset):
+ updated = queryset.update(favorite=False)
+ self.message_user(request, f'{updated} banner(s) unmarked as favorite.')
diff --git a/website/models/banner.py b/website/models/banner.py
index b25728f6..64b4341c 100644
--- a/website/models/banner.py
+++ b/website/models/banner.py
@@ -49,15 +49,6 @@ class Banner(models.Model):
date_added = models.DateField(auto_now_add=True)
date_added.help_text = "This is an automatically set, readonly field. When there are many banners specified for a page, we prioritize more recently added banners"
- def admin_thumbnail(self):
- if self.image:
- return u'
' % (self.image.url)
- else:
- return "No image found"
-
- admin_thumbnail.short_description = 'Thumbnail'
- admin_thumbnail.allow_tags = True
-
def __str__(self):
return f"Title={self.title} Project={self.project} LandingPage={self.landing_page} Favorite={self.favorite}"
diff --git a/website/tests/test_banner_admin.py b/website/tests/test_banner_admin.py
new file mode 100644
index 00000000..919040fb
--- /dev/null
+++ b/website/tests/test_banner_admin.py
@@ -0,0 +1,73 @@
+"""
+Regression tests for the Banner admin list-management tooling (#1082).
+
+With ~200 banners on production, the changelist gained a visual thumbnail,
+right-sidebar filters (including a custom media-type filter), inline toggles,
+and bulk actions. These tests pin the pieces that carry logic — the thumbnail
+renderer and the media-type filter — plus the static admin configuration the
+features depend on.
+"""
+
+from website.models import Banner
+from website.admin.admin_site import ml_admin_site
+from website.admin.banner_admin import BannerAdmin, MediaTypeFilter
+from website.tests.base import DatabaseTestCase
+
+
+class BannerAdminThumbnailTests(DatabaseTestCase):
+ """BannerAdmin.thumbnail() renders the right cell for each media state."""
+
+ def setUp(self):
+ self.admin = BannerAdmin(Banner, ml_admin_site)
+
+ def test_no_media_renders_dash(self):
+ banner = Banner.objects.create(title="Empty")
+ self.assertIn('—', self.admin.thumbnail(banner))
+
+ def test_video_only_renders_video_indicator(self):
+ banner = Banner.objects.create(
+ title="Video", video='banner/videos/clip.mp4')
+ self.assertIn('video', self.admin.thumbnail(banner).lower())
+
+
+class BannerMediaTypeFilterTests(DatabaseTestCase):
+ """MediaTypeFilter.filter_queryset partitions banners by media."""
+
+ def setUp(self):
+ self.image_banner = Banner.objects.create(
+ title="Image", image='banner/pic.jpg')
+ self.video_banner = Banner.objects.create(
+ title="Video", video='banner/videos/clip.mp4')
+ self.empty_banner = Banner.objects.create(title="Empty")
+
+ def _filtered(self, value):
+ return set(MediaTypeFilter.filter_queryset(Banner.objects.all(), value))
+
+ def test_image_filter(self):
+ self.assertEqual(self._filtered('image'), {self.image_banner})
+
+ def test_video_filter(self):
+ self.assertEqual(self._filtered('video'), {self.video_banner})
+
+ def test_none_filter(self):
+ self.assertEqual(self._filtered('none'), {self.empty_banner})
+
+ def test_no_value_returns_all(self):
+ self.assertEqual(
+ self._filtered(None),
+ {self.image_banner, self.video_banner, self.empty_banner},
+ )
+
+
+class BannerAdminConfigTests(DatabaseTestCase):
+ """The list page exposes the search/filter/inline-edit tooling from #1082."""
+
+ def test_changelist_tooling_configured(self):
+ admin = BannerAdmin(Banner, ml_admin_site)
+ self.assertEqual(admin.date_hierarchy, 'date_added')
+ self.assertIn(MediaTypeFilter, admin.list_filter)
+ self.assertIn('project__name', admin.search_fields)
+ self.assertEqual(set(admin.list_editable), {'landing_page', 'favorite'})
+ # list_editable fields must not be the row link, or Django errors.
+ self.assertNotIn('title', admin.list_editable)
+ self.assertIn('title', admin.list_display_links)