Files
watchlist/app/models.py
T
ki 0bca2eaaa2
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled
chore: beta release v0.5.0-beta
- Bump version to 0.5.0-beta
- Add Apache-2.0 LICENSE (official text) + NOTICE
- Add Docker support: multi-stage Dockerfile + docker-compose
  * non-root user, tini PID 1, gunicorn + uvicorn workers
  * healthcheck, OCI labels, persistent /data volume
- Add GitHub Actions CI (lint/import-check + Docker smoke-test)
- Backend polish:
  * modern lifespan handler (replaces deprecated on_event)
  * explicit sort validation (400 instead of 500)
  * title min_length=1 schema validation
  * configurable data dir + http timeout via env
  * avg_rating helper extracted (DRY)
- Frontend polish:
  * beta badge in title, footer with live version
  * loading spinner on initial fetch
  * updated README, badges, config table, roadmap
2026-07-21 23:34:01 +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.
"""
# 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 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 (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"<Media {self.kind}:{self.title!r}>"