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
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
letzter_dreh = {"ergebnis": None, "start": 0.0}
@@ -324,6 +343,7 @@ async def vorschlagen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie = await film_holen(movie_id)
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid))
@@ -336,6 +356,7 @@ async def vorschlagen(
@app.delete("/api/suggestions")
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
entfernt = suggestions.loeschen(sid)
if entfernt:
events.senden("suggestions")
@@ -366,6 +387,7 @@ def wunsch_anlegen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
liste = features.anlegen(sid, text)
if liste is None:
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."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
steht = features.abstimmen(feature_id, sid)
if steht is None:
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
@@ -421,6 +444,7 @@ def wunsch_anmerken(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
if not features.anmerken(feature_id, sid, text):
raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt")
events.senden("features")
@@ -451,6 +475,7 @@ async def merken(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
return wishlist.merken(sid, await film_holen(movie_id))
@@ -461,6 +486,7 @@ def vergessen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
return {"removed": wishlist.vergessen(sid, movie_id)}
@@ -554,6 +580,7 @@ def bewerten(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
mitglied(sid)
stars = max(1, min(watched.MAX_STERNE, stars))
# Der Name kommt aus der Runde, nicht aus der Anfrage
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)
# 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
def client():
return TestClient(main.app)
c = TestClient(main.app)
_anmelden(c, "Anna")
return c
@pytest.fixture
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)
@@ -78,14 +100,4 @@ def film():
@pytest.fixture
def anmelden():
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
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
return _anmelden

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"]
def test_ohne_namen_zaehlt_die_sitzung(client, client2, film):
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert vorschlag["name"] is None
assert client.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/suggestions").json()["mine"] is None
def test_abwaehlen_macht_wieder_zum_zuschauer(client, film):
client.post("/api/suggestions", json={"movie_id": film(1)})
client.post("/api/users/waehlen", json={"user_id": None})
assert client.get("/api/users").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' })
})
// 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)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
@@ -188,8 +192,8 @@ onMounted(load)
>
Filter
</button>
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
<span v-else class="host">Host</span>
<span v-if="host" class="host">Host</span>
<NamensWahl :items="runde" :mine="ich" @waehlen="michSetzen" />
</div>
</header>
@@ -236,6 +240,7 @@ onMounted(load)
v-else-if="activeTab === 'wheel'"
ref="rad"
:host="host"
:angemeldet="angemeldet"
:merkliste="merkliste"
:mine="meinVorschlag?.id ?? null"
:gesehen-ids="gesehenIds"
@@ -246,12 +251,14 @@ onMounted(load)
<WatchedTab
v-else-if="activeTab === 'watched'"
:host="host"
:angemeldet="angemeldet"
@open="selected = $event"
@changed="ladeGesehen"
/>
<MerklisteTab
v-else-if="activeTab === 'merkliste'"
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
@@ -264,6 +271,7 @@ onMounted(load)
:items="wuensche"
:ich="ich"
:host="host"
:angemeldet="angemeldet"
@changed="ladeWuensche"
/>
@@ -347,6 +355,7 @@ onMounted(load)
:series="series"
:mein-vorschlag="meinVorschlag"
:host="host"
:angemeldet="angemeldet"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
@close="selected = null"

View File

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

View File

@@ -7,6 +7,8 @@ const props = defineProps({
series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null },
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 },
gemerkt: { type: Boolean, default: false },
})
@@ -213,7 +215,7 @@ onUnmounted(() => {
Ähnliche finden
</button>
<button
v-if="!host"
v-if="!host && angemeldet"
type="button"
class="merken"
:class="{ an: gemerkt }"
@@ -221,7 +223,7 @@ onUnmounted(() => {
>
{{ gemerkt ? 'In Sammlung' : 'Zur Sammlung' }}
</button>
<template v-if="!host">
<template v-if="!host && angemeldet">
<button
type="button"
class="merken"
@@ -235,6 +237,11 @@ onUnmounted(() => {
Hat nicht geklappt.
</span>
</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 v-if="host || gesehen" class="gesehen">

View File

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

View File

@@ -3,7 +3,11 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { posterUrl } from '../images'
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 items = ref([])
@@ -231,7 +235,11 @@ onUnmounted(() => {
<span v-else class="von">Noch keine Wertung</span>
</p>
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
<div
v-if="angemeldet"
class="wertung"
@mouseleave="schwebt = { id: null, wert: 0 }"
>
<button
v-for="s in STERNE"
:key="s"
@@ -248,7 +256,7 @@ onUnmounted(() => {
<!-- Erst nach den Sternen: ein Text ohne Note ergäbe keine Wertung -->
<button
v-if="item.mine"
v-if="item.mine && angemeldet"
type="button"
class="link"
@click="rezensionOeffnen(item)"

View File

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

View File

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