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
347 lines
14 KiB
Python
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()
|