update
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
@@ -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."""
|
||||
|
||||
156
backend/tests/test_gaestebuch.py
Normal file
156
backend/tests/test_gaestebuch.py
Normal 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
185
backend/tests/test_login.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
110
backend/tests/test_wishlist.py
Normal file
110
backend/tests/test_wishlist.py
Normal 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
|
||||
Reference in New Issue
Block a user