Feature: Support both Free-Text and Multiple-Choice question types

This commit is contained in:
kobold-ai
2026-09-21 11:35:39 +02:00
parent c3a21369ef
commit 58959ca881
13 changed files with 448 additions and 47 deletions
+68 -18
View File
@@ -1,6 +1,7 @@
import os import os
import uuid import uuid
import json import json
import random
import datetime import datetime
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form from fastapi import FastAPI, Request, Depends, HTTPException, status, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse 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") app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) 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") @app.on_event("startup")
def startup_event(): def startup_event():
seed_db() seed_db()
@@ -65,16 +87,14 @@ async def start_game(db: Session = Depends(get_db)):
db.commit() db.commit()
first_q = questions[0] first_q = questions[0]
return { res = format_question_for_client(first_q, 1, len(question_ids))
res.update({
"session_id": session_id, "session_id": session_id,
"question_number": 1,
"total_questions": len(question_ids),
"category": first_q.category,
"question": first_q.question,
"score": 0, "score": 0,
"attempts_used": 0, "attempts_used": 0,
"max_attempts": 3 "max_attempts": 3
} })
return res
@app.post("/api/game/check") @app.post("/api/game/check")
async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)): 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: if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q: if next_q:
next_q_data = { next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids))
"question_number": session.current_question_idx + 1,
"total_questions": len(question_ids),
"category": next_q.category,
"question": next_q.question
}
return { return {
"correct": True, "correct": True,
@@ -155,12 +170,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
if not is_finished: if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q: if next_q:
next_q_data = { next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids))
"question_number": session.current_question_idx + 1,
"total_questions": len(question_ids),
"category": next_q.category,
"question": next_q.question
}
return { return {
"correct": False, "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(): if not payload.frage.strip() or not payload.antwort.strip():
raise HTTPException(status_code=400, detail="Frage und Antwort dürfen nicht leer sein.") 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( new_suggestion = Question(
category="community", category="community",
question=payload.frage.strip(), question=payload.frage.strip(),
canonical_answer=payload.antwort.strip(), canonical_answer=payload.antwort.strip(),
hint=payload.tipp.strip() if payload.tipp else None, hint=payload.tipp.strip() if payload.tipp else None,
question_type=q_type,
options=options_json,
status="needs_review", # User Question Need Test / Review status="needs_review", # User Question Need Test / Review
submitted_by=payload.name.strip() if payload.name else "Gast", submitted_by=payload.name.strip() if payload.name else "Gast",
sort_order=999 sort_order=999
@@ -304,17 +326,31 @@ async def admin_add_question(
canonical_answer: str = Form(...), canonical_answer: str = Form(...),
hint: str = Form(None), hint: str = Form(None),
category: str = Form("harry_potter"), 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), sort_order: int = Form(0),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
if not request.session.get("admin_user"): if not request.session.get("admin_user"):
return RedirectResponse(url="/admin/login", status_code=303) 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( new_q = Question(
category=category.strip(), category=category.strip(),
question=question.strip(), question=question.strip(),
canonical_answer=canonical_answer.strip(), canonical_answer=canonical_answer.strip(),
hint=hint.strip() if hint else None, hint=hint.strip() if hint else None,
question_type=question_type.strip(),
options=options_json,
status="active", status="active",
sort_order=sort_order, sort_order=sort_order,
submitted_by=f"Admin ({request.session['admin_user']})" submitted_by=f"Admin ({request.session['admin_user']})"
@@ -365,6 +401,8 @@ async def admin_edit_question(
question: str = Form(...), question: str = Form(...),
canonical_answer: str = Form(...), canonical_answer: str = Form(...),
hint: str = Form(None), hint: str = Form(None),
question_type: str = Form("text"),
options_raw: str = Form(None),
status_val: str = Form("active"), status_val: str = Form("active"),
sort_order: int = Form(0), sort_order: int = Form(0),
db: Session = Depends(get_db) db: Session = Depends(get_db)
@@ -379,6 +417,18 @@ async def admin_edit_question(
q.question = question.strip() q.question = question.strip()
q.canonical_answer = canonical_answer.strip() q.canonical_answer = canonical_answer.strip()
q.hint = hint.strip() if hint else None 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.status = status_val.strip()
q.sort_order = sort_order q.sort_order = sort_order
db.commit() db.commit()
+2
View File
@@ -10,6 +10,8 @@ class Question(Base):
question = Column(Text, nullable=False) question = Column(Text, nullable=False)
canonical_answer = Column(String(255), nullable=False) canonical_answer = Column(String(255), nullable=False)
accepted_answers = Column(Text, nullable=True) # JSON or comma-delimited 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) hint = Column(Text, nullable=True)
status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived
submitted_by = Column(String(100), nullable=True) submitted_by = Column(String(100), nullable=True)
+2
View File
@@ -30,6 +30,8 @@ class SuggestionCreateRequest(BaseModel):
antwort: str antwort: str
tipp: Optional[str] = None tipp: Optional[str] = None
name: Optional[str] = "Gast" name: Optional[str] = "Gast"
question_type: Optional[str] = "text"
falsche_antworten: Optional[List[str]] = None
class QuestionAdminCreate(BaseModel): class QuestionAdminCreate(BaseModel):
category: str = "harry_potter" category: str = "harry_potter"
+82
View File
@@ -428,6 +428,88 @@ body {
box-shadow: 0 0 12px rgba(201, 168, 106, 0.4); 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 { .hint-box {
margin-top: 20px; margin-top: 20px;
padding: 15px; padding: 15px;
+14 -2
View File
@@ -7,8 +7,9 @@ function showTab(tabId) {
targetTab.classList.add("active"); targetTab.classList.add("active");
} }
// Mark active button if (event && event.currentTarget) {
event.currentTarget.classList.add("active"); event.currentTarget.classList.add("active");
}
} }
function toggleEdit(id) { 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) { async function approveQuestion(id) {
if (!confirm(`Möchtest du Frage #${id} wirklich freischalten und für alle Spieler aktivieren?`)) { if (!confirm(`Möchtest du Frage #${id} wirklich freischalten und für alle Spieler aktivieren?`)) {
return; return;
+64 -14
View File
@@ -103,16 +103,42 @@ function renderQuestion(qData) {
document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`; document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`;
document.getElementById("frageText").innerText = qData.question; 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"); 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("hintBox").classList.add("hidden");
document.getElementById("feedbackBox").innerHTML = ""; 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 = `<span class="choice-letter">${letters[idx] || "•"}</span> <span class="choice-text">${opt}</span>`;
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(); updateProgress();
} }
@@ -121,17 +147,28 @@ function updateProgress() {
document.getElementById("progressBar").style.width = percent + "%"; document.getElementById("progressBar").style.width = percent + "%";
} }
async function checkAnswer() { function checkAnswer() {
const input = document.getElementById("answerInput"); const input = document.getElementById("answerInput");
const answer = input.value.trim(); const answer = input.value.trim();
if (!answer) { if (!answer) {
input.focus(); input.focus();
return; return;
} }
submitAnswer(answer);
}
async function submitAnswer(answer, clickedBtn = null) {
const checkBtn = document.getElementById("checkBtn"); 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 { try {
const res = await fetch("/api/game/check", { const res = await fetch("/api/game/check", {
@@ -157,9 +194,12 @@ async function checkAnswer() {
if (result.correct) { if (result.correct) {
// Success! // Success!
playSound('magic'); playSound('magic');
if (clickedBtn) {
clickedBtn.classList.remove("choice-selected");
clickedBtn.classList.add("choice-correct");
}
frageBox.classList.add("sparkle-effect"); frageBox.classList.add("sparkle-effect");
feedbackBox.innerHTML = `<span class="feedback-correct">✨ Richtig! 1 Hauspunkt für dein Haus! ✨</span>`; feedbackBox.innerHTML = `<span class="feedback-correct">✨ Richtig! 1 Hauspunkt für dein Haus! ✨</span>`;
input.disabled = true;
setTimeout(() => { setTimeout(() => {
frageBox.classList.remove("sparkle-effect"); frageBox.classList.remove("sparkle-effect");
@@ -175,6 +215,10 @@ async function checkAnswer() {
} else { } else {
// Incorrect // Incorrect
playSound('wrong'); playSound('wrong');
if (clickedBtn) {
clickedBtn.classList.remove("choice-selected");
clickedBtn.classList.add("choice-wrong");
}
frageBox.classList.add("shake-effect"); frageBox.classList.add("shake-effect");
setTimeout(() => frageBox.classList.remove("shake-effect"), 500); 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}`; document.getElementById("attemptBadge").innerText = `Versuch ${Math.min(attemptsUsed + 1, maxAttempts)} von ${maxAttempts}`;
if (result.attempts_left === 0) { if (result.attempts_left === 0) {
// Exhausted all 3 attempts // Exhausted all attempts
feedbackBox.innerHTML = ` feedbackBox.innerHTML = `
<div class="feedback-wrong">Leider nicht richtig (3 Fehlversuche).</div> <div class="feedback-wrong">Leider nicht richtig (3 Fehlversuche).</div>
<div class="feedback-revealed">Richtige Lösung: <strong>${result.revealed_answer}</strong></div> <div class="feedback-revealed">Richtige Lösung: <strong>${result.revealed_answer}</strong></div>
`; `;
input.disabled = true;
setTimeout(() => { setTimeout(() => {
if (result.is_finished) { if (result.is_finished) {
@@ -202,14 +245,21 @@ async function checkAnswer() {
} else { } else {
// Still attempts remaining // Still attempts remaining
feedbackBox.innerHTML = `<span class="feedback-wrong">Leider falsch! Noch ${result.attempts_left} Versuch(e).</span>`; feedbackBox.innerHTML = `<span class="feedback-wrong">Leider falsch! Noch ${result.attempts_left} Versuch(e).</span>`;
checkBtn.disabled = false; if (checkBtn) checkBtn.disabled = false;
input.select(); if (input) {
input.disabled = false;
input.select();
}
// Re-enable choice buttons
choiceBtns.forEach(b => b.disabled = false);
} }
} }
} catch (err) { } catch (err) {
alert("Netzwerkfehler: " + err.message); alert("Netzwerkfehler: " + err.message);
checkBtn.disabled = false; if (checkBtn) checkBtn.disabled = false;
if (input) input.disabled = false;
choiceBtns.forEach(b => b.disabled = false);
} }
} }
+41 -6
View File
@@ -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) { async function submitSuggestion(event) {
event.preventDefault(); event.preventDefault();
@@ -5,6 +21,7 @@ async function submitSuggestion(event) {
const frage = document.getElementById("frage").value.trim(); const frage = document.getElementById("frage").value.trim();
const antwort = document.getElementById("antwort").value.trim(); const antwort = document.getElementById("antwort").value.trim();
const tipp = document.getElementById("tipp").value.trim(); const tipp = document.getElementById("tipp").value.trim();
const qType = document.getElementById("q_type").value;
const submitBtn = document.getElementById("submitBtn"); const submitBtn = document.getElementById("submitBtn");
const resultBox = document.getElementById("resultMessage"); const resultBox = document.getElementById("resultMessage");
@@ -13,19 +30,36 @@ async function submitSuggestion(event) {
return; 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.disabled = true;
submitBtn.innerText = "✨ Vorschlag wird übertragen..."; submitBtn.innerText = "✨ Vorschlag wird übertragen...";
try { 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", { const res = await fetch("/api/suggestions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(payload)
name: name || "Gast",
frage: frage,
antwort: antwort,
tipp: tipp || null
})
}); });
const data = await res.json(); const data = await res.json();
@@ -39,6 +73,7 @@ async function submitSuggestion(event) {
`; `;
resultBox.classList.remove("hidden"); resultBox.classList.remove("hidden");
document.getElementById("suggestionForm").reset(); document.getElementById("suggestionForm").reset();
toggleVorschlagType("text");
} else { } else {
resultBox.innerHTML = ` resultBox.innerHTML = `
<div class="alert alert-danger"> <div class="alert alert-danger">
+62 -4
View File
@@ -52,15 +52,25 @@
{% for q in pending_questions %} {% for q in pending_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}"> <div class="card question-admin-card" id="q-card-{{ q.id }}">
<div class="q-header"> <div class="q-header">
<span class="badge badge-warning">⚠️ Status: Prüfung ausstehend</span> <div style="display: flex; gap: 8px; align-items: center;">
<span class="badge badge-warning">⚠️ Prüfung ausstehend</span>
{% if q.question_type == 'multiple_choice' %}
<span class="badge" style="background: rgba(155, 89, 182, 0.2); border-color: #9b59b6; color: #d2b4de;">🔘 Multiple-Choice</span>
{% else %}
<span class="badge badge-muted">🔤 Freitext</span>
{% endif %}
</div>
<span class="q-submitter">Von: <strong>{{ q.submitted_by or 'Gast' }}</strong> am {{ q.created_at.strftime('%d.%m.%Y %H:%M') }}</span> <span class="q-submitter">Von: <strong>{{ q.submitted_by or 'Gast' }}</strong> am {{ q.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div> </div>
<div class="q-body"> <div class="q-body">
<h3>{{ q.question }}</h3> <h3>{{ q.question }}</h3>
<p><strong>Erwartete Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p> <p><strong>Erwartete Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p>
{% if q.options %}
<p style="margin-top: 5px;"><strong>Auswahl-Optionen:</strong> <code>{{ q.options }}</code></p>
{% endif %}
{% if q.hint %} {% if q.hint %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p> <p style="margin-top: 5px;"><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %} {% endif %}
<!-- Interaktives Testfeld für den Admin --> <!-- Interaktives Testfeld für den Admin -->
@@ -93,10 +103,21 @@
<label>Frage:</label> <label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required> <input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</div> </div>
<div class="form-group">
<label>Fragentyp:</label>
<select name="question_type" class="input-text">
<option value="text" {% if q.question_type != 'multiple_choice' %}selected{% endif %}>🔤 Freitext</option>
<option value="multiple_choice" {% if q.question_type == 'multiple_choice' %}selected{% endif %}>🔘 Multiple Choice</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Richtige Antwort:</label> <label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required> <input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</div> </div>
<div class="form-group">
<label>Optionen (JSON-Liste oder Komma-getrennt für Multiple Choice):</label>
<input type="text" name="options_raw" class="input-text" value="{{ q.options or '' }}">
</div>
<div class="form-group"> <div class="form-group">
<label>Tipp:</label> <label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}"> <input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
@@ -120,15 +141,25 @@
{% for q in active_questions %} {% for q in active_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}"> <div class="card question-admin-card" id="q-card-{{ q.id }}">
<div class="q-header"> <div class="q-header">
<span class="badge badge-success">Reihenfolge #{{ q.sort_order }}</span> <div style="display: flex; gap: 8px; align-items: center;">
<span class="badge badge-success">Reihenfolge #{{ q.sort_order }}</span>
{% if q.question_type == 'multiple_choice' %}
<span class="badge" style="background: rgba(155, 89, 182, 0.2); border-color: #9b59b6; color: #d2b4de;">🔘 Multiple-Choice</span>
{% else %}
<span class="badge badge-muted">🔤 Freitext</span>
{% endif %}
</div>
<span class="q-category">Kategorie: {{ q.category }}</span> <span class="q-category">Kategorie: {{ q.category }}</span>
</div> </div>
<div class="q-body"> <div class="q-body">
<h3>{{ loop.index }}. {{ q.question }}</h3> <h3>{{ loop.index }}. {{ q.question }}</h3>
<p><strong>Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p> <p><strong>Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p>
{% if q.options %}
<p style="margin-top: 5px;"><strong>Optionen:</strong> <code>{{ q.options }}</code></p>
{% endif %}
{% if q.hint %} {% if q.hint %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p> <p style="margin-top: 5px;"><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %} {% endif %}
</div> </div>
@@ -144,10 +175,21 @@
<label>Frage:</label> <label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required> <input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</div> </div>
<div class="form-group">
<label>Fragentyp:</label>
<select name="question_type" class="input-text">
<option value="text" {% if q.question_type != 'multiple_choice' %}selected{% endif %}>🔤 Freitext</option>
<option value="multiple_choice" {% if q.question_type == 'multiple_choice' %}selected{% endif %}>🔘 Multiple Choice</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Richtige Antwort:</label> <label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required> <input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</div> </div>
<div class="form-group">
<label>Optionen (JSON-Liste oder Komma-getrennt für Multiple Choice):</label>
<input type="text" name="options_raw" class="input-text" value="{{ q.options or '' }}">
</div>
<div class="form-group"> <div class="form-group">
<label>Tipp:</label> <label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}"> <input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
@@ -179,6 +221,14 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="new_type">Fragentyp:</label>
<select name="question_type" id="new_type" class="input-text" onchange="toggleAdminNewType(this.value)">
<option value="text" selected>🔤 Freitext-Eingabe (Tippen)</option>
<option value="multiple_choice">🔘 Multiple Choice (Auswahl)</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="new_question">Frage:</label> <label for="new_question">Frage:</label>
<textarea name="question" id="new_question" class="textarea" rows="2" required placeholder="z. B. Welcher Zauber entwaffnet Gegner?"></textarea> <textarea name="question" id="new_question" class="textarea" rows="2" required placeholder="z. B. Welcher Zauber entwaffnet Gegner?"></textarea>
@@ -189,6 +239,14 @@
<input type="text" name="canonical_answer" id="new_answer" class="input-text" required placeholder="z. B. Expelliarmus"> <input type="text" name="canonical_answer" id="new_answer" class="input-text" required placeholder="z. B. Expelliarmus">
</div> </div>
<!-- Multiple Choice Optionen für Admin -->
<div id="adminMcOptions" class="form-group hidden" style="background: rgba(255,255,255,0.03); padding: 15px; border-radius: 8px; border: 1px dashed var(--border-gold);">
<label style="color: var(--gold);">Falsche Optionen für Multiple Choice (Optionen 2 bis 4):</label>
<input type="text" name="wrong_option_1" class="input-text" placeholder="Falsche Option 1 (z.B. Stupor)" style="margin-bottom: 8px;">
<input type="text" name="wrong_option_2" class="input-text" placeholder="Falsche Option 2 (z.B. Lumos)" style="margin-bottom: 8px;">
<input type="text" name="wrong_option_3" class="input-text" placeholder="Falsche Option 3 (z.B. Alohomora)">
</div>
<div class="form-group"> <div class="form-group">
<label for="new_hint">Tipp (optional):</label> <label for="new_hint">Tipp (optional):</label>
<input type="text" name="hint" id="new_hint" class="input-text" placeholder="z. B. Harrys Lieblingszauber"> <input type="text" name="hint" id="new_hint" class="input-text" placeholder="z. B. Harrys Lieblingszauber">
+12 -1
View File
@@ -53,7 +53,8 @@
<h2 id="frageText" class="question-text">Lade verzauberte Schriftrollen...</h2> <h2 id="frageText" class="question-text">Lade verzauberte Schriftrollen...</h2>
<div class="answer-form"> <!-- Freitext-Eingabe (wenn question_type == 'text') -->
<div id="textAnswerGroup" class="answer-form">
<input type="text" id="answerInput" class="input-answer" placeholder="Deine Antwort hier eingeben..." autocomplete="off"> <input type="text" id="answerInput" class="input-answer" placeholder="Deine Antwort hier eingeben..." autocomplete="off">
<div class="btn-group"> <div class="btn-group">
@@ -66,6 +67,16 @@
</div> </div>
</div> </div>
<!-- Multiple-Choice-Auswahl (wenn question_type == 'multiple_choice') -->
<div id="choiceAnswerGroup" class="choice-group hidden">
<div id="choiceOptionsContainer" class="choice-grid"></div>
<div class="btn-group" style="margin-top: 15px;">
<button id="hintBtnMC" class="btn btn-secondary" onclick="requestHint()">
💡 Zaubertipp
</button>
</div>
</div>
<div id="hintBox" class="hint-box hidden"> <div id="hintBox" class="hint-box hidden">
<strong>📜 Notiz aus der verbotenen Abteilung:</strong> <strong>📜 Notiz aus der verbotenen Abteilung:</strong>
<p id="hintText"></p> <p id="hintText"></p>
+18 -2
View File
@@ -24,9 +24,25 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="antwort">Richtige Antwort: <span class="required">*</span></label> <label for="q_type">Art der Frage:</label>
<select id="q_type" name="q_type" class="input-text" onchange="toggleVorschlagType(this.value)">
<option value="text" selected>🔤 Freitext-Antwort (Tippen mit Fehlertoleranz)</option>
<option value="multiple_choice">🔘 Multiple Choice (Auswahl aus 4 Antwortmöglichkeiten)</option>
</select>
</div>
<div class="form-group">
<label for="antwort"><span id="antwortLabel">Richtige Antwort:</span> <span class="required">*</span></label>
<input type="text" id="antwort" name="antwort" class="input-text" placeholder="z. B. Albus Dumbledore" required> <input type="text" id="antwort" name="antwort" class="input-text" placeholder="z. B. Albus Dumbledore" required>
<small class="field-help">Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab.</small> <small class="field-help" id="antwortHelp">Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab.</small>
</div>
<!-- Zusätzliche Optionen für Multiple Choice -->
<div id="mcOptionsGroup" class="form-group hidden" style="background: rgba(255,255,255,0.03); padding: 15px; border-radius: 8px; border: 1px dashed var(--border-gold);">
<label style="color: var(--gold);">Falsche Antwortmöglichkeiten (zur Auswahl für die Spieler):</label>
<input type="text" id="wrong1" class="input-text" placeholder="Falsche Option 1 (z. B. Severus Snape)" style="margin-bottom: 8px;">
<input type="text" id="wrong2" class="input-text" placeholder="Falsche Option 2 (z. B. Minerva McGonagall)" style="margin-bottom: 8px;">
<input type="text" id="wrong3" class="input-text" placeholder="Falsche Option 3 (z. B. Rubeus Hagrid)">
</div> </div>
<div class="form-group"> <div class="form-group">
+12
View File
@@ -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()
+71
View File
@@ -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()
View File