This commit is contained in:
team3
2026-10-07 15:41:41 +02:00
parent b65f4d75f4
commit 2f771a9c30
10 changed files with 527 additions and 37 deletions

View File

@@ -21,6 +21,7 @@ from . import (
features,
ki,
search,
spenden,
streamproxy,
suggestions,
sync,
@@ -98,6 +99,7 @@ async def startup():
clips.init()
wishlist.init()
features.init()
spenden.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
if not HOST_SCHLUESSEL:
@@ -714,6 +716,60 @@ def info_setzen(
return {"text": db.get_meta("info_text")}
# ── Spendenbaum ─────────────────────────────────────────────────────────
@app.get("/api/spenden")
def spenden_liste():
return spenden.alle()
@app.post("/api/spenden")
def spende_eintragen(
response: Response,
user_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
spenden.eintragen(user_id)
events.senden("spenden")
return spenden.alle()
@app.get("/api/kontostand")
def kontostand():
"""Was auf dem Filmabend-Konto liegt — der Host trägt die Zahl ein."""
wert = db.get_meta("kontostand")
return {"betrag": float(wert) if wert else None}
@app.put("/api/kontostand")
def kontostand_setzen(
response: Response,
betrag: float | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
with db.connect() as conn:
db.set_meta(conn, "kontostand", "" if betrag is None else round(betrag, 2))
events.senden("spenden")
return kontostand()
@app.delete("/api/spenden/{user_id}")
def spende_entfernen(
user_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if spenden.entfernen(user_id):
events.senden("spenden")
return spenden.alle()
@app.get("/api/features")
def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
@@ -915,7 +971,9 @@ def schutz_setzen(
@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)}
# Ob es einen Host-Film gibt, darf jeder wissen — nur nicht welchen.
# Ohne Film hat die Tastenkombination nichts zu fragen.
return {"host": users.ist_host(sid), "film": users.host_film() is not None}
@app.post("/api/host")
@@ -930,7 +988,8 @@ def host_modus(
Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
fragt danach, wie beim Anmeldefilm eines Namens.
fragt danach, wie beim Anmeldefilm eines Namens. Ist kein Host-Film
gesetzt, reicht die Tastenkombination allein.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
@@ -944,7 +1003,8 @@ def host_modus(
darf = (
users.ist_host(sid)
or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL))
or (film is not None and movie_id == film)
or film is None
or movie_id == film
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")

44
backend/app/spenden.py Normal file
View File

@@ -0,0 +1,44 @@
"""Der Spendenbaum: wer schon einmal etwas überwiesen hat.
Ein Blatt je Person, ohne Betrag. Der Host trägt ein und nimmt weg.
"""
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS spenden (
user_id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL
);
"""
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def alle():
"""Die Blätter, das neueste zuerst."""
with connect() as conn:
rows = conn.execute(
"SELECT s.user_id, u.name, s.created_at FROM spenden s "
"JOIN users u ON u.id = s.user_id ORDER BY s.created_at DESC, s.user_id DESC"
).fetchall()
return [dict(r) for r in rows]
def eintragen(user_id):
"""Ein zweites Mal ändert nichts — ein Blatt je Person."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO spenden (user_id, created_at) VALUES (?, ?)",
(user_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
def entfernen(user_id):
with connect() as conn:
return conn.execute("DELETE FROM spenden WHERE user_id = ?", (user_id,)).rowcount > 0

View File

@@ -19,7 +19,7 @@ os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app import clips, db, features, main, suggestions, users, watched, wishlist # noqa: E402
from app import clips, db, features, main, spenden, suggestions, users, watched, wishlist # noqa: E402
# Zählt, ob der Startup doch einen Sync anstoßen wollte
_SYNC_AUFRUFE = []
@@ -50,7 +50,7 @@ def sync_aufrufe():
def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
db.DB_PATH.unlink(missing_ok=True)
for modul in (db, users, suggestions, watched, clips, wishlist, features):
for modul in (db, users, suggestions, watched, clips, wishlist, features, spenden):
modul.init()
main.letzter_dreh.update(ergebnis=None, start=0.0)

View File

@@ -151,13 +151,25 @@ def test_host_darf_jeden_film_nachschlagen(client, zuschauer, film, hostmodus):
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."""
def hostfilm_setzen(client, hostmodus, movie_id):
"""Ein Host setzt den Host-Film und gibt die Rechte wieder ab."""
hostmodus(client)
client.post("/api/host/film", json={"movie_id": movie_id})
client.post("/api/host", json={"an": False})
def test_ohne_hostfilm_reicht_die_tastenkombination(zuschauer):
assert zuschauer.post("/api/host", json={"an": True}).json()["host"] is True
def test_mit_hostfilm_kein_hostmodus_ohne_film(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
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):
def test_falscher_schluessel_kein_hostmodus(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
antwort = zuschauer.post(
"/api/host", json={"an": True, "schluessel": "geraten"}
)
@@ -173,7 +185,8 @@ def test_schluessel_schaltet_frei_und_bleibt(zuschauer, hostmodus):
assert zuschauer.get("/api/host").json()["host"] is True
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus):
def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus, film):
hostfilm_setzen(zuschauer, hostmodus, film(1))
hostmodus(zuschauer)
assert zuschauer.post("/api/host", json={"an": False}).json()["host"] is False
# Jetzt ist die Sitzung wieder gewöhnlich
@@ -189,9 +202,12 @@ def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
# Nur der Host darf den Host-Film setzen oder sehen
assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403
assert client.get("/api/host/film").status_code == 403
# Ob es einen gibt, sieht jeder — welcher, nicht
assert client.get("/api/host").json() == {"host": False, "film": False}
hostmodus(zuschauer)
assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True
assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1)
assert client.get("/api/host").json() == {"host": False, "film": True}
# Falscher Film: nichts; richtiger Film: Host
assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403
@@ -199,8 +215,8 @@ def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True
assert client.get("/api/host").json()["host"] is True
# Aufheben: der Film öffnet nichts mehr
# Aufheben: ohne Host-Film reicht die Tastenkombination
zuschauer.post("/api/host/film", json={"movie_id": None})
assert zuschauer.get("/api/host/film").json()["film"] is None
client.post("/api/host", json={"an": False})
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).status_code == 403
assert client.post("/api/host", json={"an": True}).json()["host"] is True

View File

@@ -0,0 +1,27 @@
"""Der Spendenbaum: ein Blatt je Person, nur der Host pflegt ihn."""
def test_ein_blatt_je_person_nur_vom_host(client, zuschauer, hostmodus, anmelden):
anmelden(client, "Anna")
anna = client.get("/api/users").json()["mine"]["id"]
assert client.get("/api/spenden").json() == []
assert client.post("/api/spenden", json={"user_id": anna}).status_code == 403
hostmodus(zuschauer)
blaetter = zuschauer.post("/api/spenden", json={"user_id": anna}).json()
assert [b["name"] for b in blaetter] == ["Anna"]
# Ein zweites Mal: immer noch ein Blatt
assert len(zuschauer.post("/api/spenden", json={"user_id": anna}).json()) == 1
assert client.get("/api/spenden").json()[0]["name"] == "Anna"
assert client.delete(f"/api/spenden/{anna}").status_code == 403
assert zuschauer.delete(f"/api/spenden/{anna}").json() == []
def test_kontostand_setzt_nur_der_host(client, zuschauer, hostmodus):
assert client.get("/api/kontostand").json() == {"betrag": None}
assert client.put("/api/kontostand", json={"betrag": 12.5}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/kontostand", json={"betrag": 12.5}).json() == {"betrag": 12.5}
assert client.get("/api/kontostand").json() == {"betrag": 12.5}
assert zuschauer.put("/api/kontostand", json={"betrag": None}).json() == {"betrag": None}