pdf-quiz-generator/backend/app/routers/media.py
Daniel 809ac2fca6 feat: figures are thumbnails that open into a viewer, with source and overlay
A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.

The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because the same figure used in
three articles is cited the same way in all three, and a licence that turns out
to be wrong is one row to fix rather than three paragraphs to find. Asked for
when the figure is opened, not when the page is drawn.

And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.

On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.

Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:33:00 +02:00

347 lines
14 KiB
Python

"""The image bank: libraries, tags, and per-library access.
Images are stored through `storage_service`, so the bytes live in MinIO when the
backend is set to s3 and on the volume otherwise. A row stores the key, never a
URL, because a URL embeds the backend and would break the moment it changed.
"""
import logging
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy import func, or_, text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant, MediaTagLink
from app.models.user import User
from app.services import embedding_service, storage_service
from app.models.question_media import QuestionMedia
from app.services.search_service import hybrid_ids
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
log = logging.getLogger(__name__)
MAX_IMAGE_BYTES = 12 * 1024 * 1024
# A murmur is thirty seconds of audio and a bedside clip is a few megabytes, so
# they get their own ceiling rather than being squeezed under the image one.
MAX_MEDIA_BYTES = 60 * 1024 * 1024
IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"}
# Heart sounds are the reason this exists: a murmur cannot be shown as a picture.
AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
"audio/ogg", "audio/webm", "audio/mp4", "audio/aac"}
VIDEO_TYPES = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"}
ALLOWED_TYPES = IMAGE_TYPES | AUDIO_TYPES | VIDEO_TYPES
def kind_for(content_type: str) -> str:
"""Which of the three a file is. The player differs for each."""
if content_type in AUDIO_TYPES:
return "audio"
if content_type in VIDEO_TYPES:
return "video"
return "image"
def limit_for(kind: str) -> int:
return MAX_IMAGE_BYTES if kind == "image" else MAX_MEDIA_BYTES
def readable_libraries(db: Session, user: User) -> set[int] | None:
"""Library ids this user may use; None means every one of them."""
if user.is_moderator:
return None
return {row[0] for row in db.query(MediaLibraryGrant.library_id).filter(
MediaLibraryGrant.user_id == user.id).all()}
def assert_can_use(scope: set[int] | None, library_id: int | None) -> None:
if scope is None:
return
if library_id is None or library_id not in scope:
raise HTTPException(403, "You do not have access to that image library")
def _asset_json(asset: MediaAsset, tags: list[str]) -> dict:
return {
"id": asset.id,
"path": asset.path,
"title": asset.title,
"caption": asset.caption,
"alt_text": asset.alt_text,
"source": asset.source,
"source_url": asset.source_url,
"overlay": asset.overlay,
"kind": asset.kind,
"library_id": asset.library_id,
"category_id": asset.category_id,
"byte_size": asset.byte_size,
"storage": asset.storage,
"tags": tags,
# The editor shows this on hover so an image can be referenced by id.
"url": f"/uploads/{asset.path}",
}
def _tags_for(db: Session, asset_ids: list[int]) -> dict[int, list[str]]:
if not asset_ids:
return {}
# Bound parameters rather than ANY(), which is Postgres-only.
placeholders = ", ".join(f":id{i}" for i in range(len(asset_ids)))
rows = db.execute(sa_text(f"""
SELECT l.media_id, t.name FROM media_tag_links l
JOIN question_tags t ON t.id = l.tag_id
WHERE l.media_id IN ({placeholders})
"""), {f"id{i}": value for i, value in enumerate(asset_ids)}).fetchall()
out: dict[int, list[str]] = {}
for media_id, name in rows:
out.setdefault(media_id, []).append(name)
return out
@router.get("/libraries")
def list_libraries(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Libraries this user may use, with how many images each holds."""
scope = readable_libraries(db, current_user)
counts = dict(db.query(MediaAsset.library_id, func.count(MediaAsset.id))
.group_by(MediaAsset.library_id).all())
query = db.query(MediaLibrary).order_by(MediaLibrary.name)
if scope is not None:
query = query.filter(MediaLibrary.id.in_(scope or {0}))
return [{"id": lib.id, "name": lib.name, "description": lib.description,
"image_count": counts.get(lib.id, 0)} for lib in query.all()]
class LibraryIn(BaseModel):
name: str
description: str | None = None
@router.post("/libraries", status_code=201)
def create_library(data: LibraryIn, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
if db.query(MediaLibrary.id).filter(func.lower(MediaLibrary.name) == data.name.strip().lower()).first():
raise HTTPException(409, "A library with that name already exists")
library = MediaLibrary(name=data.name.strip(), description=data.description, user_id=current_user.id)
db.add(library)
db.commit()
db.refresh(library)
return {"id": library.id, "name": library.name}
class LibraryGrantIn(BaseModel):
user_id: int
@router.post("/libraries/{library_id}/grants", status_code=201)
def grant_library(library_id: int, data: LibraryGrantIn, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
if not db.get(MediaLibrary, library_id):
raise HTTPException(404, "Library not found")
if not db.get(User, data.user_id):
raise HTTPException(404, "User not found")
if db.query(MediaLibraryGrant.id).filter_by(library_id=library_id, user_id=data.user_id).first():
raise HTTPException(409, "That user already has access to this library")
db.add(MediaLibraryGrant(library_id=library_id, user_id=data.user_id, granted_by=current_user.id))
db.commit()
return {"library_id": library_id, "user_id": data.user_id}
@router.delete("/libraries/{library_id}/grants/{user_id}", status_code=204)
def revoke_library(library_id: int, user_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
grant = db.query(MediaLibraryGrant).filter_by(library_id=library_id, user_id=user_id).first()
if not grant:
raise HTTPException(404, "Grant not found")
db.delete(grant)
db.commit()
@router.get("/")
def list_media(
q: str | None = Query(None),
library_id: int | None = Query(None),
limit: int = Query(60, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Browse the image bank. `q` searches captions, alt text and titles."""
scope = readable_libraries(db, current_user)
query = db.query(MediaAsset)
if scope is not None:
query = query.filter(MediaAsset.library_id.in_(scope or {0}))
if library_id is not None:
assert_can_use(scope, library_id)
query = query.filter(MediaAsset.library_id == library_id)
if q and q.strip():
ranked, _ = hybrid_ids(db, q.strip(), "media", limit=200)
if not ranked:
return {"total": 0, "images": []}
query = query.filter(MediaAsset.id.in_(ranked))
total = query.count()
assets = query.order_by(MediaAsset.id.desc()).offset(offset).limit(limit).all()
tags = _tags_for(db, [a.id for a in assets])
# How many questions point at each one. Renaming and moving are safe because
# the link is the id; deleting is the one act that cannot be undone, so the
# count travels with the row and is shown before anyone presses delete.
used = dict(db.query(QuestionMedia.media_id, func.count(QuestionMedia.id)).filter(
QuestionMedia.media_id.in_([a.id for a in assets] or [0])
).group_by(QuestionMedia.media_id).all()) if assets else {}
return {"total": total, "images": [
{**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]}
@router.get("/by-path")
def media_by_path(path: str = Query(...), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""What is known about the image at this path: label, description, source.
The reader's viewer asks for this when a figure is opened, not when the page
is drawn — a page of prose with six figures in it should cost six requests
only if somebody opens all six.
Answers 404 rather than 403 for an image in a library this person cannot
use: whether a private library holds a given filename is not a question
this endpoint should answer.
"""
cleaned = (path or "").strip().lstrip("/")
if cleaned.startswith("uploads/"):
cleaned = cleaned[len("uploads/"):]
asset = db.query(MediaAsset).filter(MediaAsset.path == cleaned).first()
if not asset:
raise HTTPException(404, "Image not found")
scope = readable_libraries(db, current_user)
if scope is not None and asset.library_id is not None and asset.library_id not in scope:
raise HTTPException(404, "Image not found")
return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, []))
@router.post("/upload", status_code=201)
def upload_media(
file: UploadFile = File(...),
library_id: int | None = Form(None),
title: str | None = Form(None),
caption: str | None = Form(None),
alt_text: str | None = Form(None),
source: str | None = Form(None),
source_url: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Store an image and index it so it can be found by what it shows."""
scope = readable_libraries(db, current_user)
if library_id is not None:
assert_can_use(scope, library_id)
elif scope is not None:
raise HTTPException(400, "Choose one of your libraries for this image")
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Upload an image (PNG, JPEG, GIF, WebP, SVG), "
"audio (MP3, WAV, OGG, M4A) or video (MP4, WebM, MOV)")
kind = kind_for(file.content_type)
ceiling = limit_for(kind)
data = file.file.read(ceiling + 1)
if len(data) > ceiling:
raise HTTPException(413, f"Keep {kind} under {ceiling // (1024 * 1024)} MB")
if not data:
raise HTTPException(400, "That file is empty")
suffix = (file.filename or "").rsplit(".", 1)[-1].lower()[:8] or "bin"
key = f"media/{uuid.uuid4().hex}.{suffix}"
storage_service.save(key, data, file.content_type)
asset = MediaAsset(
path=key, title=title or file.filename, caption=caption, alt_text=alt_text,
kind=kind, library_id=library_id, user_id=current_user.id,
storage="s3" if storage_service.using_s3() else "local", byte_size=len(data),
)
db.add(asset)
db.commit()
db.refresh(asset)
# Text today; a vision model can embed the image itself later.
try:
if embedding_service.embed_record(asset, "media"):
db.commit()
except Exception:
db.rollback()
log.warning("Could not embed media %s; the retry task will", asset.id, exc_info=True)
return _asset_json(asset, [])
class MediaUpdate(BaseModel):
title: str | None = None
caption: str | None = None
alt_text: str | None = None
source: str | None = None
source_url: str | None = None
#: Vector shapes in normalised coordinates; see docs/image-overlays.md.
overlay: dict | None = None
library_id: int | None = None
category_id: int | None = None
tags: list[str] | None = None
@router.patch("/{media_id}")
def update_media(media_id: int, data: MediaUpdate, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Edit an image's description and tags, and move it between libraries."""
scope = readable_libraries(db, current_user)
asset = db.get(MediaAsset, media_id)
if not asset:
raise HTTPException(404, "Image not found")
assert_can_use(scope, asset.library_id)
values = data.model_dump(exclude_unset=True)
tags = values.pop("tags", None)
if "library_id" in values:
assert_can_use(scope, values["library_id"])
for field, value in values.items():
setattr(asset, field, value)
if tags is not None:
db.query(MediaTagLink).filter(MediaTagLink.media_id == asset.id).delete(synchronize_session=False)
for name in dict.fromkeys(t.strip() for t in tags if t.strip()):
# Reuse the shared vocabulary rather than inventing a media-only one.
row = db.execute(sa_text(
"SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1"
), {"n": name}).first()
tag_id = row[0] if row else db.execute(sa_text(
"INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id"
), {"n": name}).scalar()
db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id))
db.commit()
db.refresh(asset)
try:
if embedding_service.embed_record(asset, "media"):
db.commit()
except Exception:
db.rollback()
return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, []))
@router.delete("/{media_id}", status_code=204)
def delete_media(media_id: int, force: bool = False, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Delete an image. Refused while a question still uses it, unless forced.
A question refers to a figure by id, so renaming it or moving it between
libraries never breaks anything. Deleting does, and silently — the cascade
would take the link with it and the question would simply stop having a
picture. So the count has to be faced first.
"""
used = db.query(QuestionMedia).filter(QuestionMedia.media_id == media_id).count()
if used and not force:
raise HTTPException(409, f"{used} question{'s' if used > 1 else ''} still use this. "
"Detach it there first, or delete it anyway.")
asset = db.get(MediaAsset, media_id)
if not asset:
raise HTTPException(404, "Image not found")
storage_service.delete(asset.path)
db.query(MediaTagLink).filter(MediaTagLink.media_id == asset.id).delete(synchronize_session=False)
db.delete(asset)
db.commit()