Integrate join codes, player management, and themes
This update introduces "join codes" for games to simplify game joining. Enhancements include player role and winner management for better organization. Additionally, theme preferences are now user-configurable and persisted server-side.
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
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 verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password
|
||||
from ..security import (
|
||||
verify_password,
|
||||
make_session_value,
|
||||
set_session,
|
||||
clear_session,
|
||||
get_session_user_id,
|
||||
hash_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -10,11 +18,11 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
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()
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
|
||||
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}
|
||||
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(resp: Response):
|
||||
@@ -29,8 +37,8 @@ def me(req: Request, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return {"id": user.id, "email": user.email, "role": user.role}
|
||||
|
||||
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)
|
||||
@@ -41,7 +49,7 @@ 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()
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
@@ -49,4 +57,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True}
|
||||
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")
|
||||
|
||||
theme_key = (data.get("theme_key") or "").strip()
|
||||
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
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
user.theme_key = theme_key
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "theme_key": user.theme_key}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import hashlib, random
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Game, Entry, SheetState, Category
|
||||
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
|
||||
from ..security import get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/games", tags=["games"])
|
||||
|
||||
def require_user(req: Request, db: Session):
|
||||
def require_user(req: Request, db: Session) -> str:
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
@@ -17,30 +20,139 @@ 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")
|
||||
|
||||
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
|
||||
uid = require_user(req, db)
|
||||
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:
|
||||
raise HTTPException(403, "not a member of this game")
|
||||
return uid, 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)
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed)
|
||||
db.add(g); db.commit()
|
||||
return {"id": g.id, "name": g.name}
|
||||
join_code = _new_unique_join_code(db)
|
||||
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
|
||||
# creator becomes member
|
||||
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||
db.commit()
|
||||
|
||||
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:
|
||||
raise HTTPException(400, "code required")
|
||||
|
||||
g = db.query(Game).filter(Game.join_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()
|
||||
|
||||
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 = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
|
||||
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games]
|
||||
|
||||
games = (
|
||||
db.query(Game)
|
||||
.join(GameMember, GameMember.game_id == Game.id)
|
||||
.filter(GameMember.user_id == uid)
|
||||
.order_by(Game.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
out = []
|
||||
for g in games:
|
||||
winner = 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})
|
||||
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
|
||||
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}
|
||||
|
||||
@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)
|
||||
|
||||
# only non-admin users (admin doesn't play)
|
||||
players = (
|
||||
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
|
||||
.order_by(User.email.asc())
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "email": u.email} for u in players]
|
||||
|
||||
@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)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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 = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
entries = db.query(Entry).all()
|
||||
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all()
|
||||
states = (
|
||||
db.query(SheetState)
|
||||
.filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid)
|
||||
.all()
|
||||
)
|
||||
state_map = {st.entry_id: st for st in states}
|
||||
|
||||
out = {"suspect": [], "item": [], "location": []}
|
||||
@@ -51,6 +163,7 @@ 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,
|
||||
"order": stable_order(g.seed, uid, e.id),
|
||||
}
|
||||
out[e.category].append(item)
|
||||
@@ -65,19 +178,22 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
@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 = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
status = data.get("status")
|
||||
note_tag = data.get("note_tag")
|
||||
chip_code = data.get("chip_code")
|
||||
|
||||
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")
|
||||
|
||||
st = db.query(SheetState).filter(
|
||||
SheetState.game_id == g.id,
|
||||
SheetState.owner_user_id == uid,
|
||||
@@ -85,13 +201,24 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
|
||||
).first()
|
||||
|
||||
if not st:
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=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
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
Reference in New Issue
Block a user