Files
quiz/app/main.py
T

816 lines
32 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, LeaderboardEntry
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, LeaderboardSubmitRequest
load_dotenv()
app = FastAPI(title="PotterTrivia – Das Harry-Potter-Wissensquiz")
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 {
"id": q.id,
"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"
}
def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict:
if total <= 10 and score <= 10:
pct = (score / total) if total > 0 else 0
else:
max_possible = (total * 100) if total <= 10 else total
pct = (score / max_possible) if max_possible > 0 else 0
is_de = (lang != "en")
if pct >= 0.90:
grade = "O"
title = "🧙‍♂️ Großmeister der Zauberei" if is_de else "🧙‍♂️ Grandmaster of Wizardry"
subtitle = "Note: O (Ohnegleichen)" if is_de else "Grade: O (Outstanding)"
desc = (
"Unglaublich! Albus Dumbledore überlegt bereits, dir das Büro des Schulleiters zu überlassen. "
"Du kennst selbst die geheimsten Winkel von Hogwarts besser als der Fast Kopflose Nick. "
"Das Zaubereiministerium verneigt sich in Ehrfurcht!"
if is_de else
"Incredible! Albus Dumbledore is already considering handing you the headmaster's office. "
"You know the secret passages of Hogwarts better than Nearly Headless Nick. "
"The Ministry of Magic bows in deep awe!"
)
badge_class = "tier-outstanding"
elif pct >= 0.70:
grade = "E"
title = "⚡ Meister-Hexe / Zauberer" if is_de else "⚡ Master Witch / Wizard"
subtitle = "Note: E (Erwartungen übertroffen)" if is_de else "Grade: E (Exceeds Expectations)"
desc = (
"Hermine Granger nickt anerkennend und Professor McGonagall vergibt 50 Punkte für dein Haus! "
"Dein Zauberstab zuckt blitzschnell und dein Wissen lässt selbst Ravenclaws erblassen."
if is_de else
"Hermione Granger nods in deep approval and Professor McGonagall awards 50 points to your House! "
"Your wandwork is razor-sharp and your knowledge makes even Ravenclaws jealous."
)
badge_class = "tier-exceeds"
elif pct >= 0.50:
grade = "A"
title = "📜 Solider Hogwarts-Schüler" if is_de else "📜 Solid Hogwarts Student"
subtitle = "Note: A (Annehmbar)" if is_de else "Grade: A (Acceptable)"
desc = (
"Bestanden! Ein paar Kessel sind zwar im Zaubertränke-Unterricht explodiert und Peeves hat dich mit Kreide beworfen, "
"aber du hast dich tapfer durchgeschlagen. Eine solide magische Leistung!"
if is_de else
"Passed! A few cauldrons blew up during Potions class and Peeves threw chalk at your head, "
"but you bravely fought your way through. A solid magical performance!"
)
badge_class = "tier-acceptable"
elif pct >= 0.20:
grade = "M"
title = "🧹 Quaffel-Fänger in Not" if is_de else "🧹 Quaffle Catcher in Distress"
subtitle = "Note: M (Mies)" if is_de else "Grade: P (Poor)"
desc = (
"Au weia... Hat Ron Weasley dir seinen mit Tesafilm geklebten Zauberstab geliehen? "
"Deine Eule hat sich wohl im Nebel verflogen. Zeit für ein paar Extrastunden in der Bibliothek bei Madam Pince!"
if is_de else
"Yikes... Did Ron Weasley lend you his Spellotaped wand? "
"Your owl must have gotten lost in the fog. Time for extra study sessions in the library with Madam Pince!"
)
badge_class = "tier-poor"
else:
grade = "T"
title = "🧌 Bergtroll im Kerker" if is_de else "🧌 Mountain Troll in the Dungeon"
subtitle = "Note: T (Troll)" if is_de else "Grade: T (Troll)"
desc = (
"Alarm im Schloss! Du hast mit der Holzkeule auf die Fragen eingeschlagen. "
"Schnell weg, bevor Hausmeister Filch dich beim lebenslangen Nachsitzen in den Kerkern erwischt!"
if is_de else
"Troll in the dungeon! You smashed the questions with a wooden club. "
"Run quickly before caretaker Filch catches you for lifelong detention in the dungeons!"
)
badge_class = "tier-troll"
return {
"grade": grade,
"title": title,
"subtitle": subtitle,
"description": desc,
"badge_class": badge_class
}
@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}
)
VALID_CATEGORIES = ["all", "books", "movies", "creatures", "spells", "characters", "hogwarts", "quidditch", "lore", "general"]
@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"
category = payload.category if payload and payload.category in VALID_CATEGORIES else "all"
base_query = db.query(Question).filter(Question.status == "active")
if category != "all":
if category == "books":
cat_query = base_query.filter((Question.category == "books") | (Question.question.like("%Kröter%")))
else:
cat_query = base_query.filter(Question.category == category)
else:
cat_query = base_query
if difficulty != "all":
strict_pool = cat_query.filter(Question.difficulty == difficulty).all()
if len(strict_pool) >= 10 or category == "all":
pool = strict_pool
else:
# Keep all difficulty matches in this category and top up from the same category
cat_all = cat_query.all()
strict_ids = {q.id for q in strict_pool}
extra = [q for q in cat_all if q.id not in strict_ids]
random.shuffle(extra)
pool = strict_pool + extra[:max(0, 10 - len(strict_pool))]
else:
pool = cat_query.all()
if not pool:
pool = db.query(Question).filter(Question.status == "active").all()
if not pool:
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
recent_set = set(payload.recent_ids or []) if payload else set()
wrong_set = set(payload.wrong_ids or []) if payload else set()
# Priority pool 1: Questions user previously failed (learning pool)
wrong_pool = [q for q in pool if q.id in wrong_set]
# Priority pool 2: Fresh questions not seen recently and not in wrong pool
fresh_pool = [q for q in pool if q.id not in recent_set and q.id not in wrong_set]
# Priority pool 3: Recent questions fallback
recent_pool = [q for q in pool if q.id in recent_set and q.id not in wrong_set]
selected_questions = []
# Up to 3 questions from wrong_pool for spaced repetition
if wrong_pool:
k_wrong = min(3, len(wrong_pool))
selected_questions.extend(random.sample(wrong_pool, k_wrong))
# Fill remaining from fresh_pool
needed = min(10, len(pool)) - len(selected_questions)
if needed > 0 and fresh_pool:
k_fresh = min(needed, len(fresh_pool))
selected_questions.extend(random.sample(fresh_pool, k_fresh))
# If still needed, fill from recent_pool
needed = min(10, len(pool)) - len(selected_questions)
if needed > 0 and recent_pool:
k_recent = min(needed, len(recent_pool))
selected_questions.extend(random.sample(recent_pool, k_recent))
# Fallback to any remaining from pool
needed = min(10, len(pool)) - len(selected_questions)
if needed > 0:
remaining = [q for q in pool if q not in selected_questions]
if remaining:
selected_questions.extend(random.sample(remaining, min(needed, len(remaining))))
# Shuffle to mix learning and fresh questions
random.shuffle(selected_questions)
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,
created_at=datetime.datetime.utcnow()
)
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
session.finished_at = datetime.datetime.utcnow()
if session.created_at:
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
db.commit()
return {
"correct": False,
"score": session.score,
"points_earned": 0,
"question_id": None,
"attempts_used": session.attempts_current,
"attempts_left": 0,
"is_finished": True,
"duration_seconds": session.duration_seconds,
"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:
# Tiered points based on attempt
if session.attempts_current == 0:
points_earned = 100
elif session.attempts_current == 1:
points_earned = 70
else:
points_earned = 40
session.score += points_earned
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()
if session.created_at:
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
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,
"points_earned": points_earned,
"question_id": current_q_id,
"attempts_used": 0,
"attempts_left": 3,
"is_finished": is_finished,
"duration_seconds": session.duration_seconds,
"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:
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()
if session.created_at:
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
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,
"points_earned": 0,
"question_id": current_q_id,
"attempts_used": 3,
"attempts_left": 0,
"is_finished": is_finished,
"duration_seconds": session.duration_seconds,
"revealed_answer": revealed,
"next_question": next_q_data
}
else:
db.commit()
return {
"correct": False,
"score": session.score,
"points_earned": 0,
"question_id": current_q_id,
"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, time: int = 0, lang: str = "de", diff: str = "all"):
max_score = 1000 if (total <= 10 and score > 10) else (total * 100 if total <= 10 else total)
tier = get_tier_evaluation(score, total, lang)
if time >= 60:
minutes = time // 60
secs = time % 60
time_str = f"{minutes} Min. {secs} Sek." if lang != "en" else f"{minutes} min. {secs} sec."
else:
time_str = f"{time} Sek." if lang != "en" else f"{time} sec."
clean_diff = diff if diff in ["easy", "medium", "hard"] else "all"
return templates.TemplateResponse(
request=request,
name="finale.html",
context={
"score": score,
"total": total,
"max_score": max_score,
"time_seconds": time,
"time_formatted": time_str,
"lang": lang,
"difficulty": clean_diff,
"tier": tier
}
)
@app.get("/bestenliste", response_class=HTMLResponse)
async def bestenliste_page(request: Request, lang: str = "de"):
return templates.TemplateResponse(
request=request,
name="bestenliste.html",
context={"lang": lang}
)
@app.get("/impressum", response_class=HTMLResponse)
async def impressum_page(request: Request, lang: str = "de"):
return templates.TemplateResponse(
request=request,
name="impressum.html",
context={"lang": lang}
)
@app.get("/datenschutz", response_class=HTMLResponse)
async def datenschutz_page(request: Request, lang: str = "de"):
return templates.TemplateResponse(
request=request,
name="datenschutz.html",
context={"lang": lang}
)
@app.post("/api/leaderboard")
async def submit_leaderboard(payload: LeaderboardSubmitRequest, db: Session = Depends(get_db)):
nick = payload.nickname.strip()
if not nick:
wizard_adjectives = ["Mutiger", "Schlauer", "Weiser", "Flinke", "Listiger", "Fliegender"]
wizard_nouns = ["Greif", "Phönix", "Zauberer", "Drache", "Niffler", "Thestral"]
nick = f"{random.choice(wizard_adjectives)}_{random.choice(wizard_nouns)}_{random.randint(10, 99)}"
nick = nick[:30]
time_sec = max(1, payload.time_seconds)
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "all"
entry = LeaderboardEntry(
nickname=nick,
score=payload.score,
total_questions=payload.total_questions or 10,
time_seconds=time_sec,
difficulty=diff,
lang=payload.lang or "de",
session_id=payload.session_id,
created_at=datetime.datetime.utcnow()
)
db.add(entry)
db.commit()
db.refresh(entry)
better_query = db.query(LeaderboardEntry)
if entry.difficulty and entry.difficulty != "all":
better_query = better_query.filter(LeaderboardEntry.difficulty == entry.difficulty)
better_count = better_query.filter(
(LeaderboardEntry.score > entry.score) |
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds < entry.time_seconds)) |
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds == entry.time_seconds) & (LeaderboardEntry.id < entry.id))
).count()
rank = better_count + 1
return {
"success": True,
"id": entry.id,
"entry_id": entry.id,
"rank": rank,
"nickname": entry.nickname,
"score": entry.score,
"total_questions": entry.total_questions,
"time_seconds": entry.time_seconds
}
@app.get("/api/leaderboard")
async def get_leaderboard(difficulty: str = None, limit: int = 20, db: Session = Depends(get_db)):
query = db.query(LeaderboardEntry)
if difficulty and difficulty != "all":
query = query.filter(LeaderboardEntry.difficulty == difficulty)
entries = query.order_by(
LeaderboardEntry.score.desc(),
LeaderboardEntry.time_seconds.asc(),
LeaderboardEntry.created_at.asc()
).limit(min(100, limit)).all()
result = []
for idx, e in enumerate(entries, 1):
m = e.time_seconds // 60
s = e.time_seconds % 60
time_str = f"{m:02d}:{s:02d}"
result.append({
"rank": idx,
"id": e.id,
"nickname": e.nickname,
"score": e.score,
"total_questions": e.total_questions,
"time_seconds": e.time_seconds,
"time_formatted": time_str,
"difficulty": e.difficulty,
"date": e.created_at.strftime("%d.%m.%Y") if e.created_at else ""
})
return result
@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)
chosen_cat = payload.category if payload.category in ["books", "movies", "hogwarts", "characters", "spells", "creatures", "quidditch", "lore", "general"] else "general"
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "medium"
new_suggestion = Question(
category=chosen_cat,
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()
db.refresh(new_suggestion)
return {
"success": True,
"id": new_suggestion.id,
"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
}