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
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,14 @@ payload uses absolute URLs. Cross-origin requests are allowed on `/api/` only
via the in-repo `ApiCorsMiddleware` (no `django-cors-headers` dependency).
`Person.email` is intentionally not serialized. Projects are gated to
`is_visible=True`; the people list is scoped to actual members (those with a
Position). When adding a resource, follow the existing viewset/serializer
pattern and keep `v1` fields additive-only (breaking changes → `v2`). Full
reference: `docs/API.md`. Tests: `website/tests/test_api.py`.
Position). Image fields follow one rule (#1432): `thumbnail` is a cropped, sized
derivative built by `website.utils.thumbnail_utils.get_cropped_thumbnail` (sizes
in `serializers.py`; keep each size's aspect ratio equal to the model's
`ImageRatioField`), `image_original` is the raw upload; `warm_api_thumbnails`
pre-generates the derivatives at container start. When adding a resource, follow
the existing viewset/serializer pattern and keep `v1` fields additive-only
(breaking changes → `v2`). Full reference: `docs/API.md`. Tests:
`website/tests/test_api.py`.

### Settings, config, and environment

Expand All @@ -128,7 +133,7 @@ reference: `docs/API.md`. Tests: `website/tests/test_api.py`.

### Container startup side effects (`docker-entrypoint.sh`)

Every container start runs, in order: `collectstatic` → `makemigrations` → `migrate` → `makemigrations website` → `migrate website` → `delete_unused_files` → `thumbnail_cleanup` → `generate_slugs_for_old_news_items` → `auto_close_project_roles` → `remove_year_from_forum_name` → `fix_sortedm2m_columns` → `seed_sidewalk_participants` → `runserver 0.0.0.0:8000`. The repeated `makemigrations website` step is intentional (fixes first-run issues). If you add a one-shot data migration command under `website/management/commands/`, decide whether it belongs in this startup sequence.
Every container start runs, in order: `collectstatic` → `makemigrations` → `migrate` → `makemigrations website` → `migrate website` → `delete_unused_files` → `thumbnail_cleanup` → `generate_slugs_for_old_news_items` → `auto_close_project_roles` → `remove_year_from_forum_name` → `fix_sortedm2m_columns` → `seed_sidewalk_participants` → `warm_api_thumbnails` → `runserver 0.0.0.0:8000`. The repeated `makemigrations website` step is intentional (fixes first-run issues). If you add a one-shot data migration command under `website/management/commands/`, decide whether it belongs in this startup sequence.

### Image handling

Expand Down
7 changes: 7 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ echo "4.10d Running 'python manage.py seed_sidewalk_participants' to backfill Pr
echo "******************************************"
python manage.py seed_sidewalk_participants

echo "****************** STEP 4.10e/5: docker-entrypoint.sh ************************"
echo "4.10e Running 'python manage.py warm_api_thumbnails' to pre-generate the cropped thumbnails the public API serves (#1432)"
echo "******************************************"
# Idempotent: after the first run this is a couple of stat calls per row. Without
# it, the first API request after a deploy generates every derivative inline.
python manage.py warm_api_thumbnails

# echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************"
# echo "4.3 Running 'python manage.py rename_person_images' to rename person images"
# echo "******************************************"
Expand Down
45 changes: 42 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ Only **publicly visible** projects (`is_visible=True`). Detail and
sub-resources are keyed by `short_name`:

- `GET /api/v1/projects/<short_name>/` — summary, about, website, dates,
keywords, umbrellas, thumbnail.
keywords, umbrellas, `thumbnail` (cropped **1000×600**) and `image_original`.
See *Images* below.
- `GET /api/v1/projects/<short_name>/publications/` — the project's pubs.
- `GET /api/v1/projects/<short_name>/grants/` — grants funding the project.
- `GET /api/v1/projects/<short_name>/people/` — everyone with a role on the
Expand Down Expand Up @@ -100,8 +101,9 @@ Funding amounts are intentionally **not** exposed by the API.
Actual lab members (people with at least one Position); external co-authors are
not listed here even though they appear as publication `authors`. Detail by
`url_name`: `GET /api/v1/people/<url_name>/` — name, `current_title`,
`current_school`, `current_department`, bio, thumbnail, and public social/web
links (ORCID, Google Scholar, GitHub, etc.).
`current_school`, `current_department`, bio, `thumbnail` (cropped **256×256**),
`image_original`, and public social/web links (ORCID, Google Scholar, GitHub,
etc.). See *Images* below.

> **Note:** the `current_*` fields come from the person's *latest* Position, so
> for an alum they describe their last lab position, not their present-day
Expand All @@ -111,11 +113,38 @@ links (ORCID, Google Scholar, GitHub, etc.).
> **Note:** `email` is intentionally **not** exposed by the API to avoid making
> it an email-harvesting surface, even where it appears on a member page.

## Images

Every payload with a picture follows one convention:

| Field | What it is |
|------------------|---------------------------------------------------------------|
| `thumbnail` | A **cropped, sized derivative** — the same image the site itself renders, honoring the crop box an editor set in the admin. Use this. |
| `image_original` | The **raw upload**. Full resolution, uncropped, and sometimes tens of megabytes. Only reach for it if you truly need the source. |

Sizes are **256×256** for a person and **1000×600** for a project — roughly 2× what
the site renders, so a 128 px avatar (or a 500 px-wide project card) stays sharp
on a HiDPI display. Both keep the aspect ratio of the corresponding crop box, so
nothing gets cropped a second time.

Person image fields appear wherever a person does: `/people/`, publication
`authors`, and the nested `person` on `/projects/<short_name>/people/`. Both
fields are `null` when there's no image at all.

Derivatives are generated server-side, so **don't hand-build a thumbnail URL** by
editing the size in one — a size the site has never rendered doesn't exist on
disk and will 404. Ask for a different size in an issue instead.

## Stability contract

- **`v1` fields are additive-only.** New fields may be added; existing field
names and meanings will not change or be removed within `v1`. Breaking changes
ship as `/api/v2/`.
- One exception has been taken, in **2.30.0** (#1432): `thumbnail` on people
and projects used to return the raw upload and now returns the cropped
derivative described above. A field named `thumbnail` handing out an 11 MB
original was a bug, not a contract — 13 headshots cost one consumer 33.5 MB.
The raw file is still available as `image_original`.
- Don't hardcode pagination page sizes as a proxy for "all" — page through
`next`, or set `page_size` explicitly (≤100).
- URLs in responses (PDFs, thumbnails, page links) are absolute and safe to use
Expand All @@ -130,6 +159,16 @@ Code lives in `website/api/` (`serializers.py`, `views.py`, `urls.py`,
(`website.api.middleware.ApiCorsMiddleware`), scoped to `/api/`, rather than a
third-party package. Tests: `website/tests/test_api.py`.

Thumbnails go through `website.utils.thumbnail_utils.get_cropped_thumbnail` —
the same easy-thumbnails options the site's templates pass, so the API shares
their cached files — with the sizes defined as `API_PERSON_THUMBNAIL_SIZE` /
`API_PROJECT_THUMBNAIL_SIZE` in `serializers.py`. **Keep any new size's aspect
ratio equal to the model's `ImageRatioField`**, or easy-thumbnails center-crops a
second time on top of the editor's box (#1424). The API's sizes aren't rendered
anywhere on the site, so `warm_api_thumbnails` (run from `docker-entrypoint.sh`)
pre-generates them at container start; without it the first request after a
deploy generates them all inline.

**Deliberately deferred** (add on the same pattern when needed): write
endpoints, auth / API keys, request throttling, and Talks/Posters/Videos
resources.
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

# Makeability Lab Global Variables, including Makeability Lab version
ML_WEBSITE_VERSION = "2.29.1" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Performs the one-time rename adding the '_Talk'/'_Poster' filename suffix (#1404): 188 talks and 11 posters per the 2.29.0 dry-run inventory; publications untouched. Also promotes the #1390 diverged-file repair from dry-run to live, recovering 3 talks whose PDFs sat orphaned on disk under dotted names."
ML_WEBSITE_VERSION = "2.30.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "The public API now serves cropped, sized photos (#1432): a person or project thumbnail honors the crop box you set in the admin instead of handing out the raw upload, which cost one consumer 33.5 MB for 13 headshots. The full-resolution file is still available."
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
65 changes: 62 additions & 3 deletions website/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
avoid turning the API into an email-harvesting surface, even where they appear on
a member page.

Image fields follow one rule: ``thumbnail`` is a *cropped, sized derivative*
(honoring the editor's crop box, same as the site renders) and ``image_original``
is the raw upload. See :func:`cropped_thumbnail_url` and #1432.

Existing model helpers are reused rather than re-deriving formatting:
``Person.get_full_name`` / ``get_current_title``, ``Publication`` citation
helpers, ``Project.get_display_short_name``, ``Grant.start_date`` / ``grant_url``.
Expand All @@ -17,6 +21,19 @@
from rest_framework import serializers

from website.models import Grant, Person, Project, ProjectRole, Publication
from website.utils.thumbnail_utils import get_cropped_thumbnail

# Sizes for the cropped derivatives the API serves as ``thumbnail`` (#1432).
#
# Each keeps the aspect ratio of the corresponding model's ImageRatioField --
# Person's crop box is square (245x245), Project's is 15:9 (500x300). A mismatch
# would make easy-thumbnails center-crop a second time on top of the editor's
# box, which is what clipped heads off the news cards in #1424.
#
# Both are ~2x what the site itself renders, so a consumer can draw a 128px
# avatar (or a 500px-wide project card) sharply on a HiDPI display.
API_PERSON_THUMBNAIL_SIZE = (256, 256)
API_PROJECT_THUMBNAIL_SIZE = (1000, 600)


def abs_media_url(request, filefield):
Expand All @@ -35,6 +52,18 @@ def abs_media_url(request, filefield):
return request.build_absolute_uri(url) if request is not None else url


def cropped_thumbnail_url(request, image_field, size, box=None):
"""Absolute URL of the cropped derivative of ``image_field`` at ``size``.

Falls back to the original image when generation fails (a bad/missing source
file), so ``thumbnail`` is never null for a row that *has* an image; returns
``None`` when there's no image at all. Reuses the same easy-thumbnails
options the site's templates pass, so the API shares their cached files.
"""
thumbnail = get_cropped_thumbnail(image_field, size, box)
return abs_media_url(request, thumbnail or image_field)


def abs_page_url(request, url_name, *args):
"""Absolute URL for a named route, tolerant of reverse failures.

Expand All @@ -50,15 +79,22 @@ def abs_page_url(request, url_name, *args):


class PersonSummarySerializer(serializers.ModelSerializer):
"""Compact person representation, used when nested in publications/roles."""
"""Compact person representation, used when nested in publications/roles.

``thumbnail`` is the cropped 256x256 headshot -- the same derivative the
site's own pages render, honoring the crop box an editor set in the admin.
``image_original`` is the raw upload, which can be tens of megabytes; only
reach for it if you genuinely need full resolution (#1432).
"""

name = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
thumbnail = serializers.SerializerMethodField()
image_original = serializers.SerializerMethodField()

class Meta:
model = Person
fields = ["id", "url_name", "name", "url", "thumbnail"]
fields = ["id", "url_name", "name", "url", "thumbnail", "image_original"]

def get_name(self, obj):
return obj.get_full_name()
Expand All @@ -69,6 +105,14 @@ def get_url(self, obj):
)

def get_thumbnail(self, obj):
return cropped_thumbnail_url(
self.context.get("request"),
obj.image,
API_PERSON_THUMBNAIL_SIZE,
obj.cropping,
)

def get_image_original(self, obj):
return abs_media_url(self.context.get("request"), obj.image)


Expand Down Expand Up @@ -142,9 +186,15 @@ def get_url(self, obj):


class ProjectSerializer(ProjectSummarySerializer):
"""Full project representation for the projects detail/list endpoints."""
"""Full project representation for the projects detail/list endpoints.

As with people, ``thumbnail`` is the cropped 1000x600 derivative of the
gallery image (honoring ``Project.cropping``) and ``image_original`` is the
raw upload (#1432).
"""

thumbnail = serializers.SerializerMethodField()
image_original = serializers.SerializerMethodField()
keywords = serializers.SerializerMethodField()
project_umbrellas = serializers.SerializerMethodField()

Expand All @@ -158,11 +208,20 @@ class Meta(ProjectSummarySerializer.Meta):
"data_url",
"featured_code_repo_url",
"thumbnail",
"image_original",
"keywords",
"project_umbrellas",
]

def get_thumbnail(self, obj):
return cropped_thumbnail_url(
self.context.get("request"),
obj.gallery_image,
API_PROJECT_THUMBNAIL_SIZE,
obj.cropping,
)

def get_image_original(self, obj):
return abs_media_url(self.context.get("request"), obj.gallery_image)

def get_keywords(self, obj):
Expand Down
96 changes: 96 additions & 0 deletions website/management/commands/warm_api_thumbnails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Pre-generate the cropped derivatives the public API serves (#1432).

The API's ``thumbnail`` fields are sizes the *site* never renders (256x256 for a
person, 1000x600 for a project), so without this the first API request after a
deploy generates them inline -- decoding up to a page's worth of multi-megabyte
source photos inside one request, which can approach the Gunicorn timeout set in
docker-entrypoint.sh. This command does that work once at container start.

It is idempotent and cheap on re-run: easy-thumbnails skips any derivative whose
file is already on disk and newer than its source, so subsequent starts are a
couple of stat calls per row. Safe to run by hand at any time::

python manage.py warm_api_thumbnails
python manage.py warm_api_thumbnails --dry-run # just report what's covered

Scope matches what the API exposes: every person who has a photo -- not just lab
members, because PersonSummarySerializer also nests in publication ``authors``,
where external co-authors show up -- and every publicly visible project.
"""

import logging
import time

from django.core.management.base import BaseCommand

from website.api.serializers import (
API_PERSON_THUMBNAIL_SIZE,
API_PROJECT_THUMBNAIL_SIZE,
)
from website.models import Person, Project
from website.utils.thumbnail_utils import get_cropped_thumbnail

_logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = "Pre-generate the cropped thumbnails served by the public REST API (#1432)."

def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Report how many rows would be warmed without generating anything.",
)

def handle(self, *args, **options):
dry_run = options["dry_run"]

# Rows with no image at all are excluded here rather than skipped in the
# loop: gallery_image is null=True, and Django's .exclude(field="")
# keeps NULLs (NOT (x = '' AND x IS NOT NULL)), so both filters are
# needed or image-less projects get reported as failures.
people = Person.objects.exclude(image="").exclude(image__isnull=True)
projects = (
Project.objects.filter(is_visible=True)
.exclude(gallery_image="")
.exclude(gallery_image__isnull=True)
)

targets = [
("person", people, "image", "cropping", API_PERSON_THUMBNAIL_SIZE),
("project", projects, "gallery_image", "cropping",
API_PROJECT_THUMBNAIL_SIZE),
]

for label, queryset, image_attr, box_attr, size in targets:
count = queryset.count()
if dry_run:
msg = f"[dry run] would warm {count} {label} thumbnail(s) at {size}"
_logger.info(msg)
self.stdout.write(msg)
continue

start = time.monotonic()
generated = cached = failed = 0
for obj in queryset.iterator():
image, box = getattr(obj, image_attr), getattr(obj, box_attr)
if get_cropped_thumbnail(image, size, box, generate=False):
cached += 1
continue
if get_cropped_thumbnail(image, size, box) is None:
# get_cropped_thumbnail already logged the reason (usually a
# source file missing from media/); keep going.
failed += 1
else:
generated += 1

elapsed = time.monotonic() - start
msg = (
f"{label} thumbnails at {size}: {generated} generated, "
f"{cached} already cached, {failed} failed (of {count}) "
f"in {elapsed:.1f}s"
)
_logger.info(msg)
self.stdout.write(msg)
Loading
Loading