Feature: Support both Free-Text and Multiple-Choice question types
This commit is contained in:
+68
-18
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user