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 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()
+2
View File
@@ -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)
+2
View File
@@ -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"
+82
View File
@@ -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;
+14 -2
View File
@@ -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;
+64 -14
View File
@@ -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 = `<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();
}
@@ -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 = `<span class="feedback-correct">✨ Richtig! 1 Hauspunkt für dein Haus! ✨</span>`;
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 = `
<div class="feedback-wrong">Leider nicht richtig (3 Fehlversuche).</div>
<div class="feedback-revealed">Richtige Lösung: <strong>${result.revealed_answer}</strong></div>
`;
input.disabled = true;
setTimeout(() => {
if (result.is_finished) {
@@ -202,14 +245,21 @@ async function checkAnswer() {
} else {
// Still attempts remaining
feedbackBox.innerHTML = `<span class="feedback-wrong">Leider falsch! Noch ${result.attempts_left} Versuch(e).</span>`;
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);
}
}
+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) {
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 = `
<div class="alert alert-danger">
+62 -4
View File
@@ -52,15 +52,25 @@
{% for q in pending_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}">
<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>
</div>
<div class="q-body">
<h3>{{ q.question }}</h3>
<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 %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
<p style="margin-top: 5px;"><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %}
<!-- Interaktives Testfeld für den Admin -->
@@ -93,10 +103,21 @@
<label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</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">
<label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</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">
<label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
@@ -120,15 +141,25 @@
{% for q in active_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}">
<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>
</div>
<div class="q-body">
<h3>{{ loop.index }}. {{ q.question }}</h3>
<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 %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
<p style="margin-top: 5px;"><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %}
</div>
@@ -144,10 +175,21 @@
<label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</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">
<label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</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">
<label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
@@ -179,6 +221,14 @@
</select>
</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">
<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>
@@ -189,6 +239,14 @@
<input type="text" name="canonical_answer" id="new_answer" class="input-text" required placeholder="z. B. Expelliarmus">
</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">
<label for="new_hint">Tipp (optional):</label>
<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>
<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">
<div class="btn-group">
@@ -66,6 +67,16 @@
</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">
<strong>📜 Notiz aus der verbotenen Abteilung:</strong>
<p id="hintText"></p>
+18 -2
View File
@@ -24,9 +24,25 @@
</div>
<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>
<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 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