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
27 changes: 23 additions & 4 deletions website/admin/award_admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import os

from django import forms
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from easy_thumbnails.files import get_thumbnailer
from image_cropping import ImageCroppingMixin
from website.models import Award
from website.admin.admin_site import ml_admin_site
from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple
Expand All @@ -28,12 +32,12 @@ def clean(self):


@admin.register(Award, site=ml_admin_site)
class AwardAdmin(admin.ModelAdmin):
class AwardAdmin(ImageCroppingMixin, admin.ModelAdmin):
form = AwardAdminForm

# get_recipient_names / get_project_names are methods on the Award model;
# their column headers come from each method's short_description.
list_display = ('title', 'organization', 'date',
list_display = ('title', 'get_display_thumbnail', 'organization', 'date',
'get_recipient_names', 'get_project_names', 'award_type')

list_filter = ('award_type', 'date')
Expand Down Expand Up @@ -80,7 +84,7 @@ def get_fieldsets(self, request, obj=None):
'fields': ['url', 'description'],
}),
('Display', {
'fields': ['badge', 'badge_alt_text'],
'fields': ['badge', 'badge_cropping', 'badge_alt_text'],
'description': 'Optional. On the Awards page, faculty honors show a medal icon, '
'student awards show the recipient’s photo, and project awards '
'show the project thumbnail. Upload a badge/logo here to override '
Expand All @@ -97,4 +101,19 @@ def formfield_for_manytomany(self, db_field, request, **kwargs):
"""
if db_field.name == 'recipients' or db_field.name == 'projects':
kwargs['widget'] = SortedFilteredSelectMultiple()
return super().formfield_for_manytomany(db_field, request, **kwargs)
return super().formfield_for_manytomany(db_field, request, **kwargs)

def get_display_thumbnail(self, obj):
"""Square preview of the uploaded badge (with its crop applied) in the
changelist, mirroring the SponsorAdmin/NewsAdmin logo columns. Awards
without a custom badge fall back to a medal icon on the public page, so
there's nothing to show here."""
if obj.badge and os.path.isfile(obj.badge.path):
thumbnailer = get_thumbnailer(obj.badge)
options = {'size': (50, 50), 'crop': True, 'box': obj.badge_cropping}
thumbnail_url = thumbnailer.get_thumbnail(options).url
return format_html('<img src="{}" height="50" width="50" '
'style="object-fit: cover; border-radius: 5%;"/>', thumbnail_url)
return '—'

get_display_thumbnail.short_description = 'Badge'
10 changes: 10 additions & 0 deletions website/models/award.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.db import models
from sortedm2m.fields import SortedManyToManyField

from image_cropping import ImageRatioField

from website.utils.fileutils import UniquePathAndRename
from website.utils.upload_validators import validate_image_upload

Expand Down Expand Up @@ -72,6 +74,14 @@ class Award(models.Model):
"Student awards default to the recipient's photo and project awards to "
"the project thumbnail; uploading a badge overrides those.")

# Square crop box for the badge, applied on the public Awards page so every
# anchor (badge, portrait, project thumbnail, medal) reads as a uniform square
# tile. Stored as an "x1,y1,x2,y2" string; the admin shows a Cropper.js preview
# before the first save (same pattern as Person.cropping / Sponsor.icon_cropping).
badge_cropping = ImageRatioField('badge', '245x245', size_warning=True)
badge_cropping.help_text = ("Crop the badge to a square using the preview above "
"(no need to save first). Keeps award anchors uniform.")

badge_alt_text = models.CharField(max_length=255, blank=True, null=True)
badge_alt_text.help_text = "Alt text for the badge image. Defaults to the award title if left blank."

Expand Down
7 changes: 4 additions & 3 deletions website/static/website/css/awards.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@
border-radius: 50%;
}

/* Uploaded emblem/logo: show whole (don't crop) and let it sit on the page. */
/* Uploaded emblem/logo: editors square-crop it in the admin (Award.badge_cropping),
so it fills the same square tile as the portrait/thumbnail anchors for a uniform
row. The shared .award-anchor-img rules already supply object-fit: cover + border. */
.award-anchor-badge {
object-fit: contain;
border: none;
border-radius: var(--border-radius-md);
}

/* Faculty honors: medal icon in a soft circular chip. */
Expand Down
3 changes: 2 additions & 1 deletion website/templates/snippets/display_award_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@

<div class="award-anchor" aria-hidden="true">
{% if kind == 'badge' %}
<img class="award-anchor-img award-anchor-badge" src="{{ award.badge.url }}" alt="">
<img class="award-anchor-img award-anchor-badge"
src="{% thumbnail award.badge 245x245 box=award.badge_cropping crop upscale %}" alt="">
{% elif kind == 'portrait' %}
{% with person=award.get_portrait_person %}
<img class="award-anchor-img award-anchor-portrait"
Expand Down
20 changes: 20 additions & 0 deletions website/tests/test_image_cropping.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ def test_person_exposes_crop_fields(self):
self.assertIn("cropping", Person.ratio_fields)
self.assertIn("easter_egg_crop", Person.ratio_fields)

def test_award_badge_exposes_crop_fields(self):
"""Award.badge gained a square crop so its public anchor stays uniform."""
from website.models import Award

self.assertIn("badge", Award.crop_fields)
self.assertIn("badge_cropping", Award.ratio_fields)


# --- Admin uses the Cropper.js widget, not Jcrop ---------------------------

Expand Down Expand Up @@ -206,3 +213,16 @@ def test_person_admin_image_field_uses_crop_widget(self):
db_field = Person._meta.get_field("image")
formfield = admin_obj.formfield_for_dbfield(db_field, request)
self.assertIsInstance(formfield.widget, CropImageWidget)

def test_award_admin_badge_field_uses_crop_widget(self):
from django.contrib.auth.models import AnonymousUser
from image_cropping.widgets import CropImageWidget
from website.models import Award
from website.admin.admin_site import ml_admin_site

admin_obj = ml_admin_site._registry[Award]
request = MagicMock()
request.user = AnonymousUser()
db_field = Award._meta.get_field("badge")
formfield = admin_obj.formfield_for_dbfield(db_field, request)
self.assertIsInstance(formfield.widget, CropImageWidget)
Loading