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 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) -> dict: options_list = None if q.question_type == "multiple_choice" and q.options: try: parsed = json.loads(q.options) if isinstance(parsed, list): options_list = list(parsed) random.shuffle(options_list) except Exception: options_list = [opt.strip() for opt in q.options.split(",") if opt.strip()] random.shuffle(options_list) return { "question_number": number, "total_questions": total, "category": q.category, "question": q.question, "question_type": q.question_type or "text", "options": options_list } @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] res = format_question_for_client(first_q, 1, len(question_ids)) res.update({ "session_id": session_id, "score": 0, "attempts_used": 0, "max_attempts": 3 }) 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.") 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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) 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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) 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.") 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) 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, 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"), 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) 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, 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"), 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 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 }