Refactor and enhance game management, user roles, and state handling

This commit introduces significant changes across the backend and frontend to improve game creation, joining, and member management. Key updates include adding a host role, structured handling of winners, and a New Game modal in the frontend. The refactor also simplifies join codes, improves persistence for user themes, and enhances overall user interaction with better UI feedback and logic.
This commit is contained in:
2026-02-06 11:21:43 +01:00
parent d0f65b856e
commit 4669d1f8c4
9 changed files with 488 additions and 268 deletions

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User
from ..security import (
@@ -14,21 +13,25 @@ from ..security import (
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
email = (data.get("email") or "").lower().strip()
password = data.get("password") or ""
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.email == email, User.disabled == False).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials")
set_session(resp, make_session_value(user.id))
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
@router.post("/logout")
def logout(resp: Response):
clear_session(resp)
return {"ok": True}
@router.get("/me")
def me(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
@@ -39,6 +42,7 @@ def me(req: Request, db: Session = Depends(get_db)):
raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key}
@router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
@@ -49,19 +53,18 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
user.password_hash = hash_password(password)
db.add(user)
db.commit()
return {"ok": True}
@router.patch("/theme")
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
"""Persist user design selection server-side."""
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
@@ -70,7 +73,7 @@ def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
if not theme_key:
raise HTTPException(status_code=400, detail="theme_key required")
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")

View File

@@ -1,151 +1,193 @@
import hashlib
import random
import string
import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"])
def require_user(req: Request, db: Session) -> str:
def require_user(req: Request, db: Session):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
return uid
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
s = f"{seed}:{user_id}:{entry_id}".encode()
return hashlib.sha256(s).hexdigest()
def _rand_join_code(n: int = 6) -> str:
return "".join(random.choice(string.digits) for _ in range(n))
def _new_unique_join_code(db: Session) -> str:
for _ in range(50):
code = _rand_join_code()
if not db.query(Game).filter(Game.join_code == code).first():
return code
raise HTTPException(500, "failed to generate join code")
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
uid = require_user(req, db)
def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex:
return
db.add(GameMember(game_id=game_id, user_id=user_id))
db.commit()
def require_game_member(db: Session, game_id: str, user_id: str) -> Game:
g = db.query(Game).filter(Game.id == game_id).first()
if not g:
raise HTTPException(404, "game not found")
m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
if not m:
mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if not mem:
raise HTTPException(403, "not a member of this game")
return uid, g
return g
@router.post("")
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
name = data.get("name") or "Neues Spiel"
seed = random.randint(1, 2_000_000_000)
join_code = _new_unique_join_code(db)
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
# unique code
code = gen_code()
while db.query(Game).filter(Game.code == code).first():
code = gen_code()
g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None)
db.add(g)
db.commit()
# creator becomes member
db.add(GameMember(game_id=g.id, user_id=uid))
db.commit()
# creator joins automatically
ensure_member(db, g.id, uid)
return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
return {"id": g.id, "name": g.name, "join_code": g.join_code}
@router.post("/join")
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
code = (data.get("code") or "").strip()
if not code or len(code) < 4:
code = (data.get("code") or "").strip().upper()
if not code:
raise HTTPException(400, "code required")
g = db.query(Game).filter(Game.join_code == code).first()
g = db.query(Game).filter(Game.code == code).first()
if not g:
raise HTTPException(404, "game not found")
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
if not exists:
db.add(GameMember(game_id=g.id, user_id=uid))
db.commit()
ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}}
@router.get("")
def list_games(req: Request, db: Session = Depends(get_db)):
uid = require_user(req, db)
games = (
# list games where user is member
q = (
db.query(Game)
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid)
.order_by(Game.created_at.desc())
.all()
)
games = q.all()
# winner email (optional)
out = []
for g in games:
winner = None
winner_email = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
if wu:
winner = {"id": wu.id, "email": wu.email}
out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner})
winner_email = wu.email if wu else None
out.append(
{
"id": g.id,
"name": g.name,
"seed": g.seed,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
}
)
return out
@router.get("/{game_id}/meta")
def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
winner = None
@router.get("/{game_id}")
def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
winner_email = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
if wu:
winner = {"id": wu.id, "email": wu.email}
return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner}
winner_email = wu.email if wu else None
@router.get("/{game_id}/players")
def list_players(req: Request, game_id: str, db: Session = Depends(get_db)):
_uid, g = require_member(req, db, game_id)
return {
"id": g.id,
"name": g.name,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
}
# only non-admin users (admin doesn't play)
players = (
@router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
_g = require_game_member(db, game_id, uid)
# return only "user" role (admin excluded)
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
return [{"id": u.id, "email": u.email} for u in players]
return [{"id": u.id, "email": u.email} for u in members]
@router.patch("/{game_id}/winner")
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
_uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
# only host can set winner
if g.host_user_id != uid:
raise HTTPException(403, "only host can set winner")
winner_user_id = data.get("winner_user_id")
if winner_user_id is not None:
# must be a member + non-admin
u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712
if not u or u.role != Role.user.value:
raise HTTPException(400, "invalid winner_user_id")
if winner_user_id is None:
g.winner_user_id = None
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": None}
member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first()
if not member:
raise HTTPException(400, "winner is not in this game")
# must be a member AND role=user
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
if not member:
raise HTTPException(400, "winner must be a member of the game")
u = db.query(User).filter(User.id == winner_user_id).first()
if not u or u.role != Role.user.value or u.disabled:
raise HTTPException(400, "invalid winner")
g.winner_user_id = winner_user_id
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": g.winner_user_id}
@router.get("/{game_id}/sheet")
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
entries = db.query(Entry).all()
states = (
@@ -163,12 +205,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
"label": e.label,
"status": st.status if st else 0,
"note_tag": st.note_tag if st else None,
"chip_code": st.chip_code if st else None,
"chip": st.chip if st else None, # NEW
"order": stable_order(g.seed, uid, e.id),
}
out[e.category].append(item)
# sort within category
for k in out:
out[k].sort(key=lambda x: x["order"])
for i in out[k]:
@@ -176,49 +217,58 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
return out
@router.patch("/{game_id}/sheet/{entry_id}")
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
status = data.get("status")
note_tag = data.get("note_tag")
chip_code = data.get("chip_code")
chip = data.get("chip")
if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2, 3):
raise HTTPException(400, "invalid status")
# chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's')
if chip_code is not None:
if not isinstance(chip_code, str) or len(chip_code) > 16:
raise HTTPException(400, "invalid chip_code")
if chip is not None:
chip = (chip or "").strip().upper()
if chip == "":
chip = None
if chip is not None:
if len(chip) > 8:
raise HTTPException(400, "invalid chip")
st = db.query(SheetState).filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id
).first()
st = (
db.query(SheetState)
.filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id,
)
.first()
)
if not st:
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None)
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None)
db.add(st)
if status is not None:
st.status = status
if "note_tag" in data:
st.note_tag = note_tag
# if leaving 's', clear chip
if note_tag != "s":
st.chip_code = None
if "chip_code" in data:
# chip_code is only meaningful when note_tag is 's'
effective_tag = st.note_tag
if effective_tag != "s":
raise HTTPException(400, "chip_code requires note_tag 's'")
st.chip_code = chip_code
# wenn note_tag zurück auf null -> chip auch löschen
if note_tag is None:
st.chip = None
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
if "chip" in data:
if st.note_tag == "s":
st.chip = chip
else:
st.chip = None
db.commit()
return {"ok": True}