pdf-quiz-generator/backend/app/routers/media.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +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=None)
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=None,
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()