From 6f28d8b23d6468a016a5d3847559b1d966094a40 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 15 Jun 2026 14:19:35 -0700 Subject: [PATCH] Admin: preview, crop & shuffle the easter-egg figure in the Person cropper (#1304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Person has no easter_egg image, so the Cropper.js widget was blank and Person.save() assigned a random Star Wars figure invisibly — no way to preview, re-roll, or crop it up front. This wires a figure picker into the easter-egg cropper: - New EasterEggCropImageWidget (CropImageWidget subclass) embeds the figure list as JSON and renders an accessible "Shuffle" button. - ml_cropper.js seeds a random figure on an empty field (new Person), reveals Shuffle on every Person (so an existing easter egg can be swapped too), and clears the choice when the editor uploads their own image (upload wins). - PersonAdminForm copies the chosen figure into easter_egg on save when the editor shuffled and didn't upload; the hidden choice field is validated against the known figure list (guards path traversal). The crop box persists via the existing easter_egg_crop ratio field. - Person.save()'s random fallback is unchanged for the untouched/non-admin path. - fileutils.list_starwars_images() is the single source of truth; the random picker now draws from it. Tests: website/tests/test_easter_egg_picker.py covers all three save paths (chosen / shuffled-on-existing / uploaded), the random fallback, untouched-edit preservation, and the path-traversal rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- image_cropping/README.md | 18 +- .../static/image_cropping/ml_cropper.css | 15 ++ .../static/image_cropping/ml_cropper.js | 81 +++++++ image_cropping/widgets.py | 45 ++++ makeabilitylab/settings.py | 4 +- website/admin/person_admin.py | 99 ++++++++- website/tests/test_easter_egg_picker.py | 204 ++++++++++++++++++ website/utils/fileutils.py | 66 ++++-- 8 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 website/tests/test_easter_egg_picker.py diff --git a/image_cropping/README.md b/image_cropping/README.md index 5137f09e..0cab9d51 100644 --- a/image_cropping/README.md +++ b/image_cropping/README.md @@ -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 diff --git a/image_cropping/static/image_cropping/ml_cropper.css b/image_cropping/static/image_cropping/ml_cropper.css index 21f16423..1980e51c 100644 --- a/image_cropping/static/image_cropping/ml_cropper.css +++ b/image_cropping/static/image_cropping/ml_cropper.css @@ -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; +} diff --git a/image_cropping/static/image_cropping/ml_cropper.js b/image_cropping/static/image_cropping/ml_cropper.js index 9e9d8da3..2be28478 100644 --- a/image_cropping/static/image_cropping/ml_cropper.js +++ b/image_cropping/static/image_cropping/ml_cropper.js @@ -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 () { @@ -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) { diff --git a/image_cropping/widgets.py b/image_cropping/widgets.py index b6947537..72724364 100644 --- a/image_cropping/widgets.py +++ b/image_cropping/widgets.py @@ -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): @@ -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 " + ) + return mark_safe(html + shuffle) diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index 63ae95b6..a468816e 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -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 diff --git a/website/admin/person_admin.py b/website/admin/person_admin.py index 6ebb4f8d..2c00409e 100644 --- a/website/admin/person_admin.py +++ b/website/admin/person_admin.py @@ -1,4 +1,6 @@ +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 @@ -6,6 +8,8 @@ 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 @@ -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. @@ -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 diff --git a/website/tests/test_easter_egg_picker.py b/website/tests/test_easter_egg_picker.py new file mode 100644 index 00000000..6a34d059 --- /dev/null +++ b/website/tests/test_easter_egg_picker.py @@ -0,0 +1,204 @@ +""" +Tests for the Star Wars easter-egg picker in the Person admin (#1304). + +A brand-new Person has no ``easter_egg`` image, so historically the Cropper.js +widget had nothing to show until ``Person.save()`` assigned a *random* figure +server-side — invisible and un-croppable until after the first save. The picker +lets an editor preview / crop / shuffle a default figure up front; the chosen +figure's basename rides along in a hidden ``easter_egg_starwars_choice`` field +that ``PersonAdminForm`` copies into the model on save. + +These tests pin that default-vs-chosen logic and its security gate: + +1. The figure list (``fileutils.list_starwars_images``) is the single source of + truth and the random picker draws from it. +2. A valid choice is copied into ``easter_egg`` when nothing was uploaded. +3. An uploaded image always wins over a choice. +4. With neither, ``Person.save()``'s random fallback still fires (preserving the + original, non-admin/bulk behavior). +5. A crafted choice (path traversal / unknown name) is rejected. +""" + +import os + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, TestCase + +import website.utils.fileutils as ml_fileutils +from website.tests.base import _GIF_1PX + + +# --- figure listing (single source of truth) ------------------------------- + + +class StarWarsImageListingTests(SimpleTestCase): + """The figure list backs both the random default and the admin picker.""" + + def test_list_is_nonempty_and_all_images(self): + images = ml_fileutils.list_starwars_images() + self.assertTrue(images, "expected Star Wars figures under media/") + self.assertTrue(all(ml_fileutils.is_image(name) for name in images)) + + def test_random_default_is_drawn_from_the_list(self): + path = ml_fileutils.get_path_to_random_starwars_image() + self.assertIn( + os.path.basename(path), ml_fileutils.list_starwars_images() + ) + + def test_image_url_points_into_the_figure_directory(self): + name = ml_fileutils.list_starwars_images()[0] + url = ml_fileutils.get_starwars_image_url(name) + self.assertTrue(url.startswith(settings.MEDIA_URL)) + self.assertIn("StarWarsFiguresFullSquare/Rebels/", url) + self.assertTrue(url.endswith(name)) + + def test_image_url_strips_path_components(self): + # Even if handed a traversal-ish value, the URL stays in the directory. + url = ml_fileutils.get_starwars_image_url("../../secret.png") + self.assertTrue(url.endswith("/secret.png")) + self.assertNotIn("..", url) + + +# --- PersonAdminForm default-vs-chosen logic ------------------------------- + + +class EasterEggChoiceFormTests(TestCase): + """Exercises ``PersonAdminForm.save`` across the three easter-egg paths.""" + + def setUp(self): + # Person.save() writes the headshot + easter egg into media/person/; + # track and remove them so tests don't litter the bind-mounted media. + self._created_files = [] + + def tearDown(self): + for path in self._created_files: + try: + os.remove(path) + except OSError: + pass + + def _track(self, person): + for filefield in (person.image, person.easter_egg): + if filefield: + try: + self._created_files.append(filefield.path) + except (ValueError, NotImplementedError): + pass + return person + + def _data(self, **extra): + data = {"first_name": "Egg", "last_name": "Picker"} + data.update(extra) + return data + + def test_chosen_figure_is_copied_when_no_upload(self): + from website.admin.person_admin import PersonAdminForm + + choice = ml_fileutils.list_starwars_images()[0] + form = PersonAdminForm(data=self._data(easter_egg_starwars_choice=choice)) + self.assertTrue(form.is_valid(), form.errors) + person = self._track(form.save()) + + self.assertTrue(person.easter_egg) + # The saved easter egg is a byte-for-byte copy of the chosen figure... + src = os.path.join(ml_fileutils.get_starwars_image_dir(), choice) + with open(src, "rb") as original: + person.easter_egg.open("rb") + try: + self.assertEqual(person.easter_egg.read(), original.read()) + finally: + person.easter_egg.close() + # ...and a crop box was seeded (by ImageRatioField.initial_cropping). + self.assertTrue(person.easter_egg_crop) + + def test_existing_easter_egg_can_be_swapped_to_a_figure(self): + """An editor can shuffle a figure onto a Person that already has one.""" + from website.admin.person_admin import PersonAdminForm + from website.models import Person + + person = self._track( + Person.objects.create( + first_name="Egg", + last_name="Picker", + image=SimpleUploadedFile("h.gif", _GIF_1PX, content_type="image/gif"), + easter_egg=SimpleUploadedFile( + "old.gif", _GIF_1PX, content_type="image/gif" + ), + ) + ) + + choice = ml_fileutils.list_starwars_images()[0] + form = PersonAdminForm( + data=self._data(easter_egg_starwars_choice=choice), instance=person + ) + self.assertTrue(form.is_valid(), form.errors) + updated = self._track(form.save()) + + src = os.path.join(ml_fileutils.get_starwars_image_dir(), choice) + with open(src, "rb") as original: + updated.easter_egg.open("rb") + try: + self.assertEqual(updated.easter_egg.read(), original.read()) + finally: + updated.easter_egg.close() + + def test_existing_easter_egg_kept_when_picker_untouched(self): + """Editing a Person without shuffling leaves the easter egg unchanged.""" + from website.admin.person_admin import PersonAdminForm + from website.models import Person + + person = self._track( + Person.objects.create( + first_name="Egg", + last_name="Picker", + image=SimpleUploadedFile("h.gif", _GIF_1PX, content_type="image/gif"), + easter_egg=SimpleUploadedFile( + "old.gif", _GIF_1PX, content_type="image/gif" + ), + ) + ) + original_name = person.easter_egg.name + + form = PersonAdminForm(data=self._data(bio="updated"), instance=person) + self.assertTrue(form.is_valid(), form.errors) + updated = form.save() + self.assertEqual(updated.easter_egg.name, original_name) + + def test_upload_wins_over_choice(self): + from website.admin.person_admin import PersonAdminForm + + choice = ml_fileutils.list_starwars_images()[0] + upload = SimpleUploadedFile("mine.gif", _GIF_1PX, content_type="image/gif") + form = PersonAdminForm( + data=self._data(easter_egg_starwars_choice=choice), + files={"easter_egg": upload}, + ) + self.assertTrue(form.is_valid(), form.errors) + person = self._track(form.save()) + + # The saved file is the uploaded GIF, not the (larger) Star Wars figure. + person.easter_egg.open("rb") + try: + self.assertEqual(person.easter_egg.read(), _GIF_1PX) + finally: + person.easter_egg.close() + + def test_random_fallback_when_untouched(self): + """No upload + no choice -> Person.save() still assigns a figure.""" + from website.admin.person_admin import PersonAdminForm + + form = PersonAdminForm(data=self._data()) + self.assertTrue(form.is_valid(), form.errors) + person = self._track(form.save()) + self.assertTrue(person.easter_egg) + + def test_unknown_choice_is_rejected(self): + from website.admin.person_admin import PersonAdminForm + + for bad in ("../../../etc/passwd", "not-a-real-figure.png", "/etc/hosts"): + form = PersonAdminForm( + data=self._data(easter_egg_starwars_choice=bad) + ) + self.assertFalse(form.is_valid(), f"{bad!r} should be rejected") + self.assertIn("easter_egg_starwars_choice", form.errors) diff --git a/website/utils/fileutils.py b/website/utils/fileutils.py index 44fed475..42a9e6e9 100644 --- a/website/utils/fileutils.py +++ b/website/utils/fileutils.py @@ -56,25 +56,65 @@ def is_image(filename): filename = filename.lower() return filename[filename.rfind(".") + 1:] in ext2conttype -def get_path_to_random_starwars_image(starwars_side = 'Rebels'): - """Gets a random star wars image path to assign""" - - if not starwars_side or starwars_side not in ['Rebels', 'Neither', 'DarkSide', 'Unfiled']: - starwars_side = 'Rebels' +# The Star Wars LEGO figures that seed a Person's default headshot / easter-egg +# image live under media/images/StarWarsFiguresFullSquare//. 'Rebels' is +# the canonical set used for easter eggs (see Person.easter_egg). +STARWARS_SIDES = ['Rebels', 'Neither', 'DarkSide', 'Unfiled'] +STARWARS_SUBDIR = ('images', 'StarWarsFiguresFullSquare') + + +def _normalize_starwars_side(starwars_side): + """Coerce an arbitrary side to a known one, defaulting to 'Rebels'.""" + if not starwars_side or starwars_side not in STARWARS_SIDES: + return 'Rebels' + return starwars_side + - #print("settings.MEDIA_ROOT: ", settings.MEDIA_ROOT); +def get_starwars_image_dir(starwars_side='Rebels'): + """Returns the on-disk directory (relative to cwd) for a Star Wars side. + Django dislikes absolute paths for FileField assignment, so we return a + path relative to MEDIA_ROOT's relative form (matches existing usage). + """ + starwars_side = _normalize_starwars_side(starwars_side) # requires the volume mount from docker - # Django doesn't like when we use absolute paths, so we need to get the relative path to the media folder local_media_folder = os.path.relpath(settings.MEDIA_ROOT) - star_wars_path = os.path.join(local_media_folder, 'images', 'StarWarsFiguresFullSquare', starwars_side) - # print("star_wars_path: ", star_wars_path); + return os.path.join(local_media_folder, *STARWARS_SUBDIR, starwars_side) + + +def list_starwars_images(starwars_side='Rebels'): + """Returns a sorted list of Star Wars image *basenames* for the given side. + + This is the single source of truth for the available figures: both the + random default picker (:func:`get_path_to_random_starwars_image`) and the + admin easter-egg picker (which lets editors preview/shuffle a figure before + the first save, see #1304) build on it. + + Example: + >>> list_starwars_images('Rebels')[:2] + ['300px-Ani-helmet.jpg', '540px-Logray.jpg'] + """ + star_wars_path = get_starwars_image_dir(starwars_side) + return sorted(f for f in os.listdir(star_wars_path) if is_image(f)) - all_images_in_dir = [f for f in os.listdir(star_wars_path) if is_image(f)] - # print("all_images_in_dir: ", all_images_in_dir); - # Return a randoms single path - return os.path.join(star_wars_path, random.choice(all_images_in_dir)) +def get_starwars_image_url(filename, starwars_side='Rebels'): + """Returns the public MEDIA_URL for a Star Wars image basename. + + The basename is sanitized (path components stripped) so this cannot be + coaxed into building a URL outside the Star Wars directory. + """ + starwars_side = _normalize_starwars_side(starwars_side) + filename = os.path.basename(filename) + return settings.MEDIA_URL + '/'.join( + (*STARWARS_SUBDIR, starwars_side, filename) + ) + + +def get_path_to_random_starwars_image(starwars_side='Rebels'): + """Gets a random star wars image path to assign""" + star_wars_path = get_starwars_image_dir(starwars_side) + return os.path.join(star_wars_path, random.choice(list_starwars_images(starwars_side))) def get_files_in_directory(dir_path): """Returns a list of files in the given directory"""