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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user