This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -9,6 +9,11 @@ load_dotenv(ROOT / ".env")
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
# Wer den Hostmodus einschalten darf. Einmal /?host=<schlüssel> aufrufen,
# danach steckt es in der Sitzung. Ohne Eintrag kommt niemand an die
# Hostknöpfe — auch nicht über die Tastenkombination.
HOST_SCHLUESSEL = os.getenv("HOST_SCHLUESSEL", "")
TMDB_BASE = "https://api.themoviedb.org/3"
IMAGE_BASE = "https://image.tmdb.org/t/p"

View File

@@ -116,10 +116,25 @@ def existing_netflix_ids(conn):
return {row["id"]: row["netflix_id"] for row in rows}
# Sammlung, Rad und Gesehen zeigen auch Filme, die nie auf Netflix liefen.
# Deren Daten stehen trotzdem im Katalog — sonst blieben die Karten leer.
BENUTZT = ("wishlist", "suggestions", "watched")
def benutzte_ids(conn):
"""Alle movie_ids, an denen jemand hängt."""
teile = " UNION ".join(f"SELECT movie_id FROM {t}" for t in BENUTZT)
return {r[0] for r in conn.execute(teile)}
def drop_missing(conn, keep_ids):
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus."""
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus.
Ausgenommen, was jemand gemerkt, vorgeschlagen oder geschaut hat: die
Karten dazu holen ihre Daten aus dieser Tabelle.
"""
current = {r["id"] for r in conn.execute("SELECT id FROM movies").fetchall()}
gone = current - set(keep_ids)
gone = current - set(keep_ids) - benutzte_ids(conn)
if gone:
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
return len(gone)

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -22,7 +23,7 @@ from . import (
watched,
wishlist,
)
from .config import IMAGE_BASE, STATIC_DIR
from .config import HOST_SCHLUESSEL, IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
@@ -93,12 +94,19 @@ async def startup():
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
if not HOST_SCHLUESSEL:
log.warning(
"HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu."
)
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
asyncio.create_task(guarded_sync())
else:
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
asyncio.create_task(sync.katalog_nachtragen())
@app.on_event("shutdown")
@@ -364,14 +372,21 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
async def film_holen(movie_id: int):
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB.
Was von TMDB kommt, landet gleich im Katalog. Sonst blieben die Karten
in Sammlung, Rad und Gesehen leer: die lesen ihre Daten von dort.
"""
movie = db.get_movie(movie_id)
if movie:
return movie
try:
return await search.detail(movie_id)
movie = await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
with db.connect() as conn:
db.upsert(conn, movie)
return movie
@app.get("/api/features")
@@ -493,7 +508,11 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"items": users.alle(), "mine": users.aktuell(sid)}
return {
"items": users.alle(),
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@app.post("/api/users")
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
return {"removed": 1}
@app.get("/api/users/{user_id}/schutz")
async def schutz_zeigen(
user_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Welcher Film hinterlegt ist — nur für den Host und den Namen selbst.
Wer ihn ändern darf, darf ihn auch sehen: sonst müsste man raten, was
man gerade überschreibt.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
movie_id = users.schutz_von(user_id)
if movie_id is None:
return {"film": None}
film = await film_holen(movie_id)
return {
"film": {
"id": film["id"],
"title": film["title"],
"year": film.get("year"),
"poster_path": film.get("poster_path"),
}
}
@app.post("/api/users/{user_id}/schutz")
def schutz_setzen(
user_id: int,
response: Response,
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Anmeldefilm hinterlegen — oder mit movie_id null wieder freigeben.
Der Host pflegt die Runde, sonst darf jeder nur den eigenen Namen
ändern. Wer angemeldet ist, hat den alten Film ohnehin gewusst.
"""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
raise HTTPException(status_code=403, detail="Nicht dein Name")
if not users.schutz_setzen(user_id, movie_id):
raise HTTPException(status_code=404, detail="Unbekannter Name")
events.senden("users")
return {"geschuetzt": movie_id is not None}
@app.get("/api/host")
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"host": users.ist_host(sid)}
@app.post("/api/host")
def host_modus(
response: Response,
an: bool = Body(..., embed=True),
schluessel: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
diese Route nicht zuständig.
"""
sid = sitzung(response, filmabend_sid)
if not an:
return {"host": users.host_setzen(sid, False)}
darf = users.ist_host(sid) or (
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@app.post("/api/users/waehlen")
def name_waehlen(
response: Response,
user_id: int | None = Body(None, embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Wer bin ich? Ohne user_id wieder anonym."""
"""Wer bin ich? Ohne user_id wieder anonym.
Hat der Name einen Anmeldefilm, muss movie_id ihn treffen. Der Host
und wer schon angemeldet ist, kommen ohne durch.
"""
sid = sitzung(response, filmabend_sid)
if user_id is None:
users.abwaehlen(sid)
return {"mine": None}
film = users.schutz_von(user_id)
frei = (
film is None
or users.ist_host(sid)
or users.besitzer(sid) == user_id
)
if not frei and movie_id != film:
raise HTTPException(status_code=403, detail="Das ist nicht der Film.")
benutzer = users.waehlen(sid, user_id)
if not benutzer:
raise HTTPException(status_code=404, detail="Unbekannter Name")
@@ -576,14 +694,13 @@ def bewerten(
watched_id: int,
response: Response,
stars: int = Body(..., embed=True),
text: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text)
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
if not eintrag:
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
events.senden("watched")
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
return {"removed": 1}
@app.post("/api/watched/{watched_id}/notes")
def gaestebuch_schreiben(
watched_id: int,
response: Response,
text: str = Body(..., embed=True),
parent_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen anderen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
note_id = watched.anmerken(watched_id, sid, text, parent_id)
if note_id is None:
raise HTTPException(status_code=400, detail="Kein Text oder Ziel unbekannt")
events.senden("watched")
return {"id": note_id}
# Eigener Pfad, damit er /api/watched/{watched_id} nicht in die Quere kommt
@app.delete("/api/watched-notes/{note_id}")
def gaestebuch_loeschen(
note_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Der Verfasser oder der Host — samt allen Antworten darunter."""
sid = sitzung(response, filmabend_sid)
entfernt = watched.anmerkung_loeschen(note_id, sid, users.ist_host(sid))
if not entfernt:
raise HTTPException(status_code=404, detail="Nicht gefunden oder nicht deins")
events.senden("watched")
return {"removed": entfernt}
@app.delete("/api/suggestions/alle")
def runde_zuruecksetzen():
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone
from urllib.parse import quote_plus
from . import canon, db, imagehash, netflix, teaser, tmdb
from . import canon, db, imagehash, netflix, search, teaser, tmdb
log = logging.getLogger("sync")
@@ -144,6 +144,40 @@ async def resolve_canon():
return places
async def katalog_nachtragen():
"""Filme in den Katalog holen, die jemand gemerkt hat, die aber weder
auf Netflix laufen noch im Kanon stehen.
Ohne sie bleiben die Karten in Sammlung, Rad und Gesehen leer: kein
Teaser, keine Note, keine Bilder. Die Detailansicht holt sich das
jedes Mal neu von TMDB — hier landet es einmal in der Datenbank.
"""
with db.connect() as conn:
fehlen = sorted(
db.benutzte_ids(conn)
- {r["id"] for r in conn.execute("SELECT id FROM movies")}
)
if not fehlen:
return 0
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
async def hole(movie_id):
async with bremse:
try:
return await search.detail(movie_id)
except Exception:
# Ein gelöschter TMDB-Eintrag soll den Rest nicht aufhalten
return None
filme = [f for f in await asyncio.gather(*map(hole, fehlen)) if f]
with db.connect() as conn:
for film in filme:
db.upsert(conn, film)
log.info("%s Filme aus Sammlungen in den Katalog geholt", len(filme))
return len(filme)
async def run():
log.info("Sync startet")
canon_places = await resolve_canon()
@@ -180,15 +214,18 @@ async def run():
db.set_meta(conn, "movie_count", len(on_netflix))
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
nachgetragen = await katalog_nachtragen()
log.info(
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
"%s Netflix-Links, %s neue Teaser, %s entfernt",
"%s Netflix-Links, %s neue Teaser, %s entfernt, %s nachgetragen",
len(movies),
len(on_netflix),
sum(1 for m in movies if m["canon_group"] is not None),
linked,
new_teasers,
removed,
nachgetragen,
)
return {
"movies": len(movies),
@@ -196,6 +233,7 @@ async def run():
"netflix_links": linked,
"new_teasers": new_teasers,
"removed": removed,
"nachgetragen": nachgetragen,
}

View File

@@ -1,8 +1,12 @@
"""Die Runde: wer mitmacht, und wer gerade wer ist.
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
Kein Passwort — für acht Freunde, die sich ohnehin über Discord hören, wäre
das nur eine Hürde ohne Nutzen.
Wer nicht will, dass ein anderer in seinem Namen bewertet, hinterlegt in
der Verwaltung einen Anmeldefilm. Dann muss man ihn beim Wechsel erst in
der Suche finden und anklicken. Kein Passwort im Klartext, nichts zu
merken außer einem Film — und für einen Filmabend unter Freunden genau die
richtige Hürde. Wer keinen Film hinterlegt, ist weiter offen für alle.
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
@@ -26,18 +30,37 @@ CREATE TABLE IF NOT EXISTS user_sessions (
user_id INTEGER NOT NULL,
seit TEXT NOT NULL
);
-- Wer den Hostmodus eingeschaltet hat. Der Host führt durch den Abend und
-- wechselt die Namen frei, ohne jedes Mal den Anmeldefilm zu suchen.
CREATE TABLE IF NOT EXISTS host_sessions (
session_id TEXT PRIMARY KEY,
seit TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
# Der Anmeldefilm kam später dazu
spalten = {r[1] for r in conn.execute("PRAGMA table_info(users)")}
if "auth_movie_id" not in spalten:
conn.execute("ALTER TABLE users ADD COLUMN auth_movie_id INTEGER")
def _oeffentlich(row):
"""Der hinterlegte Film verlässt den Server nie — sonst stünde das
Passwort im Netzwerk-Tab. Nach draußen geht nur, ob einer gesetzt ist."""
benutzer = dict(row)
benutzer["geschuetzt"] = benutzer.pop("auth_movie_id", None) is not None
return benutzer
def alle():
with connect() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
return [dict(r) for r in rows]
return [_oeffentlich(r) for r in rows]
def anlegen(name):
@@ -59,7 +82,24 @@ def anlegen(name):
def von_name(name):
with connect() as conn:
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def schutz_setzen(user_id, movie_id):
"""Anmeldefilm hinterlegen oder mit None wieder freigeben."""
with connect() as conn:
return conn.execute(
"UPDATE users SET auth_movie_id = ? WHERE id = ?", (movie_id, user_id)
).rowcount
def schutz_von(user_id):
"""Die hinterlegte Film-Kennung, oder None bei offenem Namen."""
with connect() as conn:
row = conn.execute(
"SELECT auth_movie_id FROM users WHERE id = ?", (user_id,)
).fetchone()
return row["auth_movie_id"] if row else None
def entfernen(user_id):
@@ -84,7 +124,7 @@ def waehlen(session_id, user_id):
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
return dict(da)
return _oeffentlich(da)
def abwaehlen(session_id):
@@ -103,7 +143,32 @@ def aktuell(session_id):
"WHERE s.session_id = ?",
(session_id,),
).fetchone()
return dict(row) if row else None
return _oeffentlich(row) if row else None
def ist_host(session_id):
with connect() as conn:
return bool(
conn.execute(
"SELECT 1 FROM host_sessions WHERE session_id = ?", (session_id,)
).fetchone()
)
def host_setzen(session_id, an):
"""Den Hostmodus merkt sich auch der Server — sonst könnte er den
Anmeldefilm nicht überspringen."""
with connect() as conn:
if an:
conn.execute(
"INSERT OR REPLACE INTO host_sessions (session_id, seit) VALUES (?, ?)",
(session_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
else:
conn.execute(
"DELETE FROM host_sessions WHERE session_id = ?", (session_id,)
)
return an
def besitzer(session_id):

View File

@@ -1,13 +1,16 @@
"""Bereits geschaute Filme und ihre Bewertungen.
"""Bereits geschaute Filme — das Buch der Abende.
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
freiwillig, ohne ihn zählt die Stimme trotzdem.
Der Host trägt ein, was am Abend lief. Jeder Film bekommt eine Seite mit
zwei Teilen. Links die Momentaufnahme: eine Wertung je Person, nur Sterne,
so wie es direkt nach dem Abspann war. Rechts die Diskussion: Kommentare,
auf die man antworten kann, so tief man will.
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
auch wenn der Film später aus dem Katalog fällt.
auch wenn der Film später aus dem Katalog fällt. Szenenbild und Laufzeit
kommen aus dem Katalog.
"""
import json
from datetime import datetime, timezone
from . import users
@@ -39,16 +42,54 @@ CREATE TABLE IF NOT EXISTS ratings (
);
"""
# Die Diskussion: ein Kommentar zum Abend, oder mit parent_id eine
# Antwort auf einen anderen Kommentar — beliebig tief
NOTES = """
CREATE TABLE IF NOT EXISTS watched_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
parent_id INTEGER,
session_id TEXT NOT NULL,
user_id INTEGER,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_TEXT = 4000
MAX_KOMMENTAR = 4000
# Was das Gästebuch aus dem Katalog dazuholt
AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
def init():
with connect() as conn:
conn.executescript(SCHEMA + RATINGS)
conn.executescript(SCHEMA + RATINGS + NOTES)
# Eine Stimme je Person und Film
users.je_person(conn, "ratings", RATINGS, "watched_id")
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
conn.execute("DROP TABLE IF EXISTS watched_hearts")
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched_notes)")}
if "rating_id" in spalten:
conn.execute("ALTER TABLE watched_notes RENAME COLUMN rating_id TO parent_id")
# Eine Weile stand ein Text an der Wertung. Seit die Sterne allein
# die Wertung sind, gehört er in die Diskussion — einmalig umziehen.
alte = conn.execute(
"SELECT * FROM ratings WHERE text IS NOT NULL AND text != ''"
).fetchall()
for r in alte:
conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, NULL, ?, ?, ?, ?)",
(r["watched_id"], r["session_id"], r["user_id"], r["text"], r["created_at"]),
)
if alte:
conn.execute("UPDATE ratings SET text = NULL")
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
if "hidden" not in spalten:
@@ -66,43 +107,80 @@ def _jetzt():
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
filme = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
f"SELECT w.*, {felder} FROM watched w "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.watched_at DESC, w.id DESC"
).fetchall()
stimmen = conn.execute(
"SELECT id, watched_id, session_id, user_id, stars, name, text "
"SELECT id, watched_id, session_id, user_id, stars, name, created_at "
"FROM ratings ORDER BY created_at ASC"
).fetchall()
notizen = conn.execute(
"SELECT n.*, u.name FROM watched_notes n "
"LEFT JOIN users u ON u.id = n.user_id "
"ORDER BY n.created_at ASC"
).fetchall()
ich = besitzer(session_id)
def notiz(n):
return {
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
}
# Die Kommentare eines Films als Baum: jede Antwort hängt sich an
# ihren Vater, was keinen hat, steht oben. Die Liste kommt nach
# Zeit sortiert, also ist der Vater immer schon da.
knoten = {n["id"]: notiz(n) for n in notizen}
kommentare = {}
for n in notizen:
k = knoten[n["id"]]
vater = knoten.get(n["parent_id"]) if n["parent_id"] is not None else None
if vater:
vater["replies"].append(k)
else:
kommentare.setdefault(n["watched_id"], []).append(k)
je_film = {}
for s in stimmen:
je_film.setdefault(s["watched_id"], []).append(s)
out = []
for film in filme:
for nummer, film in zip(range(len(filme), 0, -1), filme):
eigene = je_film.get(film["id"], [])
werte = [s["stars"] for s in eigene]
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
daten = dict(film)
daten["images"] = json.loads(daten["images"]) if daten["images"] else []
daten["genres"] = json.loads(daten["genres"]) if daten["genres"] else []
out.append(
{
**dict(film),
**daten,
"nummer": nummer,
"ratings": [
{
"id": s["id"],
"stars": s["stars"],
"name": s["name"],
"text": s["text"],
"created_at": s["created_at"],
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
],
"notes": kommentare.get(film["id"], []),
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
"mine": meine["stars"] if meine else None,
"mine_text": meine["text"] if meine else None,
}
)
return out
@@ -130,7 +208,9 @@ def markieren(movie):
def entfernen(watched_id):
"""Samt Wertungen und Kommentaren."""
with connect() as conn:
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
return conn.execute(
"DELETE FROM watched WHERE id = ?", (watched_id,)
@@ -156,15 +236,9 @@ def sichtbarkeit(watched_id, versteckt):
).rowcount
def bewerten(watched_id, session_id, stars, name=None, text=None):
"""Sterne und ein optionaler Satz dazu.
Kommt kein Text mit (None), bleibt der bisherige stehen — wer nur die
Sterne nachjustiert, verliert seinen Satz nicht. Ein leerer String
löscht ihn ausdrücklich.
Gibt None zurück, wenn der Film gar nicht in der Liste steht.
"""
def bewerten(watched_id, session_id, stars, name=None):
"""Sterne setzen. Gibt None zurück, wenn der Film nicht in der Liste
steht."""
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
@@ -172,14 +246,6 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
if not da:
return None
ich = besitzer(session_id)
if text is None:
alt = conn.execute(
f"SELECT text FROM ratings WHERE watched_id = ? AND {WEM}",
(watched_id, ich, ich, session_id),
).fetchone()
text = alt["text"] if alt else None
else:
text = text.strip()[:MAX_TEXT] or None
# Eine Stimme je Person — die eigene alte fliegt vorher raus
conn.execute(
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
@@ -187,19 +253,11 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, user_id, stars, name, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
watched_id,
session_id,
ich,
stars,
(name or "").strip()[:40] or None,
text,
_jetzt(),
),
"(watched_id, session_id, user_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
)
return {"watched_id": watched_id, "stars": stars, "text": text}
return {"watched_id": watched_id, "stars": stars}
def stimme_loeschen(rating_id):
@@ -210,6 +268,70 @@ def stimme_loeschen(rating_id):
).rowcount
def anmerken(watched_id, session_id, text, parent_id=None):
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen
anderen. Gibt None zurück, wenn Film oder Vater fehlen."""
text = (text or "").strip()[:MAX_KOMMENTAR]
if not text:
return None
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone()
if not da:
return None
if parent_id is not None:
passt = conn.execute(
"SELECT 1 FROM watched_notes WHERE id = ? AND watched_id = ?",
(parent_id, watched_id),
).fetchone()
if not passt:
return None
cur = conn.execute(
"INSERT INTO watched_notes "
"(watched_id, parent_id, session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(watched_id, parent_id, session_id, besitzer(session_id), text, _jetzt()),
)
return cur.lastrowid
def _nachkommen(conn, note_id):
"""Alle Antworten unter einem Kommentar, beliebig tief."""
gefunden, offen = [], [note_id]
while offen:
kinder = [
r["id"]
for r in conn.execute(
f"SELECT id FROM watched_notes WHERE parent_id IN "
f"({','.join('?' * len(offen))})",
offen,
)
]
gefunden.extend(kinder)
offen = kinder
return gefunden
def anmerkung_loeschen(note_id, session_id, host):
"""Der Verfasser und der Host dürfen weg damit — samt allem, was
darunter hängt. Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
ich = besitzer(session_id)
with connect() as conn:
zeile = conn.execute(
"SELECT * FROM watched_notes WHERE id = ?", (note_id,)
).fetchone()
if not zeile:
return 0
if not host and not gehoert_mir(zeile, session_id, ich):
return 0
alle_ids = [note_id, *_nachkommen(conn, note_id)]
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

View File

@@ -37,13 +37,20 @@ def init():
users.je_person(conn, "wishlist", SCHEMA, "movie_id")
# Was die Karte in der Detailansicht zeigt. Steht im Katalog, nicht in der
# Sammlung — ein Film, der nie im Katalog war, bleibt hier leer.
AUS_KATALOG = ("genres", "teaser", "overview", "runtime", "vote_average",
"vote_count", "images")
def alle(session_id):
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
rows = conn.execute(
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
"SELECT w.*, u.name, m.genres FROM wishlist w "
f"SELECT w.*, u.name, {felder} FROM wishlist w "
"LEFT JOIN users u ON u.id = w.user_id "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.created_at DESC"
@@ -54,9 +61,10 @@ def alle(session_id):
**{
k: v
for k, v in dict(r).items()
if k not in ("session_id", "user_id", "genres")
if k not in ("session_id", "user_id", "genres", "images")
},
"genres": json.loads(r["genres"]) if r["genres"] else [],
"images": json.loads(r["images"]) if r["images"] else [],
"mine": gehoert_mir(r, session_id, ich),
}
for r in rows

Binary file not shown.

View File

@@ -12,6 +12,8 @@ _ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
os.environ.setdefault("TMDB_TOKEN", "test")
os.environ.setdefault("MINIMAX_KEY", "test")
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@@ -85,6 +87,21 @@ def zuschauer():
return TestClient(main.app)
HOST_SCHLUESSEL = "test-host-schluessel"
@pytest.fixture
def hostmodus():
"""Schaltet einen Client per Hostschlüssel frei."""
def an(client):
return client.post(
"/api/host", json={"an": True, "schluessel": HOST_SCHLUESSEL}
)
return an
@pytest.fixture
def film():
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""

View File

@@ -0,0 +1,156 @@
"""Das Buch der Abende: Wertungen als Momentaufnahme, Kommentare als
Diskussion — verschachtelt, so tief man will."""
def abend(client, film, movie_id=1):
"""Host trägt einen Film ein, gibt die watched_id zurück."""
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
def wertung(client, watched_id, stars=4):
client.post(f"/api/watched/{watched_id}/rating", json={"stars": stars})
film = next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
return next(r for r in film["ratings"] if r["mine"])
def film_von(client, watched_id):
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
def kommentar(client, watched_id, text, parent_id=None):
antwort = client.post(
f"/api/watched/{watched_id}/notes", json={"text": text, "parent_id": parent_id}
)
assert antwort.status_code == 200, antwort.text
return antwort.json()["id"]
def test_laufende_nummer_zaehlt_vom_ersten_abend(client, film):
erster = abend(client, film, 1)
client.patch(f"/api/watched/{erster}", json={"watched_at": "2026-01-01"})
zweiter = abend(client, film, 2)
client.patch(f"/api/watched/{zweiter}", json={"watched_at": "2026-01-08"})
liste = client.get("/api/watched").json()
# Jüngster zuerst, trägt aber die höchste Nummer
assert [(f["id"], f["nummer"]) for f in liste] == [(zweiter, 2), (erster, 1)]
def test_katalogdaten_haengen_am_abend(client):
from app import db
with db.connect() as conn:
db.upsert(conn, {"id": 5, "title": "Alien", "backdrop_path": "/b.jpg",
"images": ["/s1.jpg"], "runtime": 117, "genres": ["Horror"]})
watched_id = client.post("/api/watched", json={"movie_id": 5}).json()["id"]
f = film_von(client, watched_id)
assert f["backdrop_path"] == "/b.jpg"
assert f["images"] == ["/s1.jpg"]
assert f["runtime"] == 117
assert f["genres"] == ["Horror"]
def test_kommentare_bilden_einen_baum(client, client2, film):
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "War super.")
antwort = kommentar(client2, watched_id, "Fand ich nicht.", oben)
tiefer = kommentar(client, watched_id, "Doch!", antwort)
noch_einer = kommentar(client2, watched_id, "Anderes Thema.")
notes = film_von(client, watched_id)["notes"]
assert [n["text"] for n in notes] == ["War super.", "Anderes Thema."]
assert notes[0]["replies"][0]["text"] == "Fand ich nicht."
assert notes[0]["replies"][0]["name"] == "Ben"
assert notes[0]["replies"][0]["mine"] is False
assert notes[0]["replies"][0]["replies"][0]["text"] == "Doch!"
assert notes[0]["replies"][0]["replies"][0]["mine"] is True
assert notes[1]["replies"] == []
assert (oben, antwort, tiefer, noch_einer) == tuple(sorted((oben, antwort, tiefer, noch_einer)))
def test_zehn_ebenen_tief(client, film):
watched_id = abend(client, film)
vater = None
for stufe in range(10):
vater = kommentar(client, watched_id, f"Stufe {stufe}", vater)
knoten = film_von(client, watched_id)["notes"][0]
tiefe = 0
while knoten["replies"]:
knoten = knoten["replies"][0]
tiefe += 1
assert tiefe == 9
assert knoten["text"] == "Stufe 9"
def test_leerer_text_oder_fremder_vater_werden_abgelehnt(client, film):
watched_id = abend(client, film, 1)
anderer = abend(client, film, 2)
dort = kommentar(client, anderer, "woanders")
assert client.post(f"/api/watched/{watched_id}/notes", json={"text": " "}).status_code == 400
# Der Vater gehört zu einem anderen Abend
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": dort}
).status_code == 400
assert client.post(
f"/api/watched/{watched_id}/notes", json={"text": "x", "parent_id": 9999}
).status_code == 400
def test_wertungen_und_kommentare_sind_unabhaengig(client, film):
watched_id = abend(client, film)
wertung(client, watched_id, 5)
kommentar(client, watched_id, "Diskussion.")
f = film_von(client, watched_id)
assert f["ratings"][0]["stars"] == 5
assert "text" not in f["ratings"][0]
assert [n["text"] for n in f["notes"]] == ["Diskussion."]
def test_eigenen_kommentar_loeschen_fremden_nicht(client, client2, film):
watched_id = abend(client, film)
meiner = kommentar(client, watched_id, "meiner")
assert client2.delete(f"/api/watched-notes/{meiner}").status_code == 404
assert client.delete(f"/api/watched-notes/{meiner}").json()["removed"] == 1
assert film_von(client, watched_id)["notes"] == []
def test_loeschen_nimmt_den_ganzen_ast_mit(client, client2, film):
"""Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
watched_id = abend(client, film)
oben = kommentar(client, watched_id, "a")
mitte = kommentar(client2, watched_id, "b", oben)
unten = kommentar(client, watched_id, "c", mitte)
daneben = kommentar(client2, watched_id, "d")
assert client.delete(f"/api/watched-notes/{oben}").json()["removed"] == 3
notes = film_von(client, watched_id)["notes"]
assert [n["id"] for n in notes] == [daneben]
def test_host_loescht_jeden_kommentar(client, zuschauer, hostmodus, film):
watched_id = abend(client, film)
note_id = kommentar(client, watched_id, "x")
hostmodus(zuschauer)
assert zuschauer.delete(f"/api/watched-notes/{note_id}").json()["removed"] == 1
def test_film_loeschen_raeumt_alles(client, client2, film):
from app import db
watched_id = abend(client, film)
wertung(client, watched_id, 5)
oben = kommentar(client2, watched_id, "frei")
kommentar(client, watched_id, "antwort", oben)
client.delete(f"/api/watched/{watched_id}")
with db.connect() as conn:
for tabelle in ("ratings", "watched_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0, tabelle
def test_zuschauer_darf_nicht_schreiben(zuschauer, client, film):
watched_id = abend(client, film)
assert zuschauer.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).status_code == 403

185
backend/tests/test_login.py Normal file
View File

@@ -0,0 +1,185 @@
"""Anmelden per Film.
Wer einen Anmeldefilm hinterlegt hat, muss ihn beim Namenswechsel in der
Suche finden. Ohne Film bleibt der Name offen für alle.
"""
def namen(client, name="Anna"):
"""Legt den Namen an und gibt seine Kennung zurück."""
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
return antwort.json()["id"]
runde = client.get("/api/users").json()["items"]
return next(u["id"] for u in runde if u["name"] == name)
def test_ohne_film_bleibt_der_name_offen(client2, film):
user_id = namen(client2, "Offen")
antwort = client2.post("/api/users/waehlen", json={"user_id": user_id})
assert antwort.status_code == 200
assert antwort.json()["mine"]["geschuetzt"] is False
def test_falscher_film_kommt_nicht_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Ben kennt den Film nicht
assert (
client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": film(7)}
).status_code
== 403
)
# Ganz ohne Film auch nicht
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 403
)
assert client2.get("/api/users").json()["mine"]["name"] == "Ben"
def test_richtiger_film_laesst_durch(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
antwort = client2.post(
"/api/users/waehlen", json={"user_id": user_id, "movie_id": 42}
)
assert antwort.status_code == 200
assert antwort.json()["mine"]["name"] == "Anna"
assert client2.get("/api/users").json()["mine"]["name"] == "Anna"
def test_der_film_verlaesst_den_server_nicht(client, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
runde = client.get("/api/users").json()
anna = next(u for u in runde["items"] if u["id"] == user_id)
assert anna["geschuetzt"] is True
assert "auth_movie_id" not in anna
assert "auth_movie_id" not in runde["mine"]
def test_aufheben_macht_den_namen_wieder_frei(client, client2, film):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": None})
assert client.get("/api/users").json()["items"][0]["geschuetzt"] is False
assert (
client2.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_host_braucht_den_film_nicht(client, zuschauer, film, hostmodus):
user_id = namen(client, "Anna")
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
hostmodus(zuschauer)
assert zuschauer.get("/api/users").json()["host"] is True
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 200
)
# Hostmodus aus: die Hürde steht wieder
zuschauer.post("/api/users/waehlen", json={"user_id": None})
zuschauer.post("/api/host", json={"an": False})
assert (
zuschauer.post("/api/users/waehlen", json={"user_id": user_id}).status_code
== 403
)
def test_wer_schon_angemeldet_ist_muss_nicht_nochmal(client, film):
user_id = namen(client, "Anna")
client.post("/api/users/waehlen", json={"user_id": user_id})
client.post(f"/api/users/{user_id}/schutz", json={"movie_id": film(42)})
# Derselbe Name in derselben Sitzung, etwa nach einem Neuladen
assert (
client.post("/api/users/waehlen", json={"user_id": user_id}).status_code == 200
)
def test_fremden_schutz_darf_man_nicht_setzen(client, client2, film):
anna = namen(client, "Anna")
assert (
client2.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 403
)
assert client2.get("/api/users").json()["items"][0]["geschuetzt"] is False
def test_eigenen_schutz_darf_man_setzen(client, film):
"""client ist als Anna angemeldet und ändert ihren eigenen Film."""
anna = client.get("/api/users").json()["mine"]["id"]
assert (
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)}).status_code
== 200
)
def test_hinterlegten_film_darf_man_nachschlagen(client, film):
"""Wer ihn ändern darf, darf ihn auch sehen — sonst rät man beim
Überschreiben."""
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
antwort = client.get(f"/api/users/{anna}/schutz")
assert antwort.status_code == 200
assert antwort.json()["film"]["title"] == "Alien"
def test_ohne_film_kommt_nichts_zurueck(client):
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get(f"/api/users/{anna}/schutz").json()["film"] is None
def test_fremden_film_darf_man_nicht_nachschlagen(client, client2, film):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42)})
assert client2.get(f"/api/users/{anna}/schutz").status_code == 403
def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus):
anna = client.get("/api/users").json()["mine"]["id"]
client.post(f"/api/users/{anna}/schutz", json={"movie_id": film(42, "Alien")})
hostmodus(zuschauer)
assert zuschauer.get(f"/api/users/{anna}/schutz").json()["film"]["title"] == "Alien"
def test_ohne_schluessel_kein_hostmodus(zuschauer):
"""Die Tastenkombination allein reicht nicht mehr."""
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_falscher_schluessel_kein_hostmodus(zuschauer):
antwort = zuschauer.post(
"/api/host", json={"an": True, "schluessel": "geraten"}
)
assert antwort.status_code == 403
assert zuschauer.get("/api/host").json()["host"] is False
def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus):
assert hostmodus(zuschauer).json()["host"] is True
assert zuschauer.get("/api/host").json()["host"] is True
# Die Freischaltung hält, ein zweiter Aufruf braucht den Schlüssel nicht
assert zuschauer.post("/api/host", json={"an": True}).status_code == 200
assert zuschauer.get("/api/host").json()["host"] is True
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus):
hostmodus(zuschauer)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# Jetzt ist die Sitzung wieder gewöhnlich
assert zuschauer.post("/api/host", json={"an": True}).status_code == 403
def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus):
hostmodus(zuschauer)
assert client2.get("/api/host").json()["host"] is False

View File

@@ -1,6 +1,5 @@
"""Wertungen: Sterne, Text, Schnitt."""
from app.watched import MAX_TEXT
def gesehen(client, film, movie_id=1):
@@ -9,42 +8,47 @@ def gesehen(client, film, movie_id=1):
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
def test_wertung_mit_text(client, film):
wid = gesehen(client, film)
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"}
[eintrag] = client.get("/api/watched").json()
def test_wertung_ist_nur_sterne(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
assert antwort.json() == {"watched_id": wid, "stars": 4}
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 4
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert eintrag["mine"] == 4
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["ratings"][0]["mine"] is True
assert "text" not in eintrag["ratings"][0]
assert "mine_text" not in eintrag
def test_text_none_behaelt_leer_loescht(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
# Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme
client.post(f"/api/watched/{wid}/rating", json={"stars": 3})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine"] == 3
assert eintrag["mine_text"] == "Gruselig"
def test_neue_sterne_ersetzen_die_alten(client, film):
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
client.post(f"/api/watched/{wid}/rating", json={"stars": 2})
eintrag = client.get("/api/watched").json()[0]
assert eintrag["mine"] == 2
assert eintrag["count"] == 1
client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine_text"] is None
def test_alter_wertungstext_zieht_in_die_diskussion(client, film):
"""Vor dem Umbau hing ein Text an den Sternen. Beim Start wandert er
als Kommentar in die Diskussion, mit Verfasser und Datum."""
from app import db, watched
def test_text_wird_gekuerzt(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)})
wid = client.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
client.post(f"/api/watched/{wid}/rating", json={"stars": 5})
with db.connect() as conn:
conn.execute("UPDATE ratings SET text = 'Alte Rezension'")
watched.init()
[eintrag] = client.get("/api/watched").json()
assert len(eintrag["mine_text"]) == MAX_TEXT
f = client.get("/api/watched").json()[0]
assert [n["text"] for n in f["notes"]] == ["Alte Rezension"]
assert f["notes"][0]["name"] == "Anna"
assert f["notes"][0]["mine"] is True
with db.connect() as conn:
assert conn.execute("SELECT COUNT(*) FROM ratings WHERE text IS NOT NULL").fetchone()[0] == 0
# Ein zweiter Start zieht nichts doppelt um
watched.init()
assert len(client.get("/api/watched").json()[0]["notes"]) == 1
def test_fremde_wertung_ist_nicht_meine(client, client2, film):

View File

@@ -0,0 +1,110 @@
"""Die Sammlungen.
Alle sehen alle, ändern darf jeder nur die eigene. Die Karten im
Sammlungen-Reiter zeigen dieselben Daten wie im Katalog — die holt die
Liste per Join nach.
"""
from app import db
def katalogfilm(movie_id, **felder):
"""Film mit allem, was die Detailkarte zeigt."""
with db.connect() as conn:
db.upsert(
conn,
{
"id": movie_id,
"title": "Alien",
"year": 1979,
"teaser": "Im Weltall hört dich niemand schreien.",
"overview": "Die Nostromo fängt ein Signal auf.",
"runtime": 117,
"vote_average": 8.2,
"vote_count": 14000,
"genres": ["Horror", "Science Fiction"],
"images": ["/a.jpg", "/b.jpg"],
**felder,
},
)
return movie_id
def test_karte_bekommt_die_katalogdaten(client):
client.post("/api/wishlist", json={"movie_id": katalogfilm(42)})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["teaser"].startswith("Im Weltall")
assert eintrag["runtime"] == 117
assert eintrag["vote_average"] == 8.2
assert eintrag["vote_count"] == 14000
assert eintrag["genres"] == ["Horror", "Science Fiction"]
assert eintrag["images"] == ["/a.jpg", "/b.jpg"]
def test_film_ausserhalb_des_katalogs_bleibt_spaerlich(client, film):
"""Titel und Poster stehen in der Sammlung selbst, der Rest fehlt."""
client.post("/api/wishlist", json={"movie_id": film(7, "Irgendwas")})
eintrag = client.get("/api/wishlist").json()[0]
assert eintrag["title"] == "Irgendwas"
assert eintrag["teaser"] is None
assert eintrag["genres"] == []
assert eintrag["images"] == []
def test_merken_und_wieder_vergessen(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
assert client.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 1
assert client.get("/api/wishlist").json() == []
def test_zweimal_merken_legt_nur_einen_an(client):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client.post("/api/wishlist", json={"movie_id": movie_id})
assert len(client.get("/api/wishlist").json()) == 1
def test_fremde_sammlung_sieht_man_aber_raeumt_sie_nicht(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
# Ben sieht Annas Eintrag, aber er gehört ihm nicht
eintrag = client2.get("/api/wishlist").json()[0]
assert eintrag["name"] == "Anna"
assert eintrag["mine"] is False
# Sein Löschversuch trifft nur die eigene Sammlung, also nichts
assert client2.delete(f"/api/wishlist/{movie_id}").json()["removed"] == 0
assert len(client.get("/api/wishlist").json()) == 1
def test_jeder_hat_seine_eigene_sammlung(client, client2):
movie_id = katalogfilm(42)
client.post("/api/wishlist", json={"movie_id": movie_id})
client2.post("/api/wishlist", json={"movie_id": movie_id})
eintraege = client.get("/api/wishlist").json()
assert len(eintraege) == 2
assert sorted(e["name"] for e in eintraege) == ["Anna", "Ben"]
def test_sync_verschont_was_jemand_gemerkt_hat(client):
"""Der Katalog kennt nur Netflix und den Kanon. Gemerkte Filme müssen
trotzdem drinbleiben — sonst verlieren ihre Karten alle Daten."""
gemerkt = katalogfilm(42)
egal = katalogfilm(99, title="Niemandsfilm")
client.post("/api/wishlist", json={"movie_id": gemerkt})
with db.connect() as conn:
# Der Sync behält nur, was er gerade geholt hat — hier gar nichts
weg = db.drop_missing(conn, [])
uebrig = {r["id"] for r in conn.execute("SELECT id FROM movies")}
assert weg == 1
assert uebrig == {gemerkt}
assert egal not in uebrig