From 2f771a9c30bf57eb0ccd38a935445749cdc839d1 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 7 Oct 2026 15:41:41 +0200 Subject: [PATCH] update --- Dockerfile | 2 +- Makefile | 2 +- backend/app/main.py | 66 +++- backend/app/spenden.py | 44 +++ backend/tests/conftest.py | 4 +- backend/tests/test_login.py | 28 +- backend/tests/test_spenden.py | 27 ++ frontend/src/App.vue | 2 +- frontend/src/components/InfoTab.vue | 378 ++++++++++++++++++- frontend/src/composables/useEinstellungen.js | 11 +- 10 files changed, 527 insertions(+), 37 deletions(-) create mode 100644 backend/app/spenden.py create mode 100644 backend/tests/test_spenden.py diff --git a/Dockerfile b/Dockerfile index 4010968..5a9a82b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ VOLUME /data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "2"] diff --git a/Makefile b/Makefile index 82f6106..b372af5 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ dev: mtx -@pkill -f "[u]vicorn app.main:app" 2>/dev/null -@pkill -f "[v]ite --port 5173" 2>/dev/null @echo "Backend: http://localhost:8000 Frontend: http://localhost:5173" - @cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 & + @cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 --timeout-graceful-shutdown 2 & @cd frontend && npx vite --port 5173 stop: mtx-stop stream-stop diff --git a/backend/app/main.py b/backend/app/main.py index 4da6c89..193225d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=, 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") diff --git a/backend/app/spenden.py b/backend/app/spenden.py new file mode 100644 index 0000000..9e7993d --- /dev/null +++ b/backend/app/spenden.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8b5e464..75525ca 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_login.py b/backend/tests/test_login.py index 1281af1..4ddf647 100644 --- a/backend/tests/test_login.py +++ b/backend/tests/test_login.py @@ -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 diff --git a/backend/tests/test_spenden.py b/backend/tests/test_spenden.py new file mode 100644 index 0000000..617c690 --- /dev/null +++ b/backend/tests/test_spenden.py @@ -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} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f1d30da..4a26a39 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -432,7 +432,7 @@ onMounted(load) @changed="ladeWuensche" /> - + diff --git a/frontend/src/components/InfoTab.vue b/frontend/src/components/InfoTab.vue index 81c1799..a2706cf 100644 --- a/frontend/src/components/InfoTab.vue +++ b/frontend/src/components/InfoTab.vue @@ -2,12 +2,16 @@ // Ein Markdown-Text, mehr nicht: PayPal, IBAN, Regeln, was man wissen // muss. Der Host schreibt ihn hier direkt; bis dahin steht die Vorlage // mit den Punkteregeln. -import { onMounted, onUnmounted, ref } from 'vue' +import { computed, onMounted, onUnmounted, ref } from 'vue' import { abonnieren } from '../live' import { rendern } from '../markdown' import MarkdownFeld from './MarkdownFeld.vue' -defineProps({ host: Boolean }) +const props = defineProps({ + host: Boolean, + // Die Runde — daraus wählt der Host, wer ein Blatt bekommt + runde: { type: Array, default: () => [] }, +}) const text = ref('') const laden = ref(true) @@ -47,12 +51,89 @@ async function speichern() { laden_() } +// ── Spendenbaum: ein Blatt je Person, das neueste oben ───────────────── +const blaetter = ref([]) +const gewaehlt = ref('') + +// Der Kontostand läuft als Erstes über den Bildschirm +const kontostand = ref(null) +const standEntwurf = ref('') + +async function baumLaden() { + try { + const resp = await fetch('/api/spenden') + if (resp.ok) blaetter.value = await resp.json() + const stand = await fetch('/api/kontostand') + if (stand.ok) kontostand.value = (await stand.json()).betrag + } catch { + // Der Abspann ist Beiwerk — ohne ihn geht die Seite trotzdem + } +} + +// Die Wartezeit vor dem Abspann gilt nur für den ersten Durchlauf. Nach +// jedem Durchlauf die Animation neu ansetzen — dann wartet sie wieder. +function neuAnsetzen(e) { + const el = e.target + el.style.animationName = 'none' + void el.offsetHeight + el.style.animationName = '' +} + +function euro(betrag) { + return betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) +} + +async function standSetzen() { + const betrag = standEntwurf.value === '' ? null : Number(standEntwurf.value) + const resp = await fetch('/api/kontostand', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ betrag }), + }) + if (resp.ok) kontostand.value = (await resp.json()).betrag + standEntwurf.value = '' +} + +// Im Abspann stehen die Namen jedes Mal in anderer Reihenfolge — niemand +// steht immer oben +const gemischt = computed(() => { + const liste = [...blaetter.value] + for (let i = liste.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[liste[i], liste[j]] = [liste[j], liste[i]] + } + return liste +}) + +// Wer noch kein Blatt hat +const ohneBlatt = computed(() => + props.runde.filter((u) => !blaetter.value.some((b) => b.user_id === u.id)), +) + +async function blattSetzen() { + if (!gewaehlt.value) return + const resp = await fetch('/api/spenden', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: Number(gewaehlt.value) }), + }) + if (resp.ok) blaetter.value = await resp.json() + gewaehlt.value = '' +} + +async function blattWeg(userId) { + const resp = await fetch(`/api/spenden/${userId}`, { method: 'DELETE' }) + if (resp.ok) blaetter.value = await resp.json() +} + let abbestellen = null onMounted(() => { laden_() + baumLaden() abbestellen = abonnieren((nachricht) => { if (nachricht.type === 'info') laden_() + else if (nachricht.type === 'spenden') baumLaden() }) }) @@ -61,31 +142,292 @@ onUnmounted(() => abbestellen?.())