Files
quiz/app/main.py
T

420 lines
15 KiB
Python
Executable File

import os
import uuid
import json
import datetime
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy.orm import Session
from dotenv import load_dotenv
from app.database import get_db
from app.models import Question, QuizSession, AdminUser
from app.matcher import is_answer_correct, levenshtein_distance, normalize_text
from app.auth import verify_password
from app.seed import seed_db
from app.schemas import GameCheckRequest, SuggestionCreateRequest
load_dotenv()
app = FastAPI(title="Magisches Harry-Potter-Escape-Quiz")
SECRET_KEY = os.getenv("SECRET_KEY", "hogwarts_secret_default_key_2026")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="quiz_session")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
@app.on_event("startup")
def startup_event():
seed_db()
# --- Public Endpoints ---
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, db: Session = Depends(get_db)):
active_count = db.query(Question).filter(Question.status == "active").count()
return templates.TemplateResponse(
request=request,
name="index.html",
context={"question_count": active_count}
)
@app.post("/api/game/start")
async def start_game(db: Session = Depends(get_db)):
# Fetch all active questions in order
questions = db.query(Question).filter(Question.status == "active").order_by(Question.sort_order.asc(), Question.id.asc()).all()
if not questions:
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
question_ids = [q.id for q in questions]
session_id = str(uuid.uuid4())
new_session = QuizSession(
id=session_id,
score=0,
current_question_idx=0,
question_ids=json.dumps(question_ids),
attempts_current=0,
is_finished=False
)
db.add(new_session)
db.commit()
first_q = questions[0]
return {
"session_id": session_id,
"question_number": 1,
"total_questions": len(question_ids),
"category": first_q.category,
"question": first_q.question,
"score": 0,
"attempts_used": 0,
"max_attempts": 3
}
@app.post("/api/game/check")
async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)):
session = db.query(QuizSession).filter(QuizSession.id == payload.session_id).first()
if not session or session.is_finished:
raise HTTPException(status_code=400, detail="Ungültige oder beendete Spiel-Session.")
question_ids = json.loads(session.question_ids)
if session.current_question_idx >= len(question_ids):
session.is_finished = True
db.commit()
return {
"correct": False,
"score": session.score,
"attempts_used": session.attempts_current,
"attempts_left": 0,
"is_finished": True,
"next_question": None
}
current_q_id = question_ids[session.current_question_idx]
current_q = db.query(Question).filter(Question.id == current_q_id).first()
if not current_q:
raise HTTPException(status_code=500, detail="Frage nicht in Datenbank gefunden.")
correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers)
if correct:
session.score += 1
session.current_question_idx += 1
session.attempts_current = 0
is_finished = session.current_question_idx >= len(question_ids)
if is_finished:
session.is_finished = True
session.finished_at = datetime.datetime.utcnow()
db.commit()
next_q_data = None
if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q:
next_q_data = {
"question_number": session.current_question_idx + 1,
"total_questions": len(question_ids),
"category": next_q.category,
"question": next_q.question
}
return {
"correct": True,
"score": session.score,
"attempts_used": 0,
"attempts_left": 3,
"is_finished": is_finished,
"revealed_answer": None,
"next_question": next_q_data
}
else:
session.attempts_current += 1
attempts_left = max(0, 3 - session.attempts_current)
if attempts_left == 0:
# 3 failed attempts: move to next question, reveal answer so player learns it
revealed = current_q.canonical_answer
session.current_question_idx += 1
session.attempts_current = 0
is_finished = session.current_question_idx >= len(question_ids)
if is_finished:
session.is_finished = True
session.finished_at = datetime.datetime.utcnow()
db.commit()
next_q_data = None
if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q:
next_q_data = {
"question_number": session.current_question_idx + 1,
"total_questions": len(question_ids),
"category": next_q.category,
"question": next_q.question
}
return {
"correct": False,
"score": session.score,
"attempts_used": 3,
"attempts_left": 0,
"is_finished": is_finished,
"revealed_answer": revealed,
"next_question": next_q_data
}
else:
db.commit()
return {
"correct": False,
"score": session.score,
"attempts_used": session.attempts_current,
"attempts_left": attempts_left,
"is_finished": False,
"revealed_answer": None,
"next_question": None
}
@app.get("/api/game/hint")
async def get_hint(session_id: str, db: Session = Depends(get_db)):
session = db.query(QuizSession).filter(QuizSession.id == session_id).first()
if not session or session.is_finished:
raise HTTPException(status_code=400, detail="Ungültige Session.")
question_ids = json.loads(session.question_ids)
if session.current_question_idx >= len(question_ids):
return {"hint": "Kein Hinweis verfügbar."}
current_q_id = question_ids[session.current_question_idx]
current_q = db.query(Question).filter(Question.id == current_q_id).first()
return {"hint": current_q.hint if current_q and current_q.hint else "Kein Tipp verfügbar."}
@app.get("/finale", response_class=HTMLResponse)
async def finale(request: Request, score: int = 0, total: int = 15):
return templates.TemplateResponse(
request=request,
name="finale.html",
context={"score": score, "total": total}
)
@app.get("/vorschlag", response_class=HTMLResponse)
async def vorschlag_page(request: Request):
return templates.TemplateResponse(
request=request,
name="vorschlag.html",
context={}
)
@app.post("/api/suggestions")
async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depends(get_db)):
if not payload.frage.strip() or not payload.antwort.strip():
raise HTTPException(status_code=400, detail="Frage und Antwort dürfen nicht leer sein.")
new_suggestion = Question(
category="community",
question=payload.frage.strip(),
canonical_answer=payload.antwort.strip(),
hint=payload.tipp.strip() if payload.tipp else None,
status="needs_review", # User Question Need Test / Review
submitted_by=payload.name.strip() if payload.name else "Gast",
sort_order=999
)
db.add(new_suggestion)
db.commit()
return {
"success": True,
"message": "Vielen Dank! Dein Vorschlag wurde eingereicht und wird von uns geprüft, bevor er im Quiz erscheint."
}
# --- Admin Endpoints ---
def get_current_admin(request: Request) -> str:
admin = request.session.get("admin_user")
if not admin:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
return admin
@app.get("/admin/login", response_class=HTMLResponse)
async def admin_login_page(request: Request):
if request.session.get("admin_user"):
return RedirectResponse(url="/admin", status_code=303)
return templates.TemplateResponse(
request=request,
name="admin_login.html",
context={"error": None}
)
@app.post("/admin/login")
async def admin_login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db)
):
admin = db.query(AdminUser).filter(AdminUser.username == username.strip()).first()
if not admin or not verify_password(admin.password_hash, password):
return templates.TemplateResponse(
request=request,
name="admin_login.html",
context={"error": "Ungültiger Benutzername oder Zauberspruch (Passwort)."},
status_code=400
)
request.session["admin_user"] = admin.username
return RedirectResponse(url="/admin", status_code=303)
@app.get("/admin/logout")
async def admin_logout(request: Request):
request.session.clear()
return RedirectResponse(url="/admin/login", status_code=303)
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, db: Session = Depends(get_db)):
if not request.session.get("admin_user"):
return RedirectResponse(url="/admin/login", status_code=303)
active_questions = db.query(Question).filter(Question.status == "active").order_by(Question.sort_order.asc(), Question.id.asc()).all()
pending_questions = db.query(Question).filter(Question.status == "needs_review").order_by(Question.created_at.desc()).all()
rejected_questions = db.query(Question).filter(Question.status == "rejected").order_by(Question.created_at.desc()).all()
return templates.TemplateResponse(
request=request,
name="admin.html",
context={
"admin_user": request.session["admin_user"],
"active_questions": active_questions,
"pending_questions": pending_questions,
"rejected_questions": rejected_questions
}
)
@app.post("/admin/questions/new")
async def admin_add_question(
request: Request,
question: str = Form(...),
canonical_answer: str = Form(...),
hint: str = Form(None),
category: str = Form("harry_potter"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
if not request.session.get("admin_user"):
return RedirectResponse(url="/admin/login", status_code=303)
new_q = Question(
category=category.strip(),
question=question.strip(),
canonical_answer=canonical_answer.strip(),
hint=hint.strip() if hint else None,
status="active",
sort_order=sort_order,
submitted_by=f"Admin ({request.session['admin_user']})"
)
db.add(new_q)
db.commit()
return RedirectResponse(url="/admin", status_code=303)
@app.post("/admin/questions/{qid}/approve")
async def admin_approve_question(qid: int, request: Request, db: Session = Depends(get_db)):
if not request.session.get("admin_user"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
q = db.query(Question).filter(Question.id == qid).first()
if not q:
raise HTTPException(status_code=404, detail="Frage nicht gefunden.")
# Determine highest sort_order
max_order = db.query(Question.sort_order).filter(Question.status == "active").order_by(Question.sort_order.desc()).first()
next_order = (max_order[0] + 1) if max_order and max_order[0] else 1
q.status = "active"
q.sort_order = next_order
q.reviewed_at = datetime.datetime.utcnow()
q.reviewed_by = request.session.get("admin_user")
db.commit()
return {"success": True, "message": f"Frage #{qid} wurde erfolgreich freigeschaltet!"}
@app.post("/admin/questions/{qid}/reject")
async def admin_reject_question(qid: int, request: Request, db: Session = Depends(get_db)):
if not request.session.get("admin_user"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
q = db.query(Question).filter(Question.id == qid).first()
if not q:
raise HTTPException(status_code=404, detail="Frage nicht gefunden.")
q.status = "rejected"
q.reviewed_at = datetime.datetime.utcnow()
q.reviewed_by = request.session.get("admin_user")
db.commit()
return {"success": True, "message": f"Frage #{qid} wurde abgelehnt."}
@app.post("/admin/questions/{qid}/edit")
async def admin_edit_question(
qid: int,
request: Request,
question: str = Form(...),
canonical_answer: str = Form(...),
hint: str = Form(None),
status_val: str = Form("active"),
sort_order: int = Form(0),
db: Session = Depends(get_db)
):
if not request.session.get("admin_user"):
return RedirectResponse(url="/admin/login", status_code=303)
q = db.query(Question).filter(Question.id == qid).first()
if not q:
raise HTTPException(status_code=404, detail="Frage nicht gefunden.")
q.question = question.strip()
q.canonical_answer = canonical_answer.strip()
q.hint = hint.strip() if hint else None
q.status = status_val.strip()
q.sort_order = sort_order
db.commit()
return RedirectResponse(url="/admin", status_code=303)
@app.post("/admin/questions/{qid}/delete")
async def admin_delete_question(qid: int, request: Request, db: Session = Depends(get_db)):
if not request.session.get("admin_user"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
q = db.query(Question).filter(Question.id == qid).first()
if not q:
raise HTTPException(status_code=404, detail="Frage nicht gefunden.")
db.delete(q)
db.commit()
return {"success": True, "message": f"Frage #{qid} gelöscht."}
@app.post("/admin/questions/test-match")
async def admin_test_match(
request: Request,
test_input: str = Form(...),
canonical: str = Form(...)
):
if not request.session.get("admin_user"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
matched = is_answer_correct(test_input, canonical)
clean_in = normalize_text(test_input)
clean_target = normalize_text(canonical)
dist = levenshtein_distance(clean_in, clean_target)
return {
"matched": matched,
"input_clean": clean_in,
"target_clean": clean_target,
"levenshtein_distance": dist
}