420 lines
15 KiB
Python
Executable File
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
|
|
}
|