Files
quiz/app/main.py
T

531 lines
19 KiB
Python
Executable File

import os
import uuid
import json
import random
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, GameStartRequest
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"))
def format_question_for_client(q: Question, number: int, total: int, lang: str = "de") -> dict:
options_list = None
if lang == "en" and q.question_en:
q_text = q.question_en
opts_raw = q.options_en or q.options
else:
q_text = q.question
opts_raw = q.options
if q.question_type == "multiple_choice" and opts_raw:
try:
parsed = json.loads(opts_raw)
if isinstance(parsed, list):
options_list = list(parsed)
random.shuffle(options_list)
except Exception:
options_list = [opt.strip() for opt in opts_raw.split(",") if opt.strip()]
random.shuffle(options_list)
return {
"question_number": number,
"total_questions": total,
"category": q.category,
"question": q_text,
"question_type": q.question_type or "text",
"options": options_list,
"difficulty": q.difficulty or "medium"
}
@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(payload: GameStartRequest = None, db: Session = Depends(get_db)):
lang = payload.lang if payload and payload.lang in ["de", "en"] else "de"
difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all"
# Fetch active questions matching difficulty (or all active)
query = db.query(Question).filter(Question.status == "active")
if difficulty != "all":
query = query.filter(Question.difficulty == difficulty)
pool = query.all()
if not pool:
# Fallback to all active if filtered pool empty
pool = db.query(Question).filter(Question.status == "active").all()
if not pool:
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
# Randomly sample 10 questions (or all if fewer)
sample_size = min(10, len(pool))
selected_questions = random.sample(pool, sample_size)
question_ids = [q.id for q in selected_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,
lang=lang,
difficulty=difficulty
)
db.add(new_session)
db.commit()
first_q = selected_questions[0]
res = format_question_for_client(first_q, 1, len(question_ids), lang=lang)
res.update({
"session_id": session_id,
"score": 0,
"attempts_used": 0,
"max_attempts": 3,
"lang": lang,
"difficulty": difficulty
})
return res
@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.")
session_lang = session.lang or "de"
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, current_q.answer_en)
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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang)
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
if session_lang == "en" and current_q.answer_en:
revealed = current_q.answer_en
else:
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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang)
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.")
session_lang = session.lang or "de"
question_ids = json.loads(session.question_ids)
if session.current_question_idx >= len(question_ids):
return {"hint": "No hint available." if session_lang == "en" else "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()
if not current_q:
return {"hint": "No hint available." if session_lang == "en" else "Kein Hinweis verfügbar."}
if session_lang == "en":
hint_text = current_q.hint_en if current_q.hint_en else (current_q.hint or "No hint available.")
else:
hint_text = current_q.hint or "Kein Tipp verfügbar."
return {"hint": hint_text}
@app.get("/finale", response_class=HTMLResponse)
async def finale(request: Request, score: int = 0, total: int = 10, lang: str = "de"):
return templates.TemplateResponse(
request=request,
name="finale.html",
context={"score": score, "total": total, "lang": lang}
)
@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.")
options_json = None
q_type = payload.question_type or "text"
if q_type == "multiple_choice":
opts = [payload.antwort.strip()]
if payload.falsche_antworten:
for opt in payload.falsche_antworten:
if opt and opt.strip():
opts.append(opt.strip())
options_json = json.dumps(opts, ensure_ascii=False)
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "medium"
new_suggestion = Question(
category="community",
question=payload.frage.strip(),
canonical_answer=payload.antwort.strip(),
hint=payload.tipp.strip() if payload.tipp else None,
question_type=q_type,
options=options_json,
difficulty=diff,
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"),
question_type: str = Form("text"),
difficulty: str = Form("medium"),
question_en: str = Form(None),
answer_en: str = Form(None),
hint_en: str = Form(None),
wrong_option_1: str = Form(None),
wrong_option_2: str = Form(None),
wrong_option_3: str = Form(None),
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)
options_json = None
if question_type == "multiple_choice":
opts = [canonical_answer.strip()]
for w in [wrong_option_1, wrong_option_2, wrong_option_3]:
if w and w.strip():
opts.append(w.strip())
options_json = json.dumps(opts, ensure_ascii=False)
diff = difficulty.strip() if difficulty and difficulty.strip() in ["easy", "medium", "hard"] else "medium"
new_q = Question(
category=category.strip(),
question=question.strip(),
canonical_answer=canonical_answer.strip(),
hint=hint.strip() if hint else None,
question_type=question_type.strip(),
options=options_json,
difficulty=diff,
question_en=question_en.strip() if question_en else None,
answer_en=answer_en.strip() if answer_en else None,
hint_en=hint_en.strip() if hint_en 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),
question_type: str = Form("text"),
difficulty: str = Form("medium"),
question_en: str = Form(None),
answer_en: str = Form(None),
hint_en: str = Form(None),
options_raw: 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.question_type = question_type.strip()
if difficulty and difficulty.strip() in ["easy", "medium", "hard"]:
q.difficulty = difficulty.strip()
q.question_en = question_en.strip() if question_en else None
q.answer_en = answer_en.strip() if answer_en else None
q.hint_en = hint_en.strip() if hint_en else None
if options_raw and options_raw.strip():
val = options_raw.strip()
if val.startswith("[") and val.endswith("]"):
q.options = val
else:
items = [x.strip() for x in val.split(",") if x.strip()]
q.options = json.dumps(items, ensure_ascii=False)
else:
q.options = 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
}