Files
watchlist/app/models.py
T
ki 67f09363f3 Initial commit: WatchStack v0.1.0
- FastAPI + SQLite backend with REST API
- Polymorphic Media model (book + series)
- Cover lookup via Open Library
- Vanilla HTML/CSS/JS frontend (MAL-inspired)
- Dark theme, status tabs, drawer detail, stats modal
- 8 seed demo entries
2026-07-21 23:11:14 +02:00

147 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQLAlchemy-Modelle für WatchStack.
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.
"""
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,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.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"<Genre {self.name}>"
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-Werte (an MAL angelehnt, aber 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) # book|series
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) # Bände
total_chapters: Mapped[Optional[int]] = mapped_column(Integer) # Kapitel
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)) # Sender/Streaming
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"<Media {self.kind}:{self.title!r}>"