This commit is contained in:
team3
2026-09-17 12:14:40 +02:00
parent b42cf64782
commit a1684cf54f
11 changed files with 181 additions and 29 deletions

View File

@@ -250,6 +250,25 @@ def sitzung(response: Response, sid):
return neu return neu
def mitglied(sid):
"""Wer keinen Namen gewählt hat, darf zusehen, aber nichts eintragen.
Vorschläge, Sammlungen, Wertungen und Wünsche gehören einer Person.
Ohne Namen hingen sie nur am Cookie: im geteilten Browser schriebe
jeder auf dasselbe Konto, und nach dem Löschen der Cookies wäre alles
weg. Die Knöpfe blendet die Oberfläche aus — das hier ist der Riegel.
Der Host braucht dafür ebenfalls einen Namen. Seine eigenen Knöpfe
(drehen, eintragen, aufräumen) laufen über andere Routen.
"""
ich = users.besitzer(sid)
if ich is None:
raise HTTPException(
status_code=403, detail="Dafür musst du oben deinen Namen wählen."
)
return ich
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann # Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
letzter_dreh = {"ergebnis": None, "start": 0.0} letzter_dreh = {"ergebnis": None, "start": 0.0}
@@ -324,6 +343,7 @@ async def vorschlagen(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie = await film_holen(movie_id) movie = await film_holen(movie_id)
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden # Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid)) eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
@@ -336,6 +356,7 @@ async def vorschlagen(
@app.delete("/api/suggestions") @app.delete("/api/suggestions")
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)): def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
entfernt = suggestions.loeschen(sid) entfernt = suggestions.loeschen(sid)
if entfernt: if entfernt:
events.senden("suggestions") events.senden("suggestions")
@@ -366,6 +387,7 @@ def wunsch_anlegen(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
liste = features.anlegen(sid, text) liste = features.anlegen(sid, text)
if liste is None: if liste is None:
raise HTTPException(status_code=400, detail="Kein Text") raise HTTPException(status_code=400, detail="Kein Text")
@@ -397,6 +419,7 @@ def wunsch_abstimmen(
): ):
"""Ein Klick setzt die Stimme, der nächste nimmt sie zurück.""" """Ein Klick setzt die Stimme, der nächste nimmt sie zurück."""
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = features.abstimmen(feature_id, sid) steht = features.abstimmen(feature_id, sid)
if steht is None: if steht is None:
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
@@ -421,6 +444,7 @@ def wunsch_anmerken(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
if not features.anmerken(feature_id, sid, text): if not features.anmerken(feature_id, sid, text):
raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt") raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt")
events.senden("features") events.senden("features")
@@ -451,6 +475,7 @@ async def merken(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
return wishlist.merken(sid, await film_holen(movie_id)) return wishlist.merken(sid, await film_holen(movie_id))
@@ -461,6 +486,7 @@ def vergessen(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
return {"removed": wishlist.vergessen(sid, movie_id)} return {"removed": wishlist.vergessen(sid, movie_id)}
@@ -554,6 +580,7 @@ def bewerten(
filmabend_sid: str | None = Cookie(None), filmabend_sid: str | None = Cookie(None),
): ):
sid = sitzung(response, filmabend_sid) sid = sitzung(response, filmabend_sid)
mitglied(sid)
stars = max(1, min(watched.MAX_STERNE, stars)) stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage # 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), text)

View File

@@ -52,14 +52,36 @@ def frische_db(app_start):
main.letzter_dreh.update(ergebnis=None, start=0.0) main.letzter_dreh.update(ergebnis=None, start=0.0)
# Ein Client je Person — jeder hält sein eigenes Cookie def _anmelden(client, name):
"""Namen anlegen, falls nötig, und für diesen Client wählen."""
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
user_id = antwort.json()["id"]
else:
runde = client.get("/api/users").json()["items"]
user_id = next(u["id"] for u in runde if u["name"] == name)
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
# Ein Client je Person — jeder hält sein eigenes Cookie. Beide stehen in
# der Runde, denn ohne Namen darf niemand etwas eintragen.
@pytest.fixture @pytest.fixture
def client(): def client():
return TestClient(main.app) c = TestClient(main.app)
_anmelden(c, "Anna")
return c
@pytest.fixture @pytest.fixture
def client2(): def client2():
c = TestClient(main.app)
_anmelden(c, "Ben")
return c
@pytest.fixture
def zuschauer():
"""Ohne gewählten Namen: darf alles lesen, nichts schreiben."""
return TestClient(main.app) return TestClient(main.app)
@@ -78,14 +100,4 @@ def film():
@pytest.fixture @pytest.fixture
def anmelden(): def anmelden():
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt.""" """Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
return _anmelden
def waehlen(client, name):
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
user_id = antwort.json()["id"]
else:
runde = client.get("/api/users").json()["items"]
user_id = next(u["id"] for u in runde if u["name"] == name)
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
return waehlen

View File

@@ -39,8 +39,10 @@ def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden,
assert client.get("/api/suggestions").json()["mine"] == als_anna["id"] assert client.get("/api/suggestions").json()["mine"] == als_anna["id"]
def test_ohne_namen_zaehlt_die_sitzung(client, client2, film): def test_abwaehlen_macht_wieder_zum_zuschauer(client, film):
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json() client.post("/api/suggestions", json={"movie_id": film(1)})
assert vorschlag["name"] is None client.post("/api/users/waehlen", json={"user_id": None})
assert client.get("/api/suggestions").json()["mine"] == vorschlag["id"] assert client.get("/api/users").json()["mine"] is None
assert client2.get("/api/suggestions").json()["mine"] is None # Lesen geht weiter, Schreiben nicht mehr
assert client.get("/api/suggestions").json()["mine"] is None
assert client.post("/api/suggestions", json={"movie_id": film(2)}).status_code == 403

View File

@@ -0,0 +1,74 @@
"""Der Zuschauer: kein Name gewählt, also nur mitlesen.
Gedacht für alle, die noch nicht in der Runde stehen. Sie sollen den
Katalog, das Rad und die Wertungen der anderen sehen, aber nichts
eintragen — sonst hinge ihr Kram allein am Cookie.
"""
import pytest
# Jede Zeile: wie die Anfrage aussieht, wenn ein Zuschauer sie stellt
SCHREIBEN = [
("post", "/api/suggestions", {"movie_id": 1}),
("delete", "/api/suggestions", None),
("post", "/api/wishlist", {"movie_id": 1}),
("delete", "/api/wishlist/1", None),
("post", "/api/features", {"text": "Dunkles Design"}),
]
@pytest.mark.parametrize("methode,pfad,daten", SCHREIBEN)
def test_zuschauer_darf_nicht_schreiben(zuschauer, film, methode, pfad, daten):
film(1)
# DELETE nimmt bei TestClient keinen json-Parameter
anfrage = getattr(zuschauer, methode)
antwort = anfrage(pfad, json=daten) if daten is not None else anfrage(pfad)
assert antwort.status_code == 403
def test_zuschauer_darf_nicht_bewerten(zuschauer, client, film):
eintrag = client.post("/api/watched", json={"movie_id": film(1)}).json()
antwort = zuschauer.post(
f"/api/watched/{eintrag['id']}/rating", json={"stars": 5, "text": "stark"}
)
assert antwort.status_code == 403
assert zuschauer.get("/api/watched").json()[0]["count"] == 0
def test_zuschauer_darf_nicht_abstimmen_oder_ergaenzen(zuschauer, client):
wunsch = client.post("/api/features", json={"text": "Dunkles Design"}).json()[0]
assert zuschauer.post(f"/api/features/{wunsch['id']}/vote").status_code == 403
assert (
zuschauer.post(
f"/api/features/{wunsch['id']}/notes", json={"text": "dazu"}
).status_code
== 403
)
assert zuschauer.get("/api/features").json()[0]["votes"] == 0
def test_zuschauer_darf_alles_lesen(zuschauer, client, film):
client.post("/api/suggestions", json={"movie_id": film(1)})
client.post("/api/wishlist", json={"movie_id": film(2)})
client.post("/api/features", json={"text": "Dunkles Design"})
client.post("/api/watched", json={"movie_id": film(3)})
for pfad in ("/api/movies", "/api/suggestions", "/api/wishlist",
"/api/features", "/api/watched", "/api/users", "/api/spin"):
assert zuschauer.get(pfad).status_code == 200
# Fremdes gehört ihm nicht
assert zuschauer.get("/api/suggestions").json()["mine"] is None
assert zuschauer.get("/api/wishlist").json()[0]["mine"] is False
assert zuschauer.get("/api/features").json()[0]["mine"] is False
def test_host_kommt_ohne_namen_an_seine_knoepfe(zuschauer, client, film):
"""Den Hostmodus kennt nur der Browser — seine Routen bleiben offen."""
client.post("/api/suggestions", json={"movie_id": film(1)})
eintrag = zuschauer.post("/api/watched", json={"movie_id": film(2)}).json()
assert zuschauer.patch(
f"/api/watched/{eintrag['id']}", json={"hidden": True}
).status_code == 200
assert zuschauer.post("/api/spin").json()["started"] is True
assert zuschauer.delete("/api/suggestions/alle").status_code == 200

View File

@@ -105,6 +105,10 @@ watch(activeTab, async () => {
leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' }) leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' })
}) })
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen
const angemeldet = computed(() => !!ich.value)
const selected = ref(null) const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
@@ -188,8 +192,8 @@ onMounted(load)
> >
Filter Filter
</button> </button>
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" /> <span v-if="host" class="host">Host</span>
<span v-else class="host">Host</span> <NamensWahl :items="runde" :mine="ich" @waehlen="michSetzen" />
</div> </div>
</header> </header>
@@ -236,6 +240,7 @@ onMounted(load)
v-else-if="activeTab === 'wheel'" v-else-if="activeTab === 'wheel'"
ref="rad" ref="rad"
:host="host" :host="host"
:angemeldet="angemeldet"
:merkliste="merkliste" :merkliste="merkliste"
:mine="meinVorschlag?.id ?? null" :mine="meinVorschlag?.id ?? null"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"
@@ -246,12 +251,14 @@ onMounted(load)
<WatchedTab <WatchedTab
v-else-if="activeTab === 'watched'" v-else-if="activeTab === 'watched'"
:host="host" :host="host"
:angemeldet="angemeldet"
@open="selected = $event" @open="selected = $event"
@changed="ladeGesehen" @changed="ladeGesehen"
/> />
<MerklisteTab <MerklisteTab
v-else-if="activeTab === 'merkliste'" v-else-if="activeTab === 'merkliste'"
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"
:items="merkliste" :items="merkliste"
:ich="ich" :ich="ich"
@@ -264,6 +271,7 @@ onMounted(load)
:items="wuensche" :items="wuensche"
:ich="ich" :ich="ich"
:host="host" :host="host"
:angemeldet="angemeldet"
@changed="ladeWuensche" @changed="ladeWuensche"
/> />
@@ -347,6 +355,7 @@ onMounted(load)
:series="series" :series="series"
:mein-vorschlag="meinVorschlag" :mein-vorschlag="meinVorschlag"
:host="host" :host="host"
:angemeldet="angemeldet"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)" :gesehen="gesehen.some((e) => e.movie_id === selected.id)"
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)" :gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
@close="selected = null" @close="selected = null"

View File

@@ -4,6 +4,8 @@ import { posterUrl } from '../images'
const props = defineProps({ const props = defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
// Eine eigene Sammlung hat nur, wer in der Runde steht
angemeldet: Boolean,
ich: { type: Object, default: null }, ich: { type: Object, default: null },
gesehenIds: { type: Set, default: () => new Set() }, gesehenIds: { type: Set, default: () => new Set() },
}) })

View File

@@ -7,6 +7,8 @@ const props = defineProps({
series: { type: Array, default: () => [] }, series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null }, meinVorschlag: { type: Object, default: null },
host: { type: Boolean, default: false }, host: { type: Boolean, default: false },
// Ohne gewählten Namen gibt es keine Sammlung und keinen Vorschlag
angemeldet: { type: Boolean, default: false },
gesehen: { type: Boolean, default: false }, gesehen: { type: Boolean, default: false },
gemerkt: { type: Boolean, default: false }, gemerkt: { type: Boolean, default: false },
}) })
@@ -213,7 +215,7 @@ onUnmounted(() => {
Ähnliche finden Ähnliche finden
</button> </button>
<button <button
v-if="!host" v-if="!host && angemeldet"
type="button" type="button"
class="merken" class="merken"
:class="{ an: gemerkt }" :class="{ an: gemerkt }"
@@ -221,7 +223,7 @@ onUnmounted(() => {
> >
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }} {{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
</button> </button>
<template v-if="!host"> <template v-if="!host && angemeldet">
<button <button
type="button" type="button"
class="merken" class="merken"
@@ -235,6 +237,11 @@ onUnmounted(() => {
Hat nicht geklappt. Hat nicht geklappt.
</span> </span>
</template> </template>
<!-- Ohne Namen fehlen hier zwei Knöpfe — sonst sucht man
vergeblich, warum -->
<p v-if="!host && !angemeldet" class="hinweis">
Zum Merken und Vorschlagen wähle oben rechts deinen Namen.
</p>
</div> </div>
<div v-if="host || gesehen" class="gesehen"> <div v-if="host || gesehen" class="gesehen">

View File

@@ -7,6 +7,8 @@ const props = defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
laden: Boolean, laden: Boolean,
host: Boolean, host: Boolean,
// Der Würfel zieht aus der eigenen Sammlung — die gibt es nur mit Namen
angemeldet: Boolean,
merkliste: { type: Array, default: () => [] }, merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null }, mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() }, gesehenIds: { type: Set, default: () => new Set() },
@@ -51,7 +53,7 @@ async function wuerfeln() {
<template> <template>
<aside class="liste"> <aside class="liste">
<div v-if="!host" class="sammlung-wuerfeln"> <div v-if="!host && angemeldet" class="sammlung-wuerfeln">
<div class="schalter"> <div class="schalter">
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true"> <button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
Horror ({{ anzahlHorror }}) Horror ({{ anzahlHorror }})

View File

@@ -3,7 +3,11 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { posterUrl } from '../images' import { posterUrl } from '../images'
import { abonnieren } from '../live' import { abonnieren } from '../live'
const props = defineProps({ host: Boolean }) const props = defineProps({
host: Boolean,
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
angemeldet: Boolean,
})
const emit = defineEmits(['open', 'changed']) const emit = defineEmits(['open', 'changed'])
const items = ref([]) const items = ref([])
@@ -231,7 +235,11 @@ onUnmounted(() => {
<span v-else class="von">Noch keine Wertung</span> <span v-else class="von">Noch keine Wertung</span>
</p> </p>
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }"> <div
v-if="angemeldet"
class="wertung"
@mouseleave="schwebt = { id: null, wert: 0 }"
>
<button <button
v-for="s in STERNE" v-for="s in STERNE"
:key="s" :key="s"
@@ -248,7 +256,7 @@ onUnmounted(() => {
<!-- Erst nach den Sternen: ein Text ohne Note ergäbe keine Wertung --> <!-- Erst nach den Sternen: ein Text ohne Note ergäbe keine Wertung -->
<button <button
v-if="item.mine" v-if="item.mine && angemeldet"
type="button" type="button"
class="link" class="link"
@click="rezensionOeffnen(item)" @click="rezensionOeffnen(item)"

View File

@@ -8,6 +8,7 @@ import VorschlagsListe from './VorschlagsListe.vue'
defineProps({ defineProps({
host: Boolean, host: Boolean,
angemeldet: Boolean,
// Beides hält die App schon aktuell — auch nach einem Namenswechsel // Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] }, merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null }, mine: { type: Number, default: null },
@@ -171,6 +172,7 @@ defineExpose({ neu: laden_ })
:items="items" :items="items"
:laden="laden" :laden="laden"
:host="host" :host="host"
:angemeldet="angemeldet"
:merkliste="merkliste" :merkliste="merkliste"
:mine="mine" :mine="mine"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"

View File

@@ -5,6 +5,8 @@ const props = defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
ich: { type: Object, default: null }, ich: { type: Object, default: null },
host: Boolean, host: Boolean,
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
angemeldet: Boolean,
}) })
const emit = defineEmits(['changed']) const emit = defineEmits(['changed'])
@@ -115,7 +117,7 @@ function datum(iso) {
<template> <template>
<div class="wuensche-tab"> <div class="wuensche-tab">
<div class="neu"> <div v-if="angemeldet" class="neu">
<textarea <textarea
v-model="entwurf" v-model="entwurf"
rows="2" rows="2"
@@ -144,6 +146,7 @@ function datum(iso) {
type="button" type="button"
class="stimme" class="stimme"
:class="{ an: w.voted, hat: w.votes > 0 }" :class="{ an: w.voted, hat: w.votes > 0 }"
:disabled="!angemeldet"
:title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'" :title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'"
@click="abstimmen(w)" @click="abstimmen(w)"
> >
@@ -201,7 +204,7 @@ function datum(iso) {
× ×
</button> </button>
</p> </p>
<div class="notiz-neu"> <div v-if="angemeldet" class="notiz-neu">
<input <input
v-model="notizEntwurf[w.id]" v-model="notizEntwurf[w.id]"
type="text" type="text"
@@ -375,11 +378,15 @@ h3 {
border-radius: 20px; border-radius: 20px;
} }
.stimme:hover { .stimme:hover:not(:disabled) {
color: var(--text); color: var(--text);
border-color: var(--accent); border-color: var(--accent);
} }
.stimme:disabled {
cursor: default;
}
.stimme.hat { .stimme.hat {
color: var(--text); color: var(--text);
} }