Benutzer, Sammlungen, Wünsche und zwei Kartenansichten

Die Runde ist jetzt benannt. Der Host pflegt die Namensliste im neuen Reiter
"Verwaltung", alle anderen wählen sich oben rechts einen aus. Der Name landet
serverseitig an Vorschlägen und Wertungen, statt aus einem Textfeld zu
kommen, in das jeder schreiben konnte, was er wollte.

Dazu zwei Sammelstellen, beide nach Person gruppiert und für alle sichtbar,
aber nur selbst bearbeitbar:
- "Sammlungen": Filme, die man sehen möchte. Getrennt vom Rad-Vorschlag,
  von dem es weiterhin einen je Person gibt.
- "Wünsche": was der Seite noch fehlt. Der Host darf hier alle Einträge
  ändern und löschen.

Die Filmliste hat jetzt zwei Ansichten. Kompakt ist die gewohnte Karte,
Details stellt Cover, Text und vier Szenenbilder nebeneinander — eine Karte
so breit wie vier kompakte oder die halbe Reihe, je nachdem was zuerst
zutrifft. Reihen und Einzelfilme sind dabei exakt gleich hoch; die
Blätterleiste liegt dafür über dem Cover statt darunter.

Die Filmansicht nutzt die Breite: links Cover, Text und Knöpfe, rechts der
Trailer, darunter die Bilder. Ein Klick zeigt ein Bild in voller Größe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
This commit is contained in:
team3
2026-09-03 19:30:48 +02:00
parent 8bd6a4e6b1
commit 9840230250
15 changed files with 1446 additions and 117 deletions

80
backend/app/features.py Normal file
View File

@@ -0,0 +1,80 @@
"""Was der Seite noch fehlt.
Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche,
ändern darf jeder nur die eigenen — wie bei den Filmsammlungen.
"""
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
MAX_LAENGE = 500
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def alle(session_id):
"""Alle Wünsche, jeder mit dem Namen seines Verfassers."""
with connect() as conn:
rows = conn.execute(
"SELECT f.*, u.name FROM features f "
"LEFT JOIN user_sessions s ON s.session_id = f.session_id "
"LEFT JOIN users u ON u.id = s.user_id "
"ORDER BY f.created_at DESC"
).fetchall()
return [
{
**{k: v for k, v in dict(r).items() if k != "session_id"},
"mine": r["session_id"] == session_id,
}
for r in rows
]
def anlegen(session_id, text):
"""Gibt None zurück, wenn nichts dasteht."""
text = (text or "").strip()[:MAX_LAENGE]
if not text:
return None
with connect() as conn:
conn.execute(
"INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)",
(
session_id,
text,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
return alle(session_id)
def aendern(feature_id, text):
"""Der Host räumt auf: Tippfehler, Dopplungen, unklare Formulierungen."""
text = (text or "").strip()[:MAX_LAENGE]
if not text:
return 0
with connect() as conn:
return conn.execute(
"UPDATE features SET text = ? WHERE id = ?", (text, feature_id)
).rowcount
def loeschen(feature_id):
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem
Verfasser und dem Host."""
with connect() as conn:
return conn.execute(
"DELETE FROM features WHERE id = ?", (feature_id,)
).rowcount

View File

@@ -9,7 +9,18 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from . import db, events, search, streamproxy, suggestions, sync, watched
from . import (
db,
events,
features,
search,
streamproxy,
suggestions,
sync,
users,
watched,
wishlist,
)
from .config import IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -71,6 +82,9 @@ async def startup():
db.init()
suggestions.init()
watched.init()
users.init()
wishlist.init()
features.init()
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
@@ -227,13 +241,12 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
async def vorschlagen(
response: Response,
movie_id: int = Body(..., embed=True),
name: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
movie = await film_holen(movie_id)
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
eintrag = suggestions.ersetzen(sid, movie, name)
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
events.senden("suggestions")
return {k: v for k, v in eintrag.items() if k != "session_id"}
@@ -258,6 +271,110 @@ async def film_holen(movie_id: int):
raise HTTPException(status_code=404, detail="Film nicht gefunden")
@app.get("/api/features")
def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return features.alle(sid)
@app.post("/api/features")
def wunsch_anlegen(
response: Response,
text: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
liste = features.anlegen(sid, text)
if liste is None:
raise HTTPException(status_code=400, detail="Kein Text")
events.senden("features")
return liste
@app.patch("/api/features/{feature_id}")
def wunsch_aendern(feature_id: int, text: str = Body(..., embed=True)):
if not features.aendern(feature_id, text):
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
events.senden("features")
return {"ok": True}
@app.delete("/api/features/{feature_id}")
def wunsch_loeschen(feature_id: int):
entfernt = features.loeschen(feature_id)
if entfernt:
events.senden("features")
return {"removed": entfernt}
@app.get("/api/wishlist")
def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Was diese Sitzung sich vorgemerkt hat."""
sid = sitzung(response, filmabend_sid)
return wishlist.alle(sid)
@app.post("/api/wishlist")
async def merken(
response: Response,
movie_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
return wishlist.merken(sid, await film_holen(movie_id))
@app.delete("/api/wishlist/{movie_id}")
def vergessen(
movie_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
return {"removed": wishlist.vergessen(sid, movie_id)}
@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)}
@app.post("/api/users")
def name_anlegen(name: str = Body(..., embed=True)):
"""Der Host erweitert die Runde."""
benutzer = users.anlegen(name)
if not benutzer:
raise HTTPException(status_code=400, detail="Name leer oder schon vergeben")
events.senden("users")
return benutzer
@app.delete("/api/users/{user_id}")
def name_entfernen(user_id: int):
if not users.entfernen(user_id):
raise HTTPException(status_code=404, detail="Unbekannter Name")
events.senden("users")
return {"removed": 1}
@app.post("/api/users/waehlen")
def name_waehlen(
response: Response,
user_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Wer bin ich? Ohne user_id wieder anonym."""
sid = sitzung(response, filmabend_sid)
if user_id is None:
users.abwaehlen(sid)
return {"mine": None}
benutzer = users.waehlen(sid, user_id)
if not benutzer:
raise HTTPException(status_code=404, detail="Unbekannter Name")
return {"mine": benutzer}
@app.get("/api/watched")
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
@@ -303,12 +420,12 @@ def bewerten(
watched_id: int,
response: Response,
stars: int = Body(..., embed=True),
name: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
stars = max(1, min(watched.MAX_STERNE, stars))
eintrag = watched.bewerten(watched_id, sid, stars, name)
# Der Name kommt aus der Runde, nicht aus der Anfrage
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")

112
backend/app/users.py Normal file
View File

@@ -0,0 +1,112 @@
"""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.
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
einem Textfeld, in das jeder schreiben kann, was er will.
"""
import sqlite3
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_sessions (
session_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
seit TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
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]
def anlegen(name):
"""Gibt None zurück, wenn der Name schon vergeben ist."""
name = (name or "").strip()[:40]
if not name:
return None
try:
with connect() as conn:
conn.execute(
"INSERT INTO users (name, created_at) VALUES (?, ?)",
(name, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
except sqlite3.IntegrityError:
return None
return von_name(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
def entfernen(user_id):
with connect() as conn:
conn.execute("DELETE FROM user_sessions WHERE user_id = ?", (user_id,))
return conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount
def waehlen(session_id, user_id):
"""Gibt None zurück, wenn es den Namen nicht gibt."""
with connect() as conn:
da = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
if not da:
return None
conn.execute(
"INSERT INTO user_sessions (session_id, user_id, seit) VALUES (?, ?, ?) "
"ON CONFLICT (session_id) DO UPDATE SET "
"user_id = excluded.user_id, seit = excluded.seit",
(
session_id,
user_id,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
return dict(da)
def abwaehlen(session_id):
with connect() as conn:
return conn.execute(
"DELETE FROM user_sessions WHERE session_id = ?", (session_id,)
).rowcount
def aktuell(session_id):
"""Der Benutzer dieser Sitzung, oder None."""
with connect() as conn:
row = conn.execute(
"SELECT u.* FROM users u "
"JOIN user_sessions s ON s.user_id = u.id "
"WHERE s.session_id = ?",
(session_id,),
).fetchone()
return dict(row) if row else None
def name_von(session_id):
"""Nur der Name, für Vorschläge und Wertungen."""
benutzer = aktuell(session_id)
return benutzer["name"] if benutzer else None

91
backend/app/wishlist.py Normal file
View File

@@ -0,0 +1,91 @@
"""Was jeder für sich sehen möchte.
Alle sehen alle Sammlungen — man merkt so, worauf die anderen Lust haben.
Ändern darf jeder nur die eigene. Der Vorschlag fürs Rad ist eine andere
Sache: davon gibt es einen je Person, und er landet im Glücksrad.
Titel und Poster kommen mit in die Zeile. Ein gemerkter Film soll lesbar
bleiben, auch wenn er nie im Katalog stand oder Netflix ihn abgibt.
"""
import sqlite3
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS wishlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
created_at TEXT NOT NULL,
UNIQUE (session_id, movie_id)
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def alle(session_id):
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
with connect() as conn:
rows = conn.execute(
"SELECT w.*, u.name FROM wishlist w "
"LEFT JOIN user_sessions s ON s.session_id = w.session_id "
"LEFT JOIN users u ON u.id = s.user_id "
"ORDER BY w.created_at DESC"
).fetchall()
return [
{
**{k: v for k, v in dict(r).items() if k != "session_id"},
"mine": r["session_id"] == session_id,
}
for r in rows
]
def merken(session_id, movie):
"""Ein zweites Mal merken ändert nichts."""
try:
with connect() as conn:
conn.execute(
"INSERT INTO wishlist "
"(session_id, movie_id, title, year, poster_path, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
session_id,
movie["id"],
movie["title"],
movie.get("year"),
movie.get("poster_path"),
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
except sqlite3.IntegrityError:
pass
return alle(session_id)
def vergessen(session_id, movie_id):
with connect() as conn:
return conn.execute(
"DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?",
(session_id, movie_id),
).rowcount
def ids(session_id):
"""movie_ids der gemerkten Filme, für die Detailansicht."""
with connect() as conn:
return [
r["movie_id"]
for r in conn.execute(
"SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,)
)
]