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
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=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()
|