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
18 changes: 17 additions & 1 deletion image_cropping/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,26 @@ image_cropping/
fields.py ImageRatioField (+ max_cropping helper)
thumbnail_processors.py crop_corners (easy_thumbnails processor)
admin.py ImageCroppingMixin
widgets.py CropImageWidget (Cropper.js)
widgets.py CropImageWidget (Cropper.js) + EasterEggCropImageWidget
static/image_cropping/ cropper.min.{js,css} (vendored) + ml_cropper.{js,css}
```

## Star Wars easter-egg picker (#1304)

`EasterEggCropImageWidget` (a `CropImageWidget` subclass) is used only for
`Person.easter_egg` via `PersonAdmin`. On a brand-new Person — whose easter egg
is empty — it seeds a random Star Wars LEGO figure into the cropper on load so
the editor can preview/crop/**shuffle** it before the first save, instead of
`Person.save()` assigning one invisibly. The widget embeds the figure list
(`fileutils.list_starwars_images`) as JSON and the chosen figure's basename
rides along in a hidden `easter_egg_starwars_choice` field that `PersonAdminForm`
validates and copies into the model on save. The Shuffle button is offered on
existing People too, so an editor can swap a current easter egg to a figure;
the choice field is only set by an explicit shuffle, so an untouched edit keeps
the existing image. Uploading an image always overrides the figure; with
neither, `Person.save()`'s random fallback still applies (the non-admin/bulk
path). See `website/tests/test_easter_egg_picker.py`.

## Upgrading Cropper.js

Replace `static/image_cropping/cropper.min.{js,css}` with a newer 1.x build and
Expand Down
15 changes: 15 additions & 0 deletions image_cropping/static/image_cropping/ml_cropper.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,18 @@
width: 4em !important;
min-width: 0;
}

/* Star Wars easter-egg picker (#1304). The hidden choice field carries the
chosen figure's basename to the server; its admin row is never meant to be
seen. */
.field-easter_egg_starwars_choice {
display: none !important;
}

/* "Shuffle Star Wars figure" button. Starts hidden in markup and is revealed by
ml_cropper.js only when the picker is wired up (JS available, field empty), so
non-JS users never see a dead control. */
.ml-cropper__shuffle {
margin: 8px 0 0 0;
cursor: pointer;
}
81 changes: 81 additions & 0 deletions image_cropping/static/image_cropping/ml_cropper.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
* sync with the visual crop box. With JS off, the raw ratio field still
* submits and the server seeds a sensible centered crop.
*
* Star Wars easter-egg picker (#1304): when an empty easter-egg field carries a
* figure list (from EasterEggCropImageWidget), this seeds a random default
* figure into the cropper on load, records its basename in the hidden
* easter_egg_starwars_choice field, and wires a "Shuffle" button to re-roll —
* so a new Person's easter egg can be previewed/cropped before the first save.
*
* Vanilla JS only (no jQuery/build step), per project conventions.
*/
(function () {
Expand Down Expand Up @@ -259,6 +265,81 @@
if (originalUrl) {
initCropper(originalUrl, parseBox(ratioInput.value));
}
// Easter-egg picker (#1304): seed a default figure when the field is empty
// (new Person), and in either case offer a "Shuffle" button so an existing
// easter egg can be swapped to a Star Wars figure too.
setupStarWarsPicker(fileInput, initCropper, !originalUrl);
}

/**
* Wire the Star Wars easter-egg picker onto a file input.
*
* Reads the figure list and the hidden choice field's name off the file input
* (set by EasterEggCropImageWidget). When ``seedDefault`` is true (empty field
* / new Person) it loads a random figure into the cropper up front; otherwise
* it leaves the existing image in place. Either way it reveals the "Shuffle"
* button, which loads a random figure and records its basename in the choice
* field so the server copies that figure into the model on save. Uploading a
* real file clears the choice so the upload wins.
*/
function setupStarWarsPicker(fileInput, initCropper, seedDefault) {
var raw = fileInput.getAttribute("data-starwars-images");
if (!raw) return;
var images;
try {
images = JSON.parse(raw);
} catch (e) {
images = [];
}
if (!images.length) return;

var form = fileInput.closest("form") || document;
var choiceName = fileInput.getAttribute("data-starwars-choice-field");
var choiceInput = choiceName
? form.querySelector('[name="' + choiceName + '"]')
: null;

var current = null;

function pickRandom() {
var next = images[Math.floor(Math.random() * images.length)];
// Avoid landing on the same figure twice in a row when we can.
if (images.length > 1 && current && next.name === current.name) {
return pickRandom();
}
return next;
}

function show(figure) {
current = figure;
if (choiceInput) choiceInput.value = figure.name;
initCropper(figure.url, null);
}

// Only auto-seed when the field is empty; an existing easter egg stays put
// until the editor actively shuffles or uploads.
if (seedDefault) show(pickRandom());

// Reveal + wire the Shuffle button (rendered hidden so non-JS users, who
// can't preview anyway, never see a dead control).
var fileRow =
fileInput.closest(".form-row, .field-easter_egg") || fileInput.parentNode;
var shuffleBtn = fileRow.querySelector("[data-starwars-shuffle]");
if (shuffleBtn) {
shuffleBtn.hidden = false;
shuffleBtn.addEventListener("click", function () {
show(pickRandom());
});
}

// If the editor uploads their own file, drop the Star Wars choice so the
// server keeps the upload instead of overwriting it with a figure.
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files[0] && choiceInput) {
choiceInput.value = "";
current = null;
}
});
}

function scan(root) {
Expand Down
45 changes: 45 additions & 0 deletions image_cropping/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
only the ``"x1,y1,x2,y2"`` box (in the sibling ratio field) is saved.
"""

import json

from django import forms
from django.contrib.admin.widgets import AdminFileWidget
from django.utils.safestring import mark_safe


class CropImageWidget(AdminFileWidget):
Expand Down Expand Up @@ -37,3 +40,45 @@ def render(self, name, value, attrs=None, renderer=None):
# No file associated (e.g. cleared) -> nothing to preview.
pass
return super().render(name, value, attrs, renderer)


class EasterEggCropImageWidget(CropImageWidget):
"""Crop widget for ``Person.easter_egg`` with a Star Wars figure picker.

Extends :class:`CropImageWidget` so an editor can preview, crop, and
*shuffle* a default Star Wars LEGO figure on a brand-new Person — before the
first save — instead of waiting for ``Person.save()`` to assign one
invisibly (issue #1304).

It advertises the available figures to ``ml_cropper.js`` via two attrs on
the file input:

* ``data-starwars-images`` — a JSON array of ``{"name", "url"}`` objects.
* ``data-starwars-choice-field`` — the name of the sibling hidden field
(``easter_egg_starwars_choice``) into which the JS writes the chosen
basename, so the server can copy that figure into the field on save.

The list is supplied by the admin via ``widget.starwars_images`` (kept out
of the widget so the widget stays free of model/filesystem imports).
"""

#: Set by the admin before rendering: list of {"name", "url"} dicts.
starwars_images = ()

#: Name of the sibling hidden field the JS writes the chosen basename into.
choice_field_name = "easter_egg_starwars_choice"

def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {}
attrs["data-starwars-images"] = json.dumps(list(self.starwars_images))
attrs["data-starwars-choice-field"] = self.choice_field_name
html = super().render(name, value, attrs, renderer)
# A real <button> (not a link) so keyboard/SR users get correct
# semantics; type="button" keeps it from submitting the admin form.
# ml_cropper.js finds it relative to the file input and wires it up.
shuffle = (
'<button type="button" class="ml-cropper__shuffle" '
'data-starwars-shuffle hidden>'
"\U0001f3b2 Shuffle Star Wars figure</button>"
)
return mark_safe(html + shuffle)
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@
ALLOWED_HOSTS = ['*']

# Makeability Lab Global Variables, including Makeability Lab version
ML_WEBSITE_VERSION = "2.7.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Modernized the admin image cropper (#1299). Replaced the end-of-life django-image-cropping (Jcrop/jQuery, Django <=4.0) with an in-repo Cropper.js widget that previews and crops images client-side, before the first save — no more 'upload, save, scroll up, then crop'. It shows a live preview of the cropped result and tucks precise pixel controls into a collapsed disclosure. The crop data layer is intentionally unchanged (ImageRatioField stores the box, easy_thumbnails renders it), so every existing crop and thumbnail renders identically and the swap is migration-neutral. Dropping this fragile, unmaintained dependency also helps unblock the future Django 6.1 LTS upgrade (#1269)."
ML_WEBSITE_VERSION = "2.8.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Admin: preview, crop, and shuffle the Star Wars easter-egg figure in the Person cropper (#1304). Previously the easter-egg cropper was blank on a new Person and Person.save() assigned a random figure invisibly. Now an editor sees a default figure in the cropper on load, can shuffle to another or upload their own, and whatever is shown — plus its crop box — is what persists. An existing easter egg can be swapped to a figure the same way (or replaced with an upload). Builds on the Cropper.js widget (#1299); the random fallback still applies to non-admin/bulk creation."
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
MAX_BANNERS = 7 # Maximum number of banners on a page

Expand Down
99 changes: 98 additions & 1 deletion website/admin/person_admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from django import forms
from django.contrib import admin
from django.core.files import File
from website.models import Position, Person, ProjectRole
from website.models.position import Title
from website.models.person import PERSON_THUMBNAIL_SIZE
from easy_thumbnails.exceptions import InvalidImageFormatError # for handling invalid images
from website.admin_list_filters import PositionRoleListFilter, PositionTitleListFilter
from website.admin.utils import get_active_professors_queryset, get_active_mentors_queryset
from image_cropping import ImageCroppingMixin
from image_cropping.widgets import EasterEggCropImageWidget
import website.utils.fileutils as ml_fileutils

from django.utils.html import format_html # for formatting thumbnails
from easy_thumbnails.files import get_thumbnailer # for generating thumbnails
Expand All @@ -16,6 +20,75 @@
import logging
_logger = logging.getLogger(__name__)


class PersonAdminForm(forms.ModelForm):
"""Person admin form that lets editors pick a default easter-egg figure.

The Star Wars easter-egg picker (#1304) writes the chosen figure's basename
into the hidden ``easter_egg_starwars_choice`` field. On save, when the
editor shuffled to a figure and didn't also upload their own image, we copy
that chosen figure into ``Person.easter_egg`` so the previewed image (and its
crop box) is exactly what persists. This applies whether the field was empty
(new Person) or already had an image (an editor swapping their easter egg) —
the field is only populated by an explicit shuffle, so an untouched edit
keeps the existing image, and an empty-and-untouched field still falls
through to ``Person.save()``'s random pick (the non-admin/bulk path).

The choice is validated against :func:`fileutils.list_starwars_images`, so a
crafted value can't read an arbitrary file off disk.
"""

# Not a model field: a browser-set hint for which Star Wars figure to use.
easter_egg_starwars_choice = forms.CharField(
required=False, widget=forms.HiddenInput
)

class Meta:
model = Person
fields = "__all__"

def clean_easter_egg_starwars_choice(self):
"""Reject anything that isn't a known Star Wars figure basename."""
choice = (self.cleaned_data.get("easter_egg_starwars_choice") or "").strip()
if not choice:
return ""
# os.path.basename guards against path components; the membership check
# is the real gate (only figures we actually ship are accepted).
if os.path.basename(choice) != choice or choice not in ml_fileutils.list_starwars_images():
raise forms.ValidationError("Unknown Star Wars figure.")
return choice

def save(self, commit=True):
person = super().save(commit=False)

# Copy the chosen figure into easter_egg when the editor shuffled to one
# and didn't also upload their own image (upload always wins). The choice
# field is only set by an explicit shuffle, so this both seeds a new
# Person's default and lets an existing one swap figures; an untouched
# field leaves easter_egg alone. self.files holds uploads.
choice = self.cleaned_data.get("easter_egg_starwars_choice")
uploaded = self.files.get(self.add_prefix("easter_egg"))
if choice and not uploaded:
src_path = os.path.join(ml_fileutils.get_starwars_image_dir(), choice)
# Person.save() reads the file during super().save(), so keep the
# handle open until after the model is saved (mirrors the random
# fallback pattern in Person.save()).
fh = open(src_path, "rb")
self._easter_egg_fh = fh
person.easter_egg = File(fh, name=choice)

if commit:
person.save()
self.save_m2m()
self._close_easter_egg_fh()
return person

def _close_easter_egg_fh(self):
fh = getattr(self, "_easter_egg_fh", None)
if fh is not None:
fh.close()
self._easter_egg_fh = None

class PositionInline(admin.StackedInline):

# This line specifies that the inline model is the Position model.
Expand Down Expand Up @@ -64,13 +137,37 @@ class ProjectRoleInline(admin.StackedInline):

@admin.register(Person, site=ml_admin_site)
class PersonAdmin(ImageCroppingMixin, admin.ModelAdmin):
form = PersonAdminForm

fieldsets = [
(None, {'fields': ['first_name', 'middle_name', 'last_name', 'image', 'cropping', 'easter_egg', 'easter_egg_crop']}),
(None, {'fields': ['first_name', 'middle_name', 'last_name', 'image', 'cropping', 'easter_egg', 'easter_egg_crop', 'easter_egg_starwars_choice']}),
('Bio', {'fields': ['bio', 'personal_website', 'github']}),
('Socials', {'fields': ['twitter', 'threads', 'mastodon', 'linkedin']}),
('For Alumni (Next Position)', {'fields': ['next_position', 'next_position_url']}),
]

def formfield_for_dbfield(self, db_field, request, **kwargs):
"""Give easter_egg the Star Wars picker widget (preview/shuffle, #1304).

The headshot ``image`` field keeps the plain crop widget — only the
easter egg gets a default-on-load figure the editor can shuffle.
"""
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
if db_field.name == 'easter_egg' and formfield is not None:
widget = EasterEggCropImageWidget()
widget.starwars_images = [
{'name': name, 'url': ml_fileutils.get_starwars_image_url(name)}
for name in ml_fileutils.list_starwars_images()
]
formfield.widget = widget
return formfield

def save_model(self, request, obj, form, change):
"""Persist, then release the easter-egg figure file handle (if any)."""
super().save_model(request, obj, form, change)
if hasattr(form, '_close_easter_egg_fh'):
form._close_easter_egg_fh()

exclude = ('bio_datetime_modified',) # don't show this field as it's auto-calculated

# inlines allow us to edit models on the same page as a parent model
Expand Down
Loading
Loading