pdf-quiz-generator/backend/app/models/media.py
Daniel db2df87fc6 feat: MinIO-backed media libraries, and file the last 316 questions
Storage
Media now goes through `storage_service`, which has two backends: the container
volume, and S3/MinIO. A volume can only be mounted by one host, has no presigned
URLs and no lifecycle rules, none of which suits ~860 MB of media. Reads fall
back to the volume when an object is missing, so the existing uploads keep
working and files can migrate gradually rather than in one risky pass.

A row stores the object key, never a URL: a URL embeds the backend, so a row
holding `http://minio:9000/...` breaks the moment the backend changes.

MinIO publishes no host ports — the backend reaches it over the compose network,
and 9000/9001 are already taken on this host by other stacks.

Image libraries (migration d2e3f4a5b6c7)
An image belongs to a library, and a person is granted a library the way they are
granted a category, so access can be given to some images without giving away all
of them. Tags reuse the shared `question_tags` vocabulary rather than inventing a
media-only one. Uploads are type- and size-checked, stored through the service,
and embedded so an image can be found by what it shows.

Classification finished
The 316 questions the chooser had declined are now filed with `--force`, which
takes the nearest candidate from the same shortlist the chooser saw. 306 were
forced, 10 the chooser accepted on this pass. No question sits on a bare system
any more:

  system only          2,730 -> 0
  condition/subsystem    214 -> 1,782
  full depth               4 -> 1,166

A forced match is a weaker signal than a chosen one, so expect more errors among
those 306 — but the original system stays as a cross-link, so nothing is lost and
they can be corrected by hand.

Tests: 8 new backend covering library scoping, edit confinement, shared-vocabulary
tags, storage indirection on upload, and type/size limits. 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgRcMaScVEL7TBLpnAoSV9
2026-09-10 06:45:22 +02:00

67 lines
3 KiB
Python

from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from app.database import Base
from app.models.embeddable import Embeddable
class MediaAsset(Base, Embeddable):
"""An image in the bank, searchable by what it shows rather than its filename.
The embedding column is filled from the caption today. A vision-capable model
can fill it from the image itself later; `embedding_model` records which one
produced each vector, so the two are never mixed silently.
"""
__tablename__ = "media_assets"
id = Column(Integer, primary_key=True, index=True)
path = Column(String(500), unique=True, nullable=False)
title = Column(String(300), nullable=True)
caption = Column(Text, nullable=True)
alt_text = Column(Text, nullable=True)
kind = Column(String(20), default="image")
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="SET NULL"), nullable=True, index=True)
# Which backend holds the bytes; reads fall back to the volume either way.
storage = Column(String(10), default="local")
byte_size = Column(Integer, nullable=True)
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class MediaTagLink(Base):
__tablename__ = "media_tag_links"
__table_args__ = (UniqueConstraint("media_id", "tag_id", name="uq_media_tag"),)
id = Column(Integer, primary_key=True, index=True)
media_id = Column(Integer, ForeignKey("media_assets.id", ondelete="CASCADE"), nullable=False, index=True)
# `question_tags` is created by raw DDL rather than an ORM model, so the
# constraint is declared in the migration; the mapper only stores the id.
tag_id = Column(Integer, nullable=False, index=True)
class MediaLibrary(Base):
"""A named group of images, so access can be given to some without all."""
__tablename__ = "media_libraries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), unique=True, nullable=False)
description = Column(Text, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class MediaLibraryGrant(Base):
"""Lets one person work with one library, mirroring the category grants."""
__tablename__ = "media_library_grants"
__table_args__ = (UniqueConstraint("library_id", "user_id", name="uq_media_library_grant"),)
id = Column(Integer, primary_key=True, index=True)
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
granted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)