feat: add boilerplate
This commit is contained in:
385
app/main.py
Normal file
385
app/main.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.game import (
|
||||
WORK_LABELS,
|
||||
advance,
|
||||
current_round,
|
||||
maybe_close_round,
|
||||
playable_jobs,
|
||||
public_job,
|
||||
start_game,
|
||||
submit_answer,
|
||||
)
|
||||
from app.models import FetchTask, GameSession, Job, Player, Round
|
||||
from app.ral import hide_salary
|
||||
from app.realtime import hub
|
||||
from app.seed import seed_jobs
|
||||
|
||||
BASE = Path(__file__).resolve().parent
|
||||
app = FastAPI(title="Guess the RAL")
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
||||
app.mount("/static", StaticFiles(directory=BASE / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
Path("data").mkdir(exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
seed_jobs(db)
|
||||
db.close()
|
||||
|
||||
|
||||
def render(request: Request, name: str, **ctx):
|
||||
ctx["request"] = request
|
||||
return templates.TemplateResponse(name, ctx)
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
if not request.session.get("admin"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def snapshot(db: Session, session: GameSession) -> dict:
|
||||
rnd = current_round(db, session) if session.status != "lobby" else None
|
||||
players = [
|
||||
{"id": p.id, "name": p.name, "score": p.score, "connected": p.connected, "host": p.is_host}
|
||||
for p in sorted(session.players, key=lambda x: (-x.score, x.name.lower()))
|
||||
]
|
||||
payload = {
|
||||
"type": "state",
|
||||
"session": {
|
||||
"id": session.id,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
"num_rounds": session.num_rounds,
|
||||
"max_players": session.max_players,
|
||||
"extra_guesses": session.extra_guesses,
|
||||
"hide_company": session.hide_company,
|
||||
"hide_subject": session.hide_subject,
|
||||
},
|
||||
"players": players,
|
||||
}
|
||||
if rnd:
|
||||
answered = {a.player_id for a in rnd.answers}
|
||||
payload["round"] = {
|
||||
"number": rnd.round_number,
|
||||
"bonus": rnd.is_bonus,
|
||||
"status": rnd.status,
|
||||
"options": json.loads(rnd.options_json),
|
||||
"job": public_job(session, rnd.job, rnd.is_bonus),
|
||||
"answered_ids": list(answered),
|
||||
"answered": [p.name for p in session.players if p.id in answered],
|
||||
"waiting": [p.name for p in session.players if p.connected and p.id not in answered],
|
||||
"work_types": WORK_LABELS,
|
||||
}
|
||||
if rnd.status == "revealed":
|
||||
payload["round"]["correct"] = rnd.correct_option
|
||||
payload["round"]["ral_exact"] = rnd.job.ral_exact
|
||||
payload["round"]["ral_min"] = rnd.job.ral_min
|
||||
payload["round"]["ral_max"] = rnd.job.ral_max
|
||||
payload["round"]["work_type"] = rnd.job.work_type
|
||||
payload["round"]["company"] = rnd.job.company
|
||||
payload["round"]["answers"] = [
|
||||
{
|
||||
"name": a.player.name,
|
||||
"choice": a.choice,
|
||||
"precise": a.precise_guess,
|
||||
"work_type": a.work_type_guess,
|
||||
"points": a.points,
|
||||
"correct": a.choice == rnd.correct_option,
|
||||
}
|
||||
for a in rnd.answers
|
||||
]
|
||||
if session.status == "finished":
|
||||
payload["winner"] = players[0]["name"] if players else ""
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request):
|
||||
return render(request, "index.html")
|
||||
|
||||
|
||||
@app.post("/create")
|
||||
def create_session(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
max_players: int = Form(8),
|
||||
num_rounds: int = Form(8),
|
||||
extra_guesses: str | None = Form(None),
|
||||
hide_company: str | None = Form(None),
|
||||
hide_subject: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
name = name.strip()[:24] or "Host"
|
||||
session = GameSession(
|
||||
max_players=max(2, min(12, max_players)),
|
||||
num_rounds=max(4, min(32, num_rounds)),
|
||||
extra_guesses=bool(extra_guesses),
|
||||
hide_company=bool(hide_company),
|
||||
hide_subject=bool(hide_subject),
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
player = Player(session_id=session.id, name=name, is_host=True)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
request.session["player_token"] = player.token
|
||||
request.session["player_name"] = player.name
|
||||
return RedirectResponse(f"/play/{session.id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/join/{session_id}", response_class=HTMLResponse)
|
||||
def join_page(request: Request, session_id: str, db: Session = Depends(get_db)):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
return render(request, "join.html", session=session)
|
||||
|
||||
|
||||
@app.post("/join/{session_id}")
|
||||
def join_session(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
name: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
name = name.strip()[:24] or "Player"
|
||||
existing = next((p for p in session.players if p.name.lower() == name.lower()), None)
|
||||
if existing:
|
||||
player = existing
|
||||
player.connected = True
|
||||
else:
|
||||
if session.status != "lobby":
|
||||
return render(request, "join.html", session=session, error="Match already started")
|
||||
connected = [p for p in session.players if p.connected]
|
||||
if len(connected) >= session.max_players:
|
||||
return render(request, "join.html", session=session, error="Lobby is full")
|
||||
player = Player(session_id=session.id, name=name)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
request.session["player_token"] = player.token
|
||||
request.session["player_name"] = player.name
|
||||
return RedirectResponse(f"/play/{session.id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/play/{session_id}", response_class=HTMLResponse)
|
||||
def play(request: Request, session_id: str, db: Session = Depends(get_db)):
|
||||
session = db.get(GameSession, session_id)
|
||||
if not session:
|
||||
return render(request, "index.html", error="Session not found")
|
||||
token = request.session.get("player_token")
|
||||
player = next((p for p in session.players if p.token == token), None)
|
||||
if not player:
|
||||
return RedirectResponse(f"/join/{session_id}", status_code=303)
|
||||
return render(request, "play.html", session=session, player=player)
|
||||
|
||||
|
||||
@app.post("/play/{session_id}/settings")
|
||||
def update_settings(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
max_players: int = Form(8),
|
||||
num_rounds: int = Form(8),
|
||||
extra_guesses: str | None = Form(None),
|
||||
hide_company: str | None = Form(None),
|
||||
hide_subject: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session = db.get(GameSession, session_id)
|
||||
player = next((p for p in session.players if p.token == request.session.get("player_token")), None)
|
||||
if not session or not player or not player.is_host or session.status != "lobby":
|
||||
return RedirectResponse(f"/play/{session_id}", status_code=303)
|
||||
session.max_players = max(2, min(12, max_players))
|
||||
session.num_rounds = max(4, min(32, num_rounds))
|
||||
session.extra_guesses = bool(extra_guesses)
|
||||
session.hide_company = bool(hide_company)
|
||||
session.hide_subject = bool(hide_subject)
|
||||
db.commit()
|
||||
return RedirectResponse(f"/play/{session_id}", status_code=303)
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def ws_play(websocket: WebSocket, session_id: str):
|
||||
token = websocket.query_params.get("token", "")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = db.get(GameSession, session_id)
|
||||
player = next((p for p in (session.players if session else []) if p.token == token), None)
|
||||
if not session or not player:
|
||||
await websocket.close()
|
||||
return
|
||||
player.connected = True
|
||||
db.commit()
|
||||
await hub.join(session_id, websocket)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
db.expire_all()
|
||||
session = db.get(GameSession, session_id)
|
||||
player = db.get(Player, player.id)
|
||||
action = msg.get("action")
|
||||
if action == "start" and player.is_host and session.status == "lobby":
|
||||
try:
|
||||
start_game(db, session)
|
||||
except ValueError as exc:
|
||||
await websocket.send_text(json.dumps({"type": "error", "message": str(exc)}))
|
||||
elif action == "answer" and session.status == "playing":
|
||||
rnd = current_round(db, session)
|
||||
if rnd and rnd.status == "open":
|
||||
precise = msg.get("precise")
|
||||
precise_val = int(precise) if precise not in (None, "", False) else None
|
||||
try:
|
||||
submit_answer(
|
||||
db,
|
||||
player,
|
||||
rnd,
|
||||
str(msg.get("choice") or ""),
|
||||
precise_val,
|
||||
str(msg.get("work_type") or ""),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
db.refresh(rnd)
|
||||
db.refresh(session)
|
||||
if maybe_close_round(db, session, rnd):
|
||||
db.refresh(session)
|
||||
elif action == "next" and player.is_host:
|
||||
rnd = current_round(db, session)
|
||||
if rnd and rnd.status == "revealed":
|
||||
advance(db, session)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
hub.leave(session_id, websocket)
|
||||
try:
|
||||
player = next((p for p in db.get(GameSession, session_id).players if p.token == token), None)
|
||||
if player:
|
||||
player.connected = False
|
||||
db.commit()
|
||||
session = db.get(GameSession, session_id)
|
||||
await hub.broadcast(session_id, snapshot(db, session))
|
||||
except Exception:
|
||||
pass
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin(request: Request, db: Session = Depends(get_db)):
|
||||
if not require_admin(request):
|
||||
return render(request, "admin_login.html")
|
||||
jobs = db.query(Job).order_by(Job.id.desc()).limit(80).all()
|
||||
tasks = db.query(FetchTask).order_by(FetchTask.id.desc()).limit(40).all()
|
||||
queued = db.query(FetchTask).filter(FetchTask.status.in_(["queued", "running"])).count()
|
||||
return render(
|
||||
request,
|
||||
"admin.html",
|
||||
jobs=jobs,
|
||||
tasks=tasks,
|
||||
queued=queued,
|
||||
playable=len(playable_jobs(db)),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/login")
|
||||
def admin_login(request: Request, password: str = Form(...)):
|
||||
if password != settings.admin_password:
|
||||
return render(request, "admin_login.html", error="Wrong password")
|
||||
request.session["admin"] = True
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/logout")
|
||||
def admin_logout(request: Request):
|
||||
request.session.pop("admin", None)
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/fetch")
|
||||
def admin_fetch(request: Request, url: str = Form(...), db: Session = Depends(get_db)):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
db.add(FetchTask(url=url.strip(), status="queued"))
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/job")
|
||||
def admin_add_job(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
company: str = Form(""),
|
||||
subject: str = Form("other"),
|
||||
location: str = Form(""),
|
||||
work_type: str = Form("hybrid"),
|
||||
ral_exact: str = Form(""),
|
||||
ral_min: str = Form(""),
|
||||
ral_max: str = Form(""),
|
||||
extra_info: str = Form(""),
|
||||
description: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
def num(v):
|
||||
v = (v or "").replace(".", "").replace(" ", "")
|
||||
return int(v) if v.isdigit() else None
|
||||
|
||||
exact, low, high = num(ral_exact), num(ral_min), num(ral_max)
|
||||
db.add(
|
||||
Job(
|
||||
title=title,
|
||||
company=company,
|
||||
subject=subject,
|
||||
location=location,
|
||||
work_type=work_type,
|
||||
ral_exact=exact,
|
||||
ral_min=low,
|
||||
ral_max=high,
|
||||
extra_info=extra_info,
|
||||
description=description,
|
||||
description_clean=hide_salary(description),
|
||||
status="ready" if (exact or low) else "needs_salary",
|
||||
source="manual",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/job/{job_id}/status")
|
||||
def admin_job_status(
|
||||
request: Request,
|
||||
job_id: int,
|
||||
status: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not require_admin(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
job = db.get(Job, job_id)
|
||||
if job and status in {"ready", "needs_salary", "hidden"}:
|
||||
job.status = status
|
||||
db.commit()
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
Reference in New Issue
Block a user