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)