"""SQLAlchemy-Modelle für Quivio. Zentrale Idee: ein polymorphes ``Media``-Objekt mit ``kind`` (book/series), das optionale, mediumspezifische Felder als NULL-sparende Spalten mitführt. So müssen wir keine separaten Tabellen für Bücher vs. Serien pflegen. """ # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations from datetime import date, datetime, timezone from typing import Optional from sqlalchemy import ( CheckConstraint, Column, Date, DateTime, Float, ForeignKey, Index, Integer, String, Table, Text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from quivio.database import Base def _utcnow() -> datetime: return datetime.now(timezone.utc) # ---------- Assoziationstabellen (n:m) --------------------------------------- media_genres = Table( "media_genres", Base.metadata, Column("media_id", Integer, ForeignKey("media.id", ondelete="CASCADE"), primary_key=True), Column("genre_id", Integer, ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True), ) media_tags = Table( "media_tags", Base.metadata, Column("media_id", Integer, ForeignKey("media.id", ondelete="CASCADE"), primary_key=True), Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True), ) class Genre(Base): __tablename__ = "genres" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) def __repr__(self) -> str: return f"" class Tag(Base): __tablename__ = "tags" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(40), unique=True, nullable=False) # ---------- Hauptentität: Media ---------------------------------------------- # Status (an MAL angelehnt, für Bücher + Serien nutzbar): # plan -> auf der Liste / will ich lesen/schauen # reading -> lese ich gerade (Buch) ODER watching (Serie) # done -> fertig (abgeschlossen) # hold -> pausiert # dropped -> abgebrochen STATUS_VALUES = ("plan", "reading", "done", "hold", "dropped") KIND_VALUES = ("book", "series") class Media(Base): """Ein Watchlist-Eintrag – Buch ODER Serie.""" __tablename__ = "media" id: Mapped[int] = mapped_column(primary_key=True) # Allgemein kind: Mapped[str] = mapped_column(String(16), nullable=False) title: Mapped[str] = mapped_column(String(200), nullable=False) original_title: Mapped[Optional[str]] = mapped_column(String(200)) description: Mapped[Optional[str]] = mapped_column(Text) cover_url: Mapped[Optional[str]] = mapped_column(String(600)) release_year: Mapped[Optional[int]] = mapped_column(Integer) status: Mapped[str] = mapped_column(String(16), nullable=False, default="plan") rating: Mapped[Optional[float]] = mapped_column(Float) # 1-10 notes: Mapped[Optional[str]] = mapped_column(Text) # Buch-spezifisch author: Mapped[Optional[str]] = mapped_column(String(200)) isbn: Mapped[Optional[str]] = mapped_column(String(20)) total_volumes: Mapped[Optional[int]] = mapped_column(Integer) total_chapters: Mapped[Optional[int]] = mapped_column(Integer) total_pages: Mapped[Optional[int]] = mapped_column(Integer) volumes_read: Mapped[Optional[int]] = mapped_column(Integer, default=0) chapters_read: Mapped[Optional[int]] = mapped_column(Integer, default=0) pages_read: Mapped[Optional[int]] = mapped_column(Integer, default=0) # Serien-spezifisch network: Mapped[Optional[str]] = mapped_column(String(120)) total_seasons: Mapped[Optional[int]] = mapped_column(Integer) total_episodes: Mapped[Optional[int]] = mapped_column(Integer) episodes_watched: Mapped[Optional[int]] = mapped_column(Integer, default=0) season_watching: Mapped[Optional[int]] = mapped_column(Integer) start_date: Mapped[Optional[date]] = mapped_column(Date) end_date: Mapped[Optional[date]] = mapped_column(Date) # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime, default=_utcnow, onupdate=_utcnow, nullable=False ) genres: Mapped[list[Genre]] = relationship(Genre, secondary=media_genres, lazy="selectin") tags: Mapped[list[Tag]] = relationship(Tag, secondary=media_tags, lazy="selectin") __table_args__ = ( Index("ix_media_kind_status", "kind", "status"), Index("ix_media_title", "title"), CheckConstraint("kind IN ('book','series')", name="ck_media_kind"), CheckConstraint("status IN ('plan','reading','done','hold','dropped')", name="ck_media_status"), ) @property def progress_percent(self) -> float: """Fortschritt in Prozent, je nach Medium.""" if self.kind == "book": total = self.total_chapters or self.total_pages or self.total_volumes or 0 done = self.chapters_read or self.pages_read or self.volumes_read or 0 else: # series total = self.total_episodes or 0 done = self.episodes_watched or 0 if total <= 0: return 0.0 return round(min(100.0, done * 100.0 / total), 1) def __repr__(self) -> str: return f""