This commit is contained in:
team3
2026-09-29 00:54:58 +02:00
parent f7c9017946
commit f80b8c5826
20 changed files with 997 additions and 77 deletions

View File

@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
from . import (
backup,
punkte,
db,
events,
features,
@@ -364,8 +365,9 @@ def mitglied(sid):
return ich
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
letzter_dreh = {"ergebnis": None, "start": 0.0}
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen
# kann — samt Anwesenheit der Runde, die beim Eintragen am Abend landet
letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []}
@app.get("/api/events")
@@ -394,6 +396,7 @@ def drehen():
letzter_dreh["ergebnis"] = ergebnis
letzter_dreh["start"] = time.monotonic()
letzter_dreh["teilnehmer"] = suggestions.teilnehmer()
events.senden("spin", ergebnis)
return {"started": True, **ergebnis}
@@ -418,6 +421,7 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
sid = sitzung(response, filmabend_sid)
eintraege = suggestions.alle()
ich = users.besitzer(sid)
dabei = suggestions.dabei_alle()
return {
"items": [
{k: v for k, v in e.items() if k not in ("session_id", "user_id")}
@@ -428,9 +432,22 @@ def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(Non
(e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)),
None,
),
# Wer ohne Vorschlag dabei ist
"dabei": [d["name"] or "Anonym" for d in dabei],
"dabei_mine": any(users.gehoert_mir(d, sid, ich) for d in dabei),
}
@app.post("/api/dabei")
def dabei(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Dabei ohne Vorschlag — an oder wieder aus."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = suggestions.dabei_umschalten(sid)
events.senden("suggestions")
return {"dabei": steht}
@app.post("/api/suggestions")
async def vorschlagen(
response: Response,
@@ -595,8 +612,10 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
staende = punkte.alle()
leer = punkte.stand(0)
return {
"items": users.alle(),
"items": [{**u, **staende.get(u["id"], leer)} for u in users.alle()],
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@@ -739,17 +758,45 @@ def name_waehlen(
@app.get("/api/watched")
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return watched.alle(sid)
return watched.alle(sid, users.ist_host(sid))
@app.post("/api/watched")
async def als_gesehen(movie_id: int = Body(..., embed=True)):
"""Der Host trägt ein, was am Abend lief."""
"""Der Host trägt ein, was am Abend lief. Ist es der gezogene Film,
hängt die Runde vom Rad als Anwesenheit am Abend."""
eintrag = watched.markieren(await film_holen(movie_id))
gezogen = letzter_dreh["ergebnis"]
if gezogen and gezogen["gewinner"]["movie_id"] == movie_id and letzter_dreh["teilnehmer"]:
watched.dabei_setzen(eintrag["id"], letzter_dreh["teilnehmer"])
events.senden("watched")
return eintrag
@app.post("/api/watched/{watched_id}/dabei")
def dabei_aendern(watched_id: int, user_ids: list[int] = Body(..., embed=True)):
"""Der Host berichtigt, wer dabei war — etwa wer spät kam."""
watched.dabei_setzen(watched_id, user_ids)
events.senden("watched")
return {"dabei": user_ids}
@app.post("/api/watched/hearts")
def herz_setzen(
response: Response,
note_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Klick setzt das Herz, der nächste nimmt es zurück."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = watched.herz(sid, note_id)
if steht is None:
raise HTTPException(status_code=404, detail="Kommentar nicht gefunden")
events.senden("watched")
return {"hearted": steht}
@app.delete("/api/watched/{watched_id}")
def gesehen_entfernen(watched_id: int):
entfernt = watched.entfernen(watched_id)

75
backend/app/punkte.py Normal file
View File

@@ -0,0 +1,75 @@
"""Erfahrung und Level — berechnet, nicht gebucht.
Punkte sind eine Formel über dem, was gerade in der Datenbank steht:
wer dabei war, bewertet, kommentiert, geantwortet und ein Herz gegeben
hat. Nimmt jemand seine Wertung zurück, sind die Punkte weg. Es gibt
kein Konto, das man pflegen oder austricksen könnte.
Je Film zählt jede Art nur einmal. Level nach Dreieckszahlen: Level L
ist erreicht, sobald L·(L−1)/2 Punkte da sind — anfangs schnell, dann
stetig langsamer, aber immer in Reichweite.
"""
from math import isqrt
from .db import connect
SCHAUEN = 10
BEWERTEN = 3
KOMMENTAR = 5
ANTWORT = 2
HERZ = 1
ZAEHLUNGEN = (
(SCHAUEN, "SELECT user_id, COUNT(*) AS n FROM watched_dabei GROUP BY user_id"),
(
BEWERTEN,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM ratings "
"WHERE user_id IS NOT NULL GROUP BY user_id",
),
(
KOMMENTAR,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
"WHERE user_id IS NOT NULL AND parent_id IS NULL GROUP BY user_id",
),
(
ANTWORT,
"SELECT user_id, COUNT(DISTINCT watched_id) AS n FROM watched_notes "
"WHERE user_id IS NOT NULL AND parent_id IS NOT NULL GROUP BY user_id",
),
(
HERZ,
"SELECT h.user_id, COUNT(DISTINCT n.watched_id) AS n FROM watched_hearts h "
"JOIN watched_notes n ON n.id = h.note_id "
"WHERE h.user_id IS NOT NULL GROUP BY h.user_id",
),
)
def level(punkte):
"""Größtes L mit L·(L−1)/2 ≤ Punkte. Null Punkte sind Level 1."""
stufe = (1 + isqrt(1 + 8 * punkte)) // 2
while stufe * (stufe - 1) // 2 > punkte:
stufe -= 1
return max(1, stufe)
def stand(punkte):
stufe = level(punkte)
return {
"punkte": punkte,
"level": stufe,
# Wo das aktuelle Level begann und wo das nächste anfängt — für den Balken
"von": stufe * (stufe - 1) // 2,
"bis": stufe * (stufe + 1) // 2,
}
def alle():
"""Punktestand je user_id."""
summen = {}
with connect() as conn:
for wert, sql in ZAEHLUNGEN:
for row in conn.execute(sql):
summen[row["user_id"]] = summen.get(row["user_id"], 0) + wert * row["n"]
return {user_id: stand(p) for user_id, p in summen.items()}

View File

@@ -32,6 +32,17 @@ CREATE TABLE IF NOT EXISTS suggestions (
);
"""
# Wer am Abend da ist, aber nichts vorschlagen will. Zusammen mit den
# Vorschlagenden ergibt das die Anwesenheit — die zählt beim Dreh.
DABEI = """
CREATE TABLE IF NOT EXISTS dabei (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
COOKIE = "filmabend_sid"
# So lange läuft die Animation im Browser. Der Server merkt sich das
@@ -54,9 +65,10 @@ BRUCH_ANTEIL = 0.5
def init():
with connect() as conn:
conn.executescript(SCHEMA)
conn.executescript(SCHEMA + DABEI)
# Ein Vorschlag je Person
users.je_person(conn, "suggestions", SCHEMA)
users.je_person(conn, "dabei", DABEI)
def neue_session():
@@ -196,4 +208,40 @@ def loeschen(session_id):
def alles_loeschen():
"""Host setzt die Runde zurück."""
with connect() as conn:
conn.execute("DELETE FROM dabei")
return conn.execute("DELETE FROM suggestions").rowcount
def dabei_umschalten(session_id):
"""Dabei ohne Vorschlag — der zweite Klick nimmt es zurück.
Gibt zurück, ob es jetzt gesetzt ist."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM dabei WHERE {WEM}", (ich, ich, session_id)
).rowcount
if weg:
return False
conn.execute(
"INSERT INTO dabei (session_id, user_id, created_at) VALUES (?, ?, ?)",
(session_id, ich, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
return True
def dabei_alle():
"""Wer sich ohne Vorschlag eingetragen hat — Namen und Kennungen."""
with connect() as conn:
rows = conn.execute(
"SELECT d.*, u.name FROM dabei d LEFT JOIN users u ON u.id = d.user_id "
"ORDER BY d.created_at ASC"
).fetchall()
return [dict(r) for r in rows]
def teilnehmer():
"""user_ids aller Anwesenden: Vorschlagende und Eingetragene."""
with connect() as conn:
ids = {r["user_id"] for r in conn.execute("SELECT user_id FROM suggestions")}
ids |= {r["user_id"] for r in conn.execute("SELECT user_id FROM dabei")}
return sorted(i for i in ids if i is not None)

View File

@@ -56,6 +56,28 @@ CREATE TABLE IF NOT EXISTS watched_notes (
);
"""
# Ein Herz je Person und Kommentar: "sehe ich auch so". Alle sehen die
# Zahl, wer es gegeben hat, sieht nur der Host.
HEARTS = """
CREATE TABLE IF NOT EXISTS watched_hearts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL
);
"""
# Wer am Abend dabei war — kommt aus der Runde am Rad, sobald der Host
# den gezogenen Film einträgt. Daraus rechnen sich die Punkte.
DABEI = """
CREATE TABLE IF NOT EXISTS watched_dabei (
watched_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (watched_id, user_id)
);
"""
MAX_STERNE = 5
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
MAX_KOMMENTAR = 4000
@@ -66,11 +88,10 @@ AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
def init():
with connect() as conn:
conn.executescript(SCHEMA + RATINGS + NOTES)
# Eine Stimme je Person und Film
conn.executescript(SCHEMA + RATINGS + NOTES + HEARTS + DABEI)
# Eine Stimme je Person und Film, ein Herz je Person und Kommentar
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")
users.je_person(conn, "watched_hearts", HEARTS, "note_id")
# 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)")}
@@ -106,9 +127,12 @@ def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
def alle(session_id, host=False):
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme, Kommentaren
und wer dabei war. Die laufende Nummer zählt vom ersten Abend.
Wer wie gewertet und wer ein Herz gegeben hat, sieht nur der Host —
für die Runde bleibt die Wertung anonym, das nimmt Hemmung."""
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
with connect() as conn:
filme = conn.execute(
@@ -125,10 +149,26 @@ def alle(session_id):
"LEFT JOIN users u ON u.id = n.user_id "
"ORDER BY n.created_at ASC"
).fetchall()
herzen = conn.execute(
"SELECT h.note_id, h.session_id, h.user_id, u.name FROM watched_hearts h "
"LEFT JOIN users u ON u.id = h.user_id ORDER BY h.created_at ASC"
).fetchall()
anwesend = conn.execute(
"SELECT d.watched_id, u.id, u.name FROM watched_dabei d "
"JOIN users u ON u.id = d.user_id ORDER BY u.name COLLATE NOCASE"
).fetchall()
ich = besitzer(session_id)
herz_je_notiz = {}
for h in herzen:
herz_je_notiz.setdefault(h["note_id"], []).append(h)
dabei_je_film = {}
for d in anwesend:
dabei_je_film.setdefault(d["watched_id"], []).append({"id": d["id"], "name": d["name"]})
def notiz(n):
meine_herzen = herz_je_notiz.get(n["id"], [])
return {
"id": n["id"],
"text": n["text"],
@@ -136,6 +176,9 @@ def alle(session_id):
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
"replies": [],
"hearts": len(meine_herzen),
"hearted": any(gehoert_mir(h, session_id, ich) for h in meine_herzen),
"hearted_by": [h["name"] or "Anonym" for h in meine_herzen] if host else [],
}
# Die Kommentare eines Films als Baum: jede Antwort hängt sich an
@@ -176,7 +219,10 @@ def alle(session_id):
"mine": gehoert_mir(s, session_id, ich),
}
for s in eigene
],
]
if host
else [],
"dabei": dabei_je_film.get(film["id"], []),
"notes": kommentare.get(film["id"], []),
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
@@ -207,9 +253,31 @@ def markieren(movie):
return dict(row)
def entfernen(watched_id):
"""Samt Wertungen und Kommentaren."""
def dabei_setzen(watched_id, user_ids):
"""Die Anwesenheit eines Abends — ersetzt die bisherige Liste."""
with connect() as conn:
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
conn.executemany(
"INSERT OR IGNORE INTO watched_dabei (watched_id, user_id) VALUES (?, ?)",
[(watched_id, i) for i in user_ids],
)
def _herzen_weg(conn, note_ids):
conn.executemany(
"DELETE FROM watched_hearts WHERE note_id = ?", [(i,) for i in note_ids]
)
def entfernen(watched_id):
"""Samt Wertungen, Kommentaren, Herzen und Anwesenheit."""
with connect() as conn:
notizen = [
r["id"]
for r in conn.execute("SELECT id FROM watched_notes WHERE watched_id = ?", (watched_id,))
]
_herzen_weg(conn, notizen)
conn.execute("DELETE FROM watched_dabei WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
return conn.execute(
@@ -326,12 +394,35 @@ def anmerkung_loeschen(note_id, session_id, host):
if not host and not gehoert_mir(zeile, session_id, ich):
return 0
alle_ids = [note_id, *_nachkommen(conn, note_id)]
_herzen_weg(conn, alle_ids)
conn.executemany(
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
)
return len(alle_ids)
def herz(session_id, note_id):
"""Herz setzen oder zurücknehmen. None, wenn es den Kommentar nicht
gibt, sonst ob das Herz jetzt steht."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM watched_hearts WHERE note_id = ? AND {WEM}",
(note_id, ich, ich, session_id),
).rowcount
if weg:
return False
da = conn.execute("SELECT 1 FROM watched_notes WHERE id = ?", (note_id,)).fetchone()
if not da:
return None
conn.execute(
"INSERT INTO watched_hearts (note_id, session_id, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(note_id, session_id, ich, _jetzt()),
)
return True
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:

Binary file not shown.

View File

@@ -9,8 +9,7 @@ def abend(client, film, movie_id=1):
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"])
return next(f for f in client.get("/api/watched").json() if f["id"] == watched_id)
def film_von(client, watched_id):
@@ -103,8 +102,9 @@ def test_wertungen_und_kommentare_sind_unabhaengig(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 f["average"] == 5.0 and f["mine"] == 5
# Die Namen an den Wertungen sieht nur der Host
assert f["ratings"] == []
assert [n["text"] for n in f["notes"]] == ["Diskussion."]

View File

@@ -0,0 +1,106 @@
"""Erfahrung und Level: berechnet aus dem Bestand, nicht gebucht."""
from app import punkte
def abend(client, film, movie_id=1):
return client.post("/api/watched", json={"movie_id": film(movie_id)}).json()["id"]
def stand(client, name):
return next(u for u in client.get("/api/users").json()["items"] if u["name"] == name)
def test_level_nach_dreieckszahlen():
assert punkte.level(0) == 1
assert punkte.level(1) == 2
assert punkte.level(2) == 2
assert punkte.level(3) == 3
assert punkte.level(20) == 6
assert punkte.level(21) == 7
assert punkte.level(1081) == 47
assert punkte.stand(21) == {"punkte": 21, "level": 7, "von": 21, "bis": 28}
def test_punkte_kommen_und_gehen_mit_dem_bestand(client, client2, film):
watched_id = abend(client, film)
assert stand(client, "Anna")["punkte"] == 0
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN
oben = client.post(f"/api/watched/{watched_id}/notes", json={"text": "gut"}).json()["id"]
client.post(f"/api/watched/{watched_id}/notes", json={"text": "nochmal"})
# Zwei Kommentare, einer zählt
assert stand(client, "Anna")["punkte"] == punkte.BEWERTEN + punkte.KOMMENTAR
client2.post(f"/api/watched/{watched_id}/notes", json={"text": "finde ich nicht", "parent_id": oben})
client2.post("/api/watched/hearts", json={"note_id": oben})
assert stand(client2, "Ben")["punkte"] == punkte.ANTWORT + punkte.HERZ
# Wertung weg, Punkte weg — nichts wird gebucht
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 4})
assert stand(client, "Anna")["level"] == punkte.level(punkte.BEWERTEN + punkte.KOMMENTAR)
client.delete(f"/api/watched/{watched_id}")
assert stand(client, "Anna")["punkte"] == 0
assert stand(client2, "Ben")["punkte"] == 0
def test_anwesenheit_kommt_vom_rad(client, client2, zuschauer, hostmodus, film):
"""Wer vorschlägt oder sich einträgt, war dabei — sobald der Host den
gezogenen Film einträgt."""
from app import main
client.post("/api/suggestions", json={"movie_id": film(1)})
assert client2.post("/api/dabei").json()["dabei"] is True
liste = client2.get("/api/suggestions").json()
assert liste["dabei"] == ["Ben"] and liste["dabei_mine"] is True
hostmodus(zuschauer)
assert zuschauer.post("/api/spin").json()["started"] is True
main.letzter_dreh["start"] = 0.0 # Animation vorbei
watched_id = zuschauer.post("/api/watched", json={"movie_id": film(1)}).json()["id"]
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
assert sorted(d["name"] for d in f["dabei"]) == ["Anna", "Ben"]
assert stand(client, "Anna")["punkte"] == punkte.SCHAUEN
assert stand(client, "Ben")["level"] == punkte.level(punkte.SCHAUEN)
# Der Host berichtigt: Ben kam doch nicht
anna = stand(client, "Anna")["id"]
zuschauer.post(f"/api/watched/{watched_id}/dabei", json={"user_ids": [anna]})
f = next(x for x in client.get("/api/watched").json() if x["id"] == watched_id)
assert [d["name"] for d in f["dabei"]] == ["Anna"]
# Reset leert auch die Eingetragenen
zuschauer.delete("/api/suggestions/alle")
assert client2.get("/api/suggestions").json()["dabei"] == []
assert client2.post("/api/dabei").json()["dabei"] is True
assert client2.post("/api/dabei").json()["dabei"] is False
def test_wertungen_und_herzen_sieht_nur_der_host(client, client2, zuschauer, hostmodus, film):
watched_id = abend(client, film)
client.post(f"/api/watched/{watched_id}/rating", json={"stars": 5})
note_id = client.post(f"/api/watched/{watched_id}/notes", json={"text": "x"}).json()["id"]
client2.post("/api/watched/hearts", json={"note_id": note_id})
def film_von(c):
return next(x for x in c.get("/api/watched").json() if x["id"] == watched_id)
# Ben sieht Schnitt und Herzzahl, aber keine Namen
f = film_von(client2)
assert f["ratings"] == [] and f["average"] == 5.0 and f["count"] == 1
assert f["notes"][0]["hearts"] == 1 and f["notes"][0]["hearted"] is True
assert f["notes"][0]["hearted_by"] == []
# Anna sieht ihre eigenen Sterne trotzdem
assert film_von(client)["mine"] == 5
# Der Host sieht alles
hostmodus(zuschauer)
f = film_von(zuschauer)
assert f["ratings"][0]["name"] == "Anna"
assert f["notes"][0]["hearted_by"] == ["Ben"]
# Herz zurück, Zähler runter; Zuschauer ohne Namen darf kein Herz geben
assert client2.post("/api/watched/hearts", json={"note_id": note_id}).json()["hearted"] is False
assert film_von(client)["notes"][0]["hearts"] == 0
assert client.post("/api/watched/hearts", json={"note_id": 999}).status_code == 404

View File

@@ -16,7 +16,8 @@ def test_wertung_ist_nur_sterne(client, film):
assert eintrag["mine"] == 4
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert "text" not in eintrag["ratings"][0]
# Wer wie gewertet hat, sieht nur der Host
assert eintrag["ratings"] == []
assert "mine_text" not in eintrag
@@ -58,7 +59,8 @@ def test_fremde_wertung_ist_nicht_meine(client, client2, film):
[eintrag] = client2.get("/api/watched").json()
assert eintrag["mine"] is None
assert eintrag["count"] == 1
assert eintrag["ratings"][0]["mine"] is False
assert eintrag["ratings"] == []
assert eintrag["count"] == 1
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
[eintrag] = client2.get("/api/watched").json()