From 58959ca881d69ef48ef2cde1c8de1788b4a22772 Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Mon, 21 Sep 2026 11:35:39 +0200 Subject: [PATCH] Feature: Support both Free-Text and Multiple-Choice question types --- app/main.py | 86 +++++++++++++++++++++++------- app/models.py | 2 + app/schemas.py | 2 + app/static/css/style.css | 82 ++++++++++++++++++++++++++++ app/static/js/admin.js | 16 +++++- app/static/js/quiz.js | 78 ++++++++++++++++++++++----- app/static/js/vorschlag.js | 47 +++++++++++++--- app/templates/admin.html | 66 +++++++++++++++++++++-- app/templates/index.html | 13 ++++- app/templates/vorschlag.html | 20 ++++++- tests/migrate_mc.py | 12 +++++ tests/test_mc.py | 71 ++++++++++++++++++++++++ test_quiz.py => tests/test_quiz.py | 0 13 files changed, 448 insertions(+), 47 deletions(-) create mode 100644 tests/migrate_mc.py create mode 100644 tests/test_mc.py rename test_quiz.py => tests/test_quiz.py (100%) diff --git a/app/main.py b/app/main.py index d13b01a..d5277da 100755 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ 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 @@ -28,6 +29,27 @@ 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() @@ -65,16 +87,14 @@ async def start_game(db: Session = Depends(get_db)): db.commit() first_q = questions[0] - return { + res = format_question_for_client(first_q, 1, len(question_ids)) + res.update({ "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 - } + }) + return res @app.post("/api/game/check") async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)): @@ -118,12 +138,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) 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 - } + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) return { "correct": True, @@ -155,12 +170,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) 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 - } + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) return { "correct": False, @@ -218,11 +228,23 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe 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 @@ -304,17 +326,31 @@ async def admin_add_question( 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']})" @@ -365,6 +401,8 @@ async def admin_edit_question( 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) @@ -379,6 +417,18 @@ async def admin_edit_question( 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() diff --git a/app/models.py b/app/models.py index f36f24c..bc4899c 100755 --- a/app/models.py +++ b/app/models.py @@ -10,6 +10,8 @@ class Question(Base): question = Column(Text, nullable=False) canonical_answer = Column(String(255), nullable=False) accepted_answers = Column(Text, nullable=True) # JSON or comma-delimited + question_type = Column(String(20), default="text") # 'text' or 'multiple_choice' + options = Column(Text, nullable=True) # JSON list of options for multiple choice hint = Column(Text, nullable=True) status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived submitted_by = Column(String(100), nullable=True) diff --git a/app/schemas.py b/app/schemas.py index 6fa8b16..1306545 100755 --- a/app/schemas.py +++ b/app/schemas.py @@ -30,6 +30,8 @@ class SuggestionCreateRequest(BaseModel): antwort: str tipp: Optional[str] = None name: Optional[str] = "Gast" + question_type: Optional[str] = "text" + falsche_antworten: Optional[List[str]] = None class QuestionAdminCreate(BaseModel): category: str = "harry_potter" diff --git a/app/static/css/style.css b/app/static/css/style.css index c762a88..6da826b 100755 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -428,6 +428,88 @@ body { box-shadow: 0 0 12px rgba(201, 168, 106, 0.4); } +/* Multiple Choice Styles */ +.choice-group { + width: 100%; + margin-top: 10px; +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + width: 100%; + max-width: 650px; + margin: 0 auto; +} + +.choice-btn { + background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--border-gold); + border-radius: 10px; + padding: 14px 18px; + color: var(--text-main); + font-size: 1.05rem; + font-family: inherit; + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.25s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.choice-btn:hover:not(:disabled) { + background: rgba(201, 168, 106, 0.18); + border-color: var(--gold); + box-shadow: 0 0 15px rgba(201, 168, 106, 0.4); + transform: translateY(-2px); +} + +.choice-btn:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.choice-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: rgba(201, 168, 106, 0.2); + border: 1px solid var(--gold); + border-radius: 50%; + color: var(--gold-bright); + font-weight: bold; + font-size: 0.9rem; + flex-shrink: 0; +} + +.choice-text { + flex: 1; +} + +.choice-selected { + border-color: var(--gold-bright); + background: rgba(201, 168, 106, 0.3); +} + +.choice-correct { + background: rgba(46, 204, 113, 0.25) !important; + border-color: #2ecc71 !important; + color: #55efc4 !important; + box-shadow: 0 0 15px rgba(46, 204, 113, 0.6) !important; +} + +.choice-wrong { + background: rgba(231, 76, 60, 0.25) !important; + border-color: #e74c3c !important; + color: #ff7675 !important; + box-shadow: 0 0 15px rgba(231, 76, 60, 0.5) !important; +} + .hint-box { margin-top: 20px; padding: 15px; diff --git a/app/static/js/admin.js b/app/static/js/admin.js index a2d2c9c..bc5d0ad 100755 --- a/app/static/js/admin.js +++ b/app/static/js/admin.js @@ -7,8 +7,9 @@ function showTab(tabId) { targetTab.classList.add("active"); } - // Mark active button - event.currentTarget.classList.add("active"); + if (event && event.currentTarget) { + event.currentTarget.classList.add("active"); + } } function toggleEdit(id) { @@ -18,6 +19,17 @@ function toggleEdit(id) { } } +function toggleAdminNewType(type) { + const mcGroup = document.getElementById("adminMcOptions"); + if (mcGroup) { + if (type === "multiple_choice") { + mcGroup.classList.remove("hidden"); + } else { + mcGroup.classList.add("hidden"); + } + } +} + async function approveQuestion(id) { if (!confirm(`Möchtest du Frage #${id} wirklich freischalten und für alle Spieler aktivieren?`)) { return; diff --git a/app/static/js/quiz.js b/app/static/js/quiz.js index a7047cc..a23fe3f 100755 --- a/app/static/js/quiz.js +++ b/app/static/js/quiz.js @@ -103,16 +103,42 @@ function renderQuestion(qData) { document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`; document.getElementById("frageText").innerText = qData.question; + const textGroup = document.getElementById("textAnswerGroup"); + const choiceGroup = document.getElementById("choiceAnswerGroup"); + const choiceContainer = document.getElementById("choiceOptionsContainer"); const input = document.getElementById("answerInput"); - input.value = ""; - input.disabled = false; - input.focus(); - document.getElementById("checkBtn").disabled = false; - document.getElementById("hintBtn").disabled = false; document.getElementById("hintBox").classList.add("hidden"); document.getElementById("feedbackBox").innerHTML = ""; + if (qData.question_type === "multiple_choice" && qData.options && qData.options.length > 0) { + textGroup.classList.add("hidden"); + choiceGroup.classList.remove("hidden"); + choiceContainer.innerHTML = ""; + + const letters = ["A", "B", "C", "D", "E", "F"]; + qData.options.forEach((opt, idx) => { + const btn = document.createElement("button"); + btn.className = "choice-btn"; + btn.type = "button"; + btn.innerHTML = `${letters[idx] || "•"} ${opt}`; + btn.onclick = () => submitAnswer(opt, btn); + choiceContainer.appendChild(btn); + }); + } else { + choiceGroup.classList.add("hidden"); + textGroup.classList.remove("hidden"); + input.value = ""; + input.disabled = false; + document.getElementById("checkBtn").disabled = false; + input.focus(); + } + + const hintBtn = document.getElementById("hintBtn"); + if (hintBtn) hintBtn.disabled = false; + const hintBtnMC = document.getElementById("hintBtnMC"); + if (hintBtnMC) hintBtnMC.disabled = false; + updateProgress(); } @@ -121,17 +147,28 @@ function updateProgress() { document.getElementById("progressBar").style.width = percent + "%"; } -async function checkAnswer() { +function checkAnswer() { const input = document.getElementById("answerInput"); const answer = input.value.trim(); - if (!answer) { input.focus(); return; } + submitAnswer(answer); +} +async function submitAnswer(answer, clickedBtn = null) { const checkBtn = document.getElementById("checkBtn"); - checkBtn.disabled = true; + const input = document.getElementById("answerInput"); + if (checkBtn) checkBtn.disabled = true; + if (input) input.disabled = true; + + // Disable all choice buttons + const choiceBtns = document.querySelectorAll(".choice-btn"); + choiceBtns.forEach(b => b.disabled = true); + if (clickedBtn) { + clickedBtn.classList.add("choice-selected"); + } try { const res = await fetch("/api/game/check", { @@ -157,9 +194,12 @@ async function checkAnswer() { if (result.correct) { // Success! playSound('magic'); + if (clickedBtn) { + clickedBtn.classList.remove("choice-selected"); + clickedBtn.classList.add("choice-correct"); + } frageBox.classList.add("sparkle-effect"); feedbackBox.innerHTML = `✨ Richtig! 1 Hauspunkt für dein Haus! ✨`; - input.disabled = true; setTimeout(() => { frageBox.classList.remove("sparkle-effect"); @@ -175,6 +215,10 @@ async function checkAnswer() { } else { // Incorrect playSound('wrong'); + if (clickedBtn) { + clickedBtn.classList.remove("choice-selected"); + clickedBtn.classList.add("choice-wrong"); + } frageBox.classList.add("shake-effect"); setTimeout(() => frageBox.classList.remove("shake-effect"), 500); @@ -182,12 +226,11 @@ async function checkAnswer() { document.getElementById("attemptBadge").innerText = `Versuch ${Math.min(attemptsUsed + 1, maxAttempts)} von ${maxAttempts}`; if (result.attempts_left === 0) { - // Exhausted all 3 attempts + // Exhausted all attempts feedbackBox.innerHTML = `
Leider nicht richtig (3 Fehlversuche).
Richtige Lösung: ${result.revealed_answer}
`; - input.disabled = true; setTimeout(() => { if (result.is_finished) { @@ -202,14 +245,21 @@ async function checkAnswer() { } else { // Still attempts remaining feedbackBox.innerHTML = `Leider falsch! Noch ${result.attempts_left} Versuch(e).`; - checkBtn.disabled = false; - input.select(); + if (checkBtn) checkBtn.disabled = false; + if (input) { + input.disabled = false; + input.select(); + } + // Re-enable choice buttons + choiceBtns.forEach(b => b.disabled = false); } } } catch (err) { alert("Netzwerkfehler: " + err.message); - checkBtn.disabled = false; + if (checkBtn) checkBtn.disabled = false; + if (input) input.disabled = false; + choiceBtns.forEach(b => b.disabled = false); } } diff --git a/app/static/js/vorschlag.js b/app/static/js/vorschlag.js index 37fd63c..e8ab344 100755 --- a/app/static/js/vorschlag.js +++ b/app/static/js/vorschlag.js @@ -1,3 +1,19 @@ +function toggleVorschlagType(type) { + const mcGroup = document.getElementById("mcOptionsGroup"); + const antwortLabel = document.getElementById("antwortLabel"); + const antwortHelp = document.getElementById("antwortHelp"); + + if (type === "multiple_choice") { + mcGroup.classList.remove("hidden"); + antwortLabel.innerText = "Richtige Antwort (die korrekte Option):"; + antwortHelp.innerText = "Diese Antwort wird zusammen mit den falschen Optionen zufällig für den Spieler angeordnet."; + } else { + mcGroup.classList.add("hidden"); + antwortLabel.innerText = "Richtige Antwort:"; + antwortHelp.innerText = "Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab."; + } +} + async function submitSuggestion(event) { event.preventDefault(); @@ -5,6 +21,7 @@ async function submitSuggestion(event) { const frage = document.getElementById("frage").value.trim(); const antwort = document.getElementById("antwort").value.trim(); const tipp = document.getElementById("tipp").value.trim(); + const qType = document.getElementById("q_type").value; const submitBtn = document.getElementById("submitBtn"); const resultBox = document.getElementById("resultMessage"); @@ -13,19 +30,36 @@ async function submitSuggestion(event) { return; } + let falscheAntworten = []; + if (qType === "multiple_choice") { + const w1 = document.getElementById("wrong1").value.trim(); + const w2 = document.getElementById("wrong2").value.trim(); + const w3 = document.getElementById("wrong3").value.trim(); + falscheAntworten = [w1, w2, w3].filter(x => x.length > 0); + + if (falscheAntworten.length === 0) { + alert("Bitte gib bei Multiple-Choice mindestens eine falsche Antwortmöglichkeit an."); + return; + } + } + submitBtn.disabled = true; submitBtn.innerText = "✨ Vorschlag wird übertragen..."; try { + const payload = { + name: name || "Gast", + frage: frage, + antwort: antwort, + tipp: tipp || null, + question_type: qType, + falsche_antworten: falscheAntworten + }; + const res = await fetch("/api/suggestions", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: name || "Gast", - frage: frage, - antwort: antwort, - tipp: tipp || null - }) + body: JSON.stringify(payload) }); const data = await res.json(); @@ -39,6 +73,7 @@ async function submitSuggestion(event) { `; resultBox.classList.remove("hidden"); document.getElementById("suggestionForm").reset(); + toggleVorschlagType("text"); } else { resultBox.innerHTML = `
diff --git a/app/templates/admin.html b/app/templates/admin.html index 6b8fb14..3c3c6d0 100755 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -52,15 +52,25 @@ {% for q in pending_questions %}
- ⚠️ Status: Prüfung ausstehend +
+ ⚠️ Prüfung ausstehend + {% if q.question_type == 'multiple_choice' %} + 🔘 Multiple-Choice + {% else %} + 🔤 Freitext + {% endif %} +
Von: {{ q.submitted_by or 'Gast' }} am {{ q.created_at.strftime('%d.%m.%Y %H:%M') }}

{{ q.question }}

Erwartete Antwort: {{ q.canonical_answer }}

+ {% if q.options %} +

Auswahl-Optionen: {{ q.options }}

+ {% endif %} {% if q.hint %} -

Tipp: {{ q.hint }}

+

Tipp: {{ q.hint }}

{% endif %} @@ -93,10 +103,21 @@
+
+ + +
+
+ + +
@@ -120,15 +141,25 @@ {% for q in active_questions %}
- Reihenfolge #{{ q.sort_order }} +
+ Reihenfolge #{{ q.sort_order }} + {% if q.question_type == 'multiple_choice' %} + 🔘 Multiple-Choice + {% else %} + 🔤 Freitext + {% endif %} +
Kategorie: {{ q.category }}

{{ loop.index }}. {{ q.question }}

Antwort: {{ q.canonical_answer }}

+ {% if q.options %} +

Optionen: {{ q.options }}

+ {% endif %} {% if q.hint %} -

Tipp: {{ q.hint }}

+

Tipp: {{ q.hint }}

{% endif %}
@@ -144,10 +175,21 @@
+
+ + +
+
+ + +
@@ -179,6 +221,14 @@
+
+ + +
+
@@ -189,6 +239,14 @@
+ + +
diff --git a/app/templates/index.html b/app/templates/index.html index 14610fd..f828f48 100755 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -53,7 +53,8 @@

Lade verzauberte Schriftrollen...

-
+ +
@@ -66,6 +67,16 @@
+ + +
- + + +
+ +
+ - Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab. + Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab. +
+ + +
diff --git a/tests/migrate_mc.py b/tests/migrate_mc.py new file mode 100644 index 0000000..d9a2993 --- /dev/null +++ b/tests/migrate_mc.py @@ -0,0 +1,12 @@ +from app.database import engine +from sqlalchemy import text + +def migrate(): + with engine.connect() as conn: + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS question_type VARCHAR(20) DEFAULT 'text'")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS options TEXT NULL")) + conn.commit() + print("Migration successful: added question_type and options columns.") + +if __name__ == "__main__": + migrate() diff --git a/tests/test_mc.py b/tests/test_mc.py new file mode 100644 index 0000000..2af06d3 --- /dev/null +++ b/tests/test_mc.py @@ -0,0 +1,71 @@ +import urllib.request +import urllib.parse +import http.cookiejar +import json + +BASE_URL = "http://127.0.0.1:8001" + +def test_mc(): + print("--- 1. Submit Multiple Choice Suggestion ---") + sugg_payload = json.dumps({ + "frage": "Welches Tier ist das Wappentier von Ravenclaw?", + "antwort": "Adler", + "tipp": "Viele Muggel denken fälschlicherweise an einen Raben.", + "name": "Luna Lovegood", + "question_type": "multiple_choice", + "falsche_antworten": ["Rabe", "Dachs", "Schlange"] + }).encode("utf-8") + + req = urllib.request.Request(f"{BASE_URL}/api/suggestions", data=sugg_payload, headers={"Content-Type": "application/json"}) + res = urllib.request.urlopen(req) + res_data = json.loads(res.read().decode()) + print("Suggestion result:", res_data) + assert res_data["success"] is True + + print("\n--- 2. Admin Login & Approve Question ---") + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + + login_data = urllib.parse.urlencode({"username": "admin", "password": "Alohomora2026!"}).encode() + opener.open(f"{BASE_URL}/admin/login", data=login_data) + + admin_page = opener.open(f"{BASE_URL}/admin").read().decode() + assert "Wappentier von Ravenclaw" in admin_page + assert "Multiple-Choice" in admin_page + print("Admin dashboard shows new Multiple-Choice question.") + + # Find the question ID from DB + from app.database import SessionLocal + from app.models import Question + db = SessionLocal() + q = db.query(Question).filter(Question.question.like("%Wappentier von Ravenclaw%")).first() + qid = q.id + print(f"Found Question ID #{qid}, status={q.status}, type={q.question_type}, options={q.options}") + assert q.question_type == "multiple_choice" + parsed_opts = json.loads(q.options) + assert "Adler" in parsed_opts + assert "Rabe" in parsed_opts + db.close() + + # Approve question + approve_res = opener.open(f"{BASE_URL}/admin/questions/{qid}/approve", data=b"") + app_data = json.loads(approve_res.read().decode()) + print("Approve result:", app_data) + assert app_data["success"] is True + + print("\n--- 3. Verify Question Served to Client with Shuffled Options ---") + from app.main import format_question_for_client + db = SessionLocal() + q_updated = db.query(Question).filter(Question.id == qid).first() + client_q = format_question_for_client(q_updated, 16, 16) + print("Client Question formatted:", client_q) + assert client_q["question_type"] == "multiple_choice" + assert len(client_q["options"]) == 4 + assert "Adler" in client_q["options"] + assert "canonical_answer" not in client_q + db.close() + + print("\n✅ Multiple Choice & Free Text integration tests successfully passed!") + +if __name__ == "__main__": + test_mc() diff --git a/test_quiz.py b/tests/test_quiz.py similarity index 100% rename from test_quiz.py rename to tests/test_quiz.py