Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 131 additions & 37 deletions website/admin/banner_admin.py
Original file line number Diff line number Diff line change
@@ -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('<a href="{}">{}</a>', media_url, media_url) if media_url else None

get_media_url.short_description = 'Media'
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(
'<img src="{}" width="160" height="50" alt="" '
'style="object-fit:cover;border-radius:3px;" />', thumb.url)
except Exception:
return format_html('<span style="color:#c00;">image error</span>')
if obj.video:
return format_html('<span title="Background video">πŸŽ₯ video</span>')
return format_html('<span style="color:#999;">β€”</span>')
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('<a href="{}">{}</a>', 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.')
9 changes: 0 additions & 9 deletions website/models/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<img src="%s" height="100"/>' % (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}"

Expand Down
73 changes: 73 additions & 0 deletions website/tests/test_banner_admin.py
Original file line number Diff line number Diff line change
@@ -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)
Loading