Verfeinerung nach Sub-Agent-Audit: Sicherheits-Härtung (HMAC, IDOR-Schutz, .htaccess), Dynamische Parsons-Puzzles, 4-Stufen-Scaffolding, Code-Inspektor & CSV-Export

This commit is contained in:
Alex Mueller 2026-08-29 20:57:54 +02:00
parent 759e64666a
commit 698e6625ac
11 changed files with 981 additions and 357 deletions

View file

@ -10,5 +10,5 @@
"streak_tage": 1, "streak_tage": 1,
"letzter_aktiver_tag": "2026-08-29", "letzter_aktiver_tag": "2026-08-29",
"historie": [], "historie": [],
"zuletzt_aktualisiert": "2026-08-29T20:50:51.073181" "zuletzt_aktualisiert": "2026-08-29T20:57:49.286070"
} }

20
.htaccess Normal file
View file

@ -0,0 +1,20 @@
# 🔒 APACHE / WEBSPACE SICHERHEITSKONFIGURATION
# 1. Blockiere Zugriff auf SQLite-Datenbanken und sensible Dateien
<FilesMatch "\.(db|sqlite|secret_key|jsonl|py|log)$">
Order Allow,Deny
Deny from all
</FilesMatch>
# 2. Blockiere versteckte Dateien (.git, .env, etc.)
<FilesMatch "^\.">
Order Allow,Deny
Deny from all
</FilesMatch>
# 3. Security Header
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

136
api.php
View file

@ -1,11 +1,19 @@
<?php <?php
/** /**
* 🐘 PHP BACKEND REST API FÜR STANDARD-WEBSPACE (Hetzner, All-Inkl, Strato, cPanel) * 🐘 GEHÄRTETES PHP REST API BACKEND FÜR SHARED-WEBSPACE (Hetzner, All-Inkl, Strato, cPanel)
* ============================================================================== * =========================================================================================
* SQLite-basierte REST-API für Benutzerverwaltung, Cloud-Speichern & Lehrerklassen. * - Signatur- und Ablauf-Prüfung für JWT-Tokens (Auth-Bypass behoben).
* - Sicherer Secret-Key aus Umgebung / geschützter Datei.
* - FastCGI Authorization Header Fallback.
* - Anti-Cheat Obergrenze bei XP-Vergabe.
* - Prepared Statements & SQL-Injection-Schutz.
*/ */
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Headers: Content-Type, Authorization');
@ -15,11 +23,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit; exit;
} }
$db_file = __DIR__ . '/platform_data.db'; // 1. SECRET KEY LADEN
$pdo = new PDO('sqlite:' . $db_file); $secret_key = getenv('SECRET_KEY');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); if (!$secret_key || strlen($secret_key) < 32) {
$secret_file = __DIR__ . '/.secret_key';
if (file_exists($secret_file)) {
$secret_key = trim((string)file_get_contents($secret_file));
} else {
$secret_key = bin2hex(random_bytes(32));
@file_put_contents($secret_file, $secret_key, LOCK_EX);
@chmod($secret_file, 0600);
}
}
// Init Tabellen // 2. DATENBANK VERBINDUNG
$db_file = __DIR__ . '/platform_data.db';
try {
$pdo = new PDO('sqlite:' . $db_file);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Datenbankverbindung fehlgeschlagen']);
exit;
}
// 3. TABELLEN INITIALISIERUNG
$pdo->exec(" $pdo->exec("
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -60,19 +89,35 @@ CREATE TABLE IF NOT EXISTS class_enrollments (
); );
"); ");
$path = $_SERVER['PATH_INFO'] ?? $_GET['action'] ?? ''; // 4. AUTH HELPER MIT SICHERER HMAC-VERIFIKATION
$body = json_decode(file_get_contents('php://input'), true) ?? []; function get_auth_user(string $secret_key): ?array {
$auth = '';
// Helper Auth if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
function get_auth_user($pdo) { $auth = $_SERVER['HTTP_AUTHORIZATION'];
} elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
$auth = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (function_exists('getallheaders')) {
$headers = getallheaders(); $headers = getallheaders();
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? ''; $auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
if (strpos($auth, 'Bearer ') === 0) { if (strpos($auth, 'Bearer ') === 0) {
$token = substr($auth, 7); $token = substr($auth, 7);
$parts = explode('.', $token); $parts = explode('.', $token);
if (count($parts) === 2) { if (count($parts) === 2) {
$payload = json_decode(base64_decode($parts[0]), true); $payload_raw = base64_decode($parts[0], true);
if ($payload && isset($payload['uid'])) { if ($payload_raw === false) return null;
$expected_sig = hash_hmac('sha256', $parts[0], $secret_key);
if (!hash_equals($expected_sig, $parts[1])) {
return null; // Ungültige Signatur
}
$payload = json_decode($payload_raw, true);
if ($payload && isset($payload['uid']) && isset($payload['exp'])) {
if ($payload['exp'] < time()) {
return null; // Abgelaufen
}
return $payload; return $payload;
} }
} }
@ -80,22 +125,32 @@ function get_auth_user($pdo) {
return null; return null;
} }
function create_token($uid, $email, $role) { function create_token(int $uid, string $email, string $role, string $secret_key): string {
$payload = base64_encode(json_encode(['uid' => $uid, 'email' => $email, 'role' => $role])); $payload_data = [
$sig = hash_hmac('sha256', $payload, 'python_lernportal_secret'); 'uid' => $uid,
'email' => $email,
'role' => $role,
'exp' => time() + (86400 * 30)
];
$payload = base64_encode((string)json_encode($payload_data));
$sig = hash_hmac('sha256', $payload, $secret_key);
return $payload . '.' . $sig; return $payload . '.' . $sig;
} }
$path = $_SERVER['PATH_INFO'] ?? $_GET['action'] ?? '';
$raw_input = file_get_contents('php://input');
$body = $raw_input ? (json_decode($raw_input, true) ?? []) : [];
// 1. REGISTER // 1. REGISTER
if ($path === '/auth/register') { if ($path === '/auth/register') {
$email = strtolower(trim($body['email'] ?? '')); $email = strtolower(trim((string)($body['email'] ?? '')));
$password = $body['password'] ?? ''; $password = (string)($body['password'] ?? '');
$name = trim($body['name'] ?? ''); $name = trim((string)($body['name'] ?? ''));
$role = $body['role'] ?? 'student'; $role = ($body['role'] ?? 'student') === 'teacher' ? 'teacher' : 'student';
if (!$email || !$password || !$name) { if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 6 || strlen($name) < 1) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Pflichtfelder fehlen']); echo json_encode(['error' => 'Ungültige Eingabedaten']);
exit; exit;
} }
@ -105,34 +160,34 @@ if ($path === '/auth/register') {
try { try {
$stmt = $pdo->prepare("INSERT INTO users (email, password_hash, salt, name, role) VALUES (?, ?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO users (email, password_hash, salt, name, role) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$email, $hash, $salt, $name, $role]); $stmt->execute([$email, $hash, $salt, $name, $role]);
$uid = $pdo->lastInsertId(); $uid = (int)$pdo->lastInsertId();
$token = create_token($uid, $email, $role); $token = create_token($uid, $email, $role, $secret_key);
http_response_code(201); http_response_code(201);
echo json_encode(['token' => $token, 'user' => ['id' => $uid, 'email' => $email, 'name' => $name, 'role' => $role, 'xp' => 0, 'level' => 1]]); echo json_encode(['token' => $token, 'user' => ['id' => $uid, 'email' => $email, 'name' => $name, 'role' => $role, 'xp' => 0, 'level' => 1]]);
} catch (Exception $e) { } catch (PDOException $e) {
http_response_code(409); http_response_code(409);
echo json_encode(['error' => 'E-Mail existiert bereits']); echo json_encode(['error' => 'E-Mail existiert bereits']);
} }
exit; exit;
} }
// 2. LOGIN // 2. LOGIN (mit Constant-Time Dummy PBKDF2)
if ($path === '/auth/login') { if ($path === '/auth/login') {
$email = strtolower(trim($body['email'] ?? '')); $email = strtolower(trim((string)($body['email'] ?? '')));
$password = $body['password'] ?? ''; $password = (string)($body['password'] ?? '');
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?"); $stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]); $stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC); $user = $stmt->fetch();
if ($user) { if ($user) {
$hash = hash_pbkdf2('sha256', $password, $user['salt'], 100000); $hash = hash_pbkdf2('sha256', $password, $user['salt'], 100000);
if (hash_equals($hash, $user['password_hash'])) { if (hash_equals($hash, $user['password_hash'])) {
$token = create_token($user['id'], $user['email'], $user['role']); $token = create_token((int)$user['id'], $user['email'], $user['role'], $secret_key);
echo json_encode([ echo json_encode([
'token' => $token, 'token' => $token,
'user' => [ 'user' => [
'id' => $user['id'], 'id' => (int)$user['id'],
'email' => $user['email'], 'email' => $user['email'],
'name' => $user['name'], 'name' => $user['name'],
'role' => $user['role'], 'role' => $user['role'],
@ -142,7 +197,10 @@ if ($path === '/auth/login') {
]); ]);
exit; exit;
} }
} else {
hash_pbkdf2('sha256', $password, 'dummy_salt_fixed', 100000);
} }
http_response_code(401); http_response_code(401);
echo json_encode(['error' => 'Ungültige Anmeldedaten']); echo json_encode(['error' => 'Ungültige Anmeldedaten']);
exit; exit;
@ -150,11 +208,11 @@ if ($path === '/auth/login') {
// 3. PROGRESS SAVE // 3. PROGRESS SAVE
if ($path === '/progress/save') { if ($path === '/progress/save') {
$user = get_auth_user($pdo); $user = get_auth_user($secret_key);
if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; } if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; }
$chapter_id = $body['chapter_id'] ?? ''; $chapter_id = substr(trim((string)($body['chapter_id'] ?? '')), 0, 120);
$code_draft = $body['code_draft'] ?? ''; $code_draft = substr((string)($body['code_draft'] ?? ''), 0, 100000);
$stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, code_draft) VALUES (?, ?, ?) ON CONFLICT(user_id, chapter_id) DO UPDATE SET code_draft = excluded.code_draft, updated_at = CURRENT_TIMESTAMP"); $stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, code_draft) VALUES (?, ?, ?) ON CONFLICT(user_id, chapter_id) DO UPDATE SET code_draft = excluded.code_draft, updated_at = CURRENT_TIMESTAMP");
$stmt->execute([$user['uid'], $chapter_id, $code_draft]); $stmt->execute([$user['uid'], $chapter_id, $code_draft]);
@ -162,13 +220,13 @@ if ($path === '/progress/save') {
exit; exit;
} }
// 4. PROGRESS SOLVE // 4. PROGRESS SOLVE (mit Anti-Cheat Cap)
if ($path === '/progress/solve') { if ($path === '/progress/solve') {
$user = get_auth_user($pdo); $user = get_auth_user($secret_key);
if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; } if (!$user) { http_response_code(401); echo json_encode(['error' => 'Nicht angemeldet']); exit; }
$chapter_id = $body['chapter_id'] ?? ''; $chapter_id = substr(trim((string)($body['chapter_id'] ?? '')), 0, 120);
$xp_reward = (int)($body['xp_reward'] ?? 100); $xp_reward = min(max(0, (int)($body['xp_reward'] ?? 100)), 100);
$stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, is_solved, solved_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(user_id, chapter_id) DO UPDATE SET is_solved = 1, solved_at = CURRENT_TIMESTAMP"); $stmt = $pdo->prepare("INSERT INTO chapter_progress (user_id, chapter_id, is_solved, solved_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(user_id, chapter_id) DO UPDATE SET is_solved = 1, solved_at = CURRENT_TIMESTAMP");
$stmt->execute([$user['uid'], $chapter_id]); $stmt->execute([$user['uid'], $chapter_id]);

View file

@ -5,22 +5,54 @@
*/ */
(function () { (function () {
window.zeigeZertifikatModal = function (lehrpfadNummer) { function escapeHtml(text) {
const data = window.GAMIFICATION_DATA || { spieler_name: "Python-Entwicklerin", level: 1 }; if (!text) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
window.zeigeZertifikatModal = async function (lehrpfadNummer) {
const data = window.GAMIFICATION_DATA || { spieler_name: "Python-Entwickler", level: 1 };
const user = window.AUTH ? window.AUTH.user : null;
const studentName = (user && user.name) ? user.name : (data.spieler_name || "Python-Entwickler");
const dateStr = new Date().toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" }); const dateStr = new Date().toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
let titel = "Grundlagen der Python-Programmierung"; let titel = "Grundlagen der Python-Programmierung";
let moduleDesc = "10 Module: Zahlen & Rechnen, Datentypen, f-Strings, Kontrollstrukturen, Funktionen, Listen, Strings, Dictionaries, Sets & Comprehensions"; let moduleDesc = "10 Module: Zahlen & Rechnen, Datentypen, f-Strings, Kontrollstrukturen, Funktionen, Listen, Strings, Dictionaries, Sets & Comprehensions";
let trackKey = "lehrpfad_1";
if (lehrpfadNummer === 2) { if (lehrpfadNummer === 2 || lehrpfadNummer === "lehrpfad_2") {
titel = "Einstieg in die Objektorientierung (OOP) & Entwickler-Tools"; titel = "Einstieg in die Objektorientierung (OOP) & Entwickler-Tools";
moduleDesc = "9 Module: Klassen, Konstruktor __init__, self, Methoden, UML-Diagramme, VS Code, Git-Versionskontrolle, Komposition & Tamagotchi-Projekt"; moduleDesc = "9 Module: Klassen, Konstruktor __init__, self, Methoden, UML-Diagramme, VS Code, Git-Versionskontrolle, Komposition & Tamagotchi-Projekt";
} else if (lehrpfadNummer === 3) { trackKey = "lehrpfad_2";
} else if (lehrpfadNummer === 3 || lehrpfadNummer === "lehrpfad_3") {
titel = "Fortgeschrittenes OOP, Softwarequalität & Desktop-GUIs"; titel = "Fortgeschrittenes OOP, Softwarequalität & Desktop-GUIs";
moduleDesc = "10 Module: Speicher-Referenzen, Dunder-Methoden, TDD Unit Testing, Vererbung, Polymorphie, Exceptions, JSON-Persistenz, Tkinter GUIs, Container & Masterprojekt"; moduleDesc = "10 Module: Speicher-Referenzen, Dunder-Methoden, TDD Unit Testing, Vererbung, Polymorphie, Exceptions, JSON-Persistenz, Tkinter GUIs, Container & Masterprojekt";
trackKey = "lehrpfad_3";
} else if (lehrpfadNummer === "master") { } else if (lehrpfadNummer === "master") {
titel = "Master of Python Software Engineering"; titel = "Master of Python Software Engineering";
moduleDesc = "Vollständiger Abschluss aller 3 Lehrpfade (27 Module, 188 bestandene automatisierte Unittests, Model-View-Controller Desktop-App)"; moduleDesc = "Vollständiger Abschluss aller 3 Lehrpfade (27 Module, 188 bestandene automatisierte Unittests, Model-View-Controller Desktop-App)";
trackKey = "master";
}
let certUuid = "CERT-" + Math.random().toString(36).substring(2, 10).toUpperCase();
// Registriere Zertifikat im Backend falls angemeldet
const token = localStorage.getItem("auth_token");
if (token) {
try {
const res = await fetch("/api/certificates/create", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ track_id: trackKey, student_name: studentName })
});
const resData = await res.json();
if (resData.uuid) certUuid = resData.uuid;
} catch (e) {}
} }
const modal = document.createElement("div"); const modal = document.createElement("div");
@ -32,18 +64,21 @@
<div class="certificate-badge">🎓 OFFIZIELLES ZERTIFIKAT 🎓</div> <div class="certificate-badge">🎓 OFFIZIELLES ZERTIFIKAT 🎓</div>
<h1 class="certificate-headline">Erfolgreicher Abschluss</h1> <h1 class="certificate-headline">Erfolgreicher Abschluss</h1>
<p class="certificate-sub">Hiermit wird bescheinigt, dass</p> <p class="certificate-sub">Hiermit wird bescheinigt, dass</p>
<div class="certificate-student-name">${data.spieler_name || "Python-Entwicklerin"}</div> <div class="certificate-student-name">${escapeHtml(studentName)}</div>
<p class="certificate-text">den anspruchsvollen Lehrpfad</p> <p class="certificate-text">den anspruchsvollen Lehrpfad</p>
<h2 class="certificate-track-title">${titel}</h2> <h2 class="certificate-track-title">${escapeHtml(titel)}</h2>
<p class="certificate-desc">${moduleDesc}</p> <p class="certificate-desc">${escapeHtml(moduleDesc)}</p>
<div class="certificate-meta"> <div class="certificate-meta">
<div> <div>
<strong>Erreichtes Level:</strong> Level ${data.level || 1} (${data.titel || "Entwicklerin"}) <strong>Erreichtes Level:</strong> Level ${data.level || 1} (${escapeHtml(data.titel || "Entwickler")})
</div> </div>
<div> <div>
<strong>Datum:</strong> ${dateStr} <strong>Datum:</strong> ${dateStr}
</div> </div>
</div> </div>
<div style="margin-top: 14px; font-size: 0.72rem; font-family: monospace; color: #64748b;">
Zertifikats-ID: ${escapeHtml(certUuid)}
</div>
<div class="certificate-seal">🏆 EXZELLENZ-SIEGEL</div> <div class="certificate-seal">🏆 EXZELLENZ-SIEGEL</div>
</div> </div>
</div> </div>
@ -56,4 +91,7 @@
document.body.appendChild(modal); document.body.appendChild(modal);
}; };
// Alias für index.html Buttons
window.openCertificate = window.zeigeZertifikatModal;
})(); })();

View file

@ -5,38 +5,36 @@
*/ */
(function () { (function () {
// Standard-Kataloge als Fallback, falls gamification_data.js noch nicht existiert
const DEFAULT_LEVELS = [ const DEFAULT_LEVELS = [
{ level: 1, min_xp: 0, max_xp: 150, titel: "Code-Küken 🐣", rang: "Bronze I" }, { level: 1, min_xp: 0, max_xp: 150, titel: "Code-Küken 🐣", rang: "Bronze I" },
{ level: 2, min_xp: 151, max_xp: 400, titel: "Code-Detektivin 🔍", rang: "Bronze II" }, { level: 2, min_xp: 151, max_xp: 400, titel: "Code-Detektiv 🔍", rang: "Bronze II" },
{ level: 3, min_xp: 401, max_xp: 800, titel: "Logik-Bastlerin ⚙️", rang: "Silber I" }, { level: 3, min_xp: 401, max_xp: 800, titel: "Logik-Bastler ⚙️", rang: "Silber I" },
{ level: 4, min_xp: 801, max_xp: 1300, titel: "Klassen-Baumeisterin 🏗️", rang: "Silber II" }, { level: 4, min_xp: 801, max_xp: 1300, titel: "Klassen-Baumeister 🏗️", rang: "Silber II" },
{ level: 5, min_xp: 1301, max_xp: 2000, titel: "Git-Strategin 🌿", rang: "Gold I" }, { level: 5, min_xp: 1301, max_xp: 2000, titel: "Git-Stratege 🌿", rang: "Gold I" },
{ level: 6, min_xp: 2001, max_xp: 2900, titel: "TDD-Qualitätsprüferin 🧪", rang: "Gold II" }, { level: 6, min_xp: 2001, max_xp: 2900, titel: "TDD-Qualitätsprüfer 🧪", rang: "Gold II" },
{ level: 7, min_xp: 2901, max_xp: 4000, titel: "Vererbungs-Meisterin 🧬", rang: "Platin I" }, { level: 7, min_xp: 2901, max_xp: 4000, titel: "Vererbungs-Meister 🧬", rang: "Platin I" },
{ level: 8, min_xp: 4001, max_xp: 5300, titel: "Exception-Wächterin 🛡️", rang: "Platin II" }, { level: 8, min_xp: 4001, max_xp: 5300, titel: "Exception-Wächter 🛡️", rang: "Platin II" },
{ level: 9, min_xp: 5301, max_xp: 6800, titel: "GUI-Entwicklerin 🖥️", rang: "Diamant" }, { level: 9, min_xp: 5301, max_xp: 6800, titel: "GUI-Entwickler 🖥️", rang: "Diamant" },
{ level: 10, min_xp: 6801, max_xp: 99999, titel: "Software-Architektin 🏆", rang: "Großmeisterin" } { level: 10, min_xp: 6801, max_xp: 99999, titel: "Software-Architekt 🏆", rang: "Großmeister" }
]; ];
const DEFAULT_BADGES = { const DEFAULT_BADGES = {
rechenkonegin: { icon: "🧮", name: "Rechenkönigin", desc: "Grundlagen G01 & G02 abgeschlossen (Zahlen, Operatoren & Typen gemeistert)." }, rechenkonegin: { icon: "🧮", name: "Rechenkönig", desc: "Grundlagen G01 & G02 abgeschlossen (Zahlen, Operatoren & Typen gemeistert)." },
dialog_profi: { icon: "💬", name: "Dialog-Profi", desc: "Grundlagen G03 abgeschlossen (Interaktive Ein-/Ausgabe mit f-Strings)." }, dialog_profi: { icon: "💬", name: "Dialog-Profi", desc: "Grundlagen G03 abgeschlossen (Interaktive Ein-/Ausgabe mit f-Strings)." },
weichenstellerin: { icon: "🚦", name: "Weichenstellerin", desc: "Grundlagen G04 abgeschlossen (Bedingungslogik & Schaltjahre gemeistert)." }, weichenstellerin: { icon: "🚦", name: "Weichensteller", desc: "Grundlagen G04 abgeschlossen (Bedingungslogik & Schaltjahre gemeistert)." },
schleifen_dompteurin: { icon: "🎡", name: "Schleifen-Dompteurin", desc: "Grundlagen G05 abgeschlossen (While- & For-Schleifen gebändigt)." }, schleifen_dompteurin: { icon: "🎡", name: "Schleifen-Dompteur", desc: "Grundlagen G05 abgeschlossen (While- & For-Schleifen gebändigt)." },
funktions_zauberin: { icon: "🪄", name: "Funktions-Zauberin", desc: "Grundlagen G06 abgeschlossen (Eigene Funktionen & Module gebaut)." }, funktions_zauberin: { icon: "🪄", name: "Funktions-Zauberer", desc: "Grundlagen G06 abgeschlossen (Eigene Funktionen & Module gebaut)." },
listen_archivarin: { icon: "📋", name: "Listen-Archivarin", desc: "Grundlagen G07 & G08 abgeschlossen (Sequenzen & Strings analysiert)." }, listen_archivarin: { icon: "📋", name: "Listen-Archivar", desc: "Grundlagen G07 & G08 abgeschlossen (Sequenzen & Strings analysiert)." },
daten_strategin: { icon: "🗃️", name: "Daten-Strategin", desc: "Grundlagen G09 & G10 abgeschlossen (Dicts, Sets & Comprehensions gemeistert)." }, daten_strategin: { icon: "🗃️", name: "Daten-Stratege", desc: "Grundlagen G09 & G10 abgeschlossen (Dicts, Sets & Comprehensions gemeistert)." },
bug_jaegerin: { icon: "🔍", name: "Bug-Jägerin", desc: "Kapitel 00 gelöst (Alle Fehler-Bugs im Warm-up aufgespürt)." }, bug_jaegerin: { icon: "🔍", name: "Bug-Jäger", desc: "Kapitel 00 gelöst (Alle Fehler-Bugs im Warm-up aufgespürt)." },
erste_architektin: { icon: "🏗️", name: "Erste Architektin", desc: "Kapitel 01 bis 03 gelöst (Erste OOP-Klassen, Konstruktoren & Methoden)." }, erste_architektin: { icon: "🏗️", name: "Erster Architekt", desc: "Kapitel 01 bis 03 gelöst (Erste OOP-Klassen, Konstruktoren & Methoden)." },
zeit_reisende: { icon: "🌿", name: "Git-Zeitreisende", desc: "Kapitel 04c Git-Versionskontrolle verstanden & Spielstände gesichert." }, zeit_reisende: { icon: "🌿", name: "Git-Zeitreisender", desc: "Kapitel 04c Git-Versionskontrolle verstanden & Spielstände gesichert." },
tamagotchi_mama: { icon: "🥚", name: "Tamagotchi-Mama", desc: "Kapitel 06 Mini-Projekt abgeschlossen (Ein lebendiges Haustier gebaut)." }, tamagotchi_mama: { icon: "🥚", name: "Tamagotchi-Hüter", desc: "Kapitel 06 Mini-Projekt abgeschlossen (Ein lebendiges Haustier gebaut)." },
qualitaets_garantin: { icon: "🧪", name: "TDD-Qualitätsgarantin", desc: "Kapitel 09 abgeschlossen (Eigene professionelle Unit Tests geschrieben)." }, qualitaets_garantin: { icon: "🧪", name: "TDD-Qualitätsgarant", desc: "Kapitel 09 abgeschlossen (Eigene professionelle Unit Tests geschrieben)." },
oop_grossmeisterin: { icon: "🧬", name: "Vererbungs-Koryphäe", desc: "Kapitel 10 bis 12 abgeschlossen (Vererbung, Polymorphie & Exceptions)." }, oop_grossmeisterin: { icon: "🧬", name: "Vererbungs-Koryphäe", desc: "Kapitel 10 bis 12 abgeschlossen (Vererbung, Polymorphie & Exceptions)." },
software_architektin: { icon: "🏆", name: "Meister-Entwicklerin", desc: "Kapitel 16 Master-Projekt abgeschlossen & vollwertige Desktop-App gebaut!" } software_architektin: { icon: "🏆", name: "Meister-Entwickler", desc: "Kapitel 16 Master-Projekt abgeschlossen & vollwertige Desktop-App gebaut!" }
}; };
// Daten abrufen
function getGamificationData() { function getGamificationData() {
if (window.GAMIFICATION_DATA) { if (window.GAMIFICATION_DATA) {
return window.GAMIFICATION_DATA; return window.GAMIFICATION_DATA;
@ -46,7 +44,7 @@
try { return JSON.parse(saved); } catch (e) { } try { return JSON.parse(saved); } catch (e) { }
} }
return { return {
spieler_name: "Python-Entwicklerin", spieler_name: "Python-Entwickler",
xp: 0, xp: 0,
level: 1, level: 1,
titel: "Code-Küken 🐣", titel: "Code-Küken 🐣",
@ -58,120 +56,89 @@
}; };
} }
function getLevelInfo(xp) { function saveGamificationData(data) {
const levels = window.GAMIFICATION_LEVEL_STUFEN || DEFAULT_LEVELS; window.GAMIFICATION_DATA = data;
for (let info of levels) { localStorage.setItem("python_gamification_state", JSON.stringify(data));
if (xp >= info.min_xp && xp <= info.max_xp) {
const nextXp = info.max_xp + 1;
const xpInLevel = xp - info.min_xp;
const xpForLevel = nextXp - info.min_xp;
const prozent = xpForLevel > 0 ? Math.min(100, Math.round((xpInLevel / xpForLevel) * 100)) : 100;
return { ...info, naechste_stufe_xp: nextXp, prozent: prozent };
}
}
return { level: 10, titel: "Software-Architektin 🏆", rang: "Großmeisterin", naechste_stufe_xp: 99999, prozent: 100 };
} }
// HUD & Trophäenraum rendern // Globaler XP-Zuwachs & Levelberechnung
function renderGamificationHUD() { window.addXP = function (amount, chapterId = "") {
const data = getGamificationData(); const data = getGamificationData();
const lvlInfo = getLevelInfo(data.xp || 0); data.xp = (data.xp || 0) + amount;
const badgesKatalog = window.GAMIFICATION_BADGES_KATALOG || DEFAULT_BADGES;
const unlockedBadges = new Set(data.freigeschaltete_badges || []);
const badgeCount = unlockedBadges.size;
const totalBadges = Object.keys(badgesKatalog).length;
// Finde oder erstelle den HUD-Container if (chapterId && !data.geloeste_kapitel.includes(chapterId)) {
let hud = document.getElementById("gamer-hud"); data.geloeste_kapitel.push(chapterId);
if (!hud) {
const mainContainer = document.querySelector(".container") || document.body;
hud = document.createElement("div");
hud.id = "gamer-hud";
mainContainer.prepend(hud);
} }
hud.innerHTML = ` // Level prüfen
<div class="hud-card"> for (let i = DEFAULT_LEVELS.length - 1; i >= 0; i--) {
<div class="hud-header"> if (data.xp >= DEFAULT_LEVELS[i].min_xp) {
<div class="hud-user"> data.level = DEFAULT_LEVELS[i].level;
<div class="hud-avatar">🎮</div> data.titel = DEFAULT_LEVELS[i].titel;
<div> data.rang = DEFAULT_LEVELS[i].rang;
<div class="hud-name">${data.spieler_name || "Python-Entwicklerin"}</div> break;
<div class="hud-title">${lvlInfo.titel} &bull; <span class="hud-rank">${lvlInfo.rang}</span></div>
</div>
</div>
<div class="hud-stats">
<div class="hud-stat-box">
<span class="hud-stat-value">⭐ Level ${lvlInfo.level}</span>
<span class="hud-stat-label">${data.xp || 0} XP</span>
</div>
<div class="hud-stat-box">
<span class="hud-stat-value">🔥 ${data.streak_tage || 1} Tage</span>
<span class="hud-stat-label">Lern-Streak</span>
</div>
<div class="hud-stat-box">
<span class="hud-stat-value">🏆 ${badgeCount}/${totalBadges}</span>
<span class="hud-stat-label">Trophäen</span>
</div>
</div>
</div>
<div class="hud-progress-container">
<div class="hud-progress-labels">
<span>Fortschritt zu Level ${lvlInfo.level + 1}</span>
<span><strong>${data.xp || 0}</strong> / ${lvlInfo.naechste_stufe_xp} XP (${lvlInfo.prozent}%)</span>
</div>
<div class="hud-progress-bar-bg">
<div class="hud-progress-bar-fill" style="width: ${lvlInfo.prozent}%;"></div>
</div>
</div>
<div class="hud-footer">
<button id="toggle-trophies-btn" class="hud-btn">
🏆 Trophäenraum öffnen (${badgeCount}/${totalBadges} freigeschaltet)
</button>
<div class="hud-cli-tip">
⌨️ Im Terminal: <code>python3 profil.py</code> oder <code>python3 test_all.py</code>
</div>
</div>
<div id="trophy-room" class="trophy-room" style="display: none;">
<h3 style="margin: 15px 0 10px 0; color: var(--text-main);">🎖️ Dein Trophäenraum</h3>
<div class="trophy-grid">
${Object.keys(badgesKatalog).map(bId => {
const b = badgesKatalog[bId];
const isUnlocked = unlockedBadges.has(bId);
return `
<div class="trophy-card ${isUnlocked ? 'trophy-unlocked' : 'trophy-locked'}">
<div class="trophy-icon">${isUnlocked ? b.icon : '🔒'}</div>
<div class="trophy-info">
<div class="trophy-title">${b.name}</div>
<div class="trophy-desc">${b.desc}</div>
<div class="trophy-badge-status">${isUnlocked ? '✅ Freigeschaltet' : '🔒 Gesperrt'}</div>
</div>
</div>
`;
}).join("")}
</div>
</div>
</div>
`;
// Event Listener für Trophäenraum Toggle
const toggleBtn = document.getElementById("toggle-trophies-btn");
const trophyRoom = document.getElementById("trophy-room");
if (toggleBtn && trophyRoom) {
toggleBtn.addEventListener("click", function () {
const isHidden = trophyRoom.style.display === "none";
trophyRoom.style.display = isHidden ? "block" : "none";
toggleBtn.innerText = isHidden
? `🔼 Trophäenraum einklappen (${badgeCount}/${totalBadges})`
: `🏆 Trophäenraum öffnen (${badgeCount}/${totalBadges} freigeschaltet)`;
});
} }
} }
// Checkbox-Persistenz für alle Aufgaben-Seiten (localStorage) saveGamificationData(data);
window.renderGamificationHUD();
return data;
};
window.renderGamificationHUD = function () {
const data = getGamificationData();
const levels = (window.GAMIFICATION_DATA && window.GAMIFICATION_DATA.levels) ? window.GAMIFICATION_DATA.levels : DEFAULT_LEVELS;
const badges = (window.GAMIFICATION_DATA && window.GAMIFICATION_DATA.badges) ? window.GAMIFICATION_DATA.badges : DEFAULT_BADGES;
const currentLevelObj = levels.find((l) => l.level === data.level) || levels[0];
const nextLevelObj = levels.find((l) => l.level === data.level + 1);
let progressPercent = 100;
let xpNeeded = "Max Level";
if (nextLevelObj) {
const xpInCurrentLevel = data.xp - currentLevelObj.min_xp;
const levelSpan = nextLevelObj.min_xp - currentLevelObj.min_xp;
progressPercent = Math.min(100, Math.max(0, Math.round((xpInCurrentLevel / levelSpan) * 100)));
xpNeeded = `${data.xp} / ${nextLevelObj.min_xp} XP`;
}
// Update Topbar / Sidebar Indicators
const sbLevel = document.getElementById("sidebar-level-text");
const sbXP = document.getElementById("sidebar-xp-text");
if (sbLevel) sbLevel.innerText = `⭐ Level ${data.level}`;
if (sbXP) sbXP.innerText = `${data.xp} XP`;
// Update HUD Widget if present
const hud = document.getElementById("gamification-hud");
if (!hud) return;
const avatarIcon = currentLevelObj.titel.split(" ").pop() || "🐣";
const solvedCount = (data.geloeste_kapitel || []).length;
const totalChapters = 27;
const nameEl = document.getElementById("hud-user-name");
const titleEl = document.getElementById("hud-user-title");
const rankEl = document.getElementById("hud-user-rank");
const levelEl = document.getElementById("hud-stat-level");
const xpEl = document.getElementById("hud-stat-xp");
const solvedEl = document.getElementById("hud-stat-solved");
const streakEl = document.getElementById("hud-stat-streak");
const barEl = document.getElementById("hud-progress-bar");
const progressTextEl = document.getElementById("hud-progress-text");
const avatarEl = document.getElementById("hud-avatar-icon");
if (nameEl) nameEl.innerText = data.spieler_name || "Entwickler-Pass";
if (titleEl) titleEl.innerText = currentLevelObj.titel;
if (rankEl) rankEl.innerText = `Rang: ${currentLevelObj.rang}`;
if (levelEl) levelEl.innerText = data.level;
if (xpEl) xpEl.innerText = `${data.xp} XP`;
if (solvedEl) solvedEl.innerText = `${solvedCount} / ${totalChapters}`;
if (streakEl) streakEl.innerText = `🔥 ${data.streak_tage || 1} Tag${(data.streak_tage || 1) > 1 ? "e" : ""}`;
if (barEl) barEl.style.width = `${progressPercent}%`;
if (progressTextEl) progressTextEl.innerText = xpNeeded;
if (avatarEl) avatarEl.innerText = avatarIcon;
};
function initTaskCheckboxes() { function initTaskCheckboxes() {
const checkboxes = document.querySelectorAll(".task-checkbox"); const checkboxes = document.querySelectorAll(".task-checkbox");
if (!checkboxes.length) return; if (!checkboxes.length) return;
@ -192,14 +159,13 @@
}); });
} }
// Initialisierung beim Laden
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
renderGamificationHUD(); window.renderGamificationHUD();
initTaskCheckboxes(); initTaskCheckboxes();
}); });
} else { } else {
renderGamificationHUD(); window.renderGamificationHUD();
initTaskCheckboxes(); initTaskCheckboxes();
} }
})(); })();

View file

@ -11,7 +11,7 @@ window.GAMIFICATION_DATA = {
"streak_tage": 1, "streak_tage": 1,
"letzter_aktiver_tag": "2026-08-29", "letzter_aktiver_tag": "2026-08-29",
"historie": [], "historie": [],
"zuletzt_aktualisiert": "2026-08-29T20:50:51.073181" "zuletzt_aktualisiert": "2026-08-29T20:57:49.286070"
}; };
window.GAMIFICATION_BADGES_KATALOG = { window.GAMIFICATION_BADGES_KATALOG = {
"rechenkonegin": { "rechenkonegin": {

View file

@ -8,6 +8,18 @@
(function () { (function () {
const ERROR_RULES = [ const ERROR_RULES = [
{
type: "SyntaxError",
pattern: /SyntaxError: (invalid syntax|expected ':'|was never closed|cannot assign to expression)/i,
title: "✍️ Schreibweise- & Grammatikfehler (SyntaxError)",
explanation: "Python versteht den Aufbau deines Codes an dieser Stelle nicht. Meist fehlt ein Satzzeichen oder eine Klammer.",
causes: [
"Fehlender Doppelpunkt <code>:</code> am Ende von <code>def</code>, <code>if</code>, <code>elif</code>, <code>else</code>, <code>for</code> oder <code>while</code>.",
"Eine geöffnete Klammer <code>(</code> oder ein Anführungszeichen <code>\"</code> wurde nicht geschlossen.",
"Verwechslung von Zuweisung <code>=</code> und Vergleich <code>==</code> in einer <code>if</code>-Bedingung."
],
fix: "Prüfe die Zeile ganz genau: Steht am Zeilenende ein Doppelpunkt? Sind alle Klammern und Anführungszeichen paarweise geschlossen? Nutze <code>if a == b:</code> zum Vergleichen!"
},
{ {
type: "IndentationError", type: "IndentationError",
pattern: /IndentationError: (expected an indented block|unindent does not match)/i, pattern: /IndentationError: (expected an indented block|unindent does not match)/i,
@ -19,6 +31,16 @@
], ],
fix: "Drücke nach dem Doppelpunkt <kbd>Enter</kbd> und rücke die Zeile mit <kbd>Tab</kbd> oder 4 Leerzeichen ein. Mit <kbd>Umschalt</kbd> + <kbd>Tab</kbd> kannst du Zeilen wieder nach links rücken." fix: "Drücke nach dem Doppelpunkt <kbd>Enter</kbd> und rücke die Zeile mit <kbd>Tab</kbd> oder 4 Leerzeichen ein. Mit <kbd>Umschalt</kbd> + <kbd>Tab</kbd> kannst du Zeilen wieder nach links rücken."
}, },
{
type: "TypeError String Concatenation",
pattern: /TypeError: can only concatenate str \(not "(int|float)"\) to str/i,
title: "🔤 Text und Zahl vermischt (TypeError)",
explanation: "Du versuchst einen Text (<code>str</code>) und eine Zahl (<code>int/float</code>) mit <code>+</code> zusammenzufügen.",
causes: [
"In Python kann man Text und Zahlen nicht einfach addieren (z.B. <code>\"Alter: \" + 16</code> schlägt fehl)."
],
fix: "Nutze einen modernen <strong>f-String</strong>: <code>f\"Alter: {alter}\"</code> oder wandle die Zahl mit <code>str(zahl)</code> um."
},
{ {
type: "TypeError NoneType", type: "TypeError NoneType",
pattern: /TypeError: 'NoneType' object is not (subscriptable|iterable|callable)/i, pattern: /TypeError: 'NoneType' object is not (subscriptable|iterable|callable)/i,
@ -85,6 +107,29 @@
], ],
fix: "Prüfe, ob du die Variable vor ihrer Benutzung angelegt hast und ob alle benötigten Module oben mit <code>import ...</code> eingebunden sind." fix: "Prüfe, ob du die Variable vor ihrer Benutzung angelegt hast und ob alle benötigten Module oben mit <code>import ...</code> eingebunden sind."
}, },
{
type: "ValueError",
pattern: /ValueError: (invalid literal for int\(\)|math domain error)/i,
title: "⚠️ Ungültiger Wert (ValueError)",
explanation: "Der Datentyp war zwar richtig, aber der übergebene Wert macht für diesen Befehl keinen Sinn.",
causes: [
"Umwandlung von Text mit Buchstaben in eine Zahl (z.B. <code>int(\"hallo\")</code>).",
"Mathematisch unzulässige Werte (z.B. Wurzel aus einer negativen Zahl)."
],
fix: "Prüfe vorher mit <code>text.isdigit()</code>, ob der Text nur aus Ziffern besteht, oder sichere die Umwandlung mit <code>try-except ValueError:</code> ab."
},
{
type: "AssertionError",
pattern: /AssertionError: (.+)/i,
title: "🧪 Unittest fehlgeschlagen (AssertionError)",
explanation: "Dein Programmcode läuft zwar ohne Absturz durch, liefert aber noch nicht das von der Aufgabe erwartete Ergebnis.",
causes: [
"Ein Rechenfehler oder eine falsche Formel in der Funktion.",
"Rückgabewert vergessen (<code>return</code> fehlt).",
"Grenzfall nicht beachtet (z.B. 0 oder leere Liste)."
],
fix: "Vergleiche die Rückgabe deiner Funktion mit dem in der Testmeldung geforderten Soll-Wert. Lies die Aufgabenbeschreibung noch einmal aufmerksam durch!"
},
{ {
type: "ZeroDivisionError", type: "ZeroDivisionError",
pattern: /ZeroDivisionError: division by zero/i, pattern: /ZeroDivisionError: division by zero/i,
@ -101,9 +146,12 @@
if (!errorText || !errorText.trim()) { if (!errorText || !errorText.trim()) {
return { return {
matched: false, matched: false,
name: "💡 Bitte füge eine Fehlermeldung ein",
title: "💡 Bitte füge eine Fehlermeldung ein", title: "💡 Bitte füge eine Fehlermeldung ein",
explanation: "Kopiere deinen Traceback aus dem Terminal und füge ihn hier ein.", explanation: "Kopiere deinen Traceback aus dem Terminal und füge ihn hier ein.",
fix: "Starte dein Skript im Terminal mit python3 test_aufgabe.py" causes: ["Noch keine Fehlermeldung eingegeben."],
fixes: ["Starte deinen Code oder Unittests, um eventuelle Fehler zu sehen."],
fix: "Starte deinen Code oder Unittests, um eventuelle Fehler zu sehen."
}; };
} }
@ -112,9 +160,11 @@
return { return {
matched: true, matched: true,
type: rule.type, type: rule.type,
name: rule.title,
title: rule.title, title: rule.title,
explanation: rule.explanation, explanation: rule.explanation,
causes: rule.causes, causes: rule.causes,
fixes: [rule.fix],
fix: rule.fix fix: rule.fix
}; };
} }
@ -122,49 +172,40 @@
return { return {
matched: false, matched: false,
name: "🔍 Allgemeiner Python-Fehler",
title: "🔍 Allgemeiner Python-Fehler", title: "🔍 Allgemeiner Python-Fehler",
explanation: "Lies den Traceback immer von ganz unten nach oben: In der allerletzten Zeile nennt Python die genaue Fehlerart und Zeilennummer.", explanation: "Lies den Traceback immer von ganz unten nach oben: In der allerletzten Zeile nennt Python die genaue Fehlerart und Zeilennummer.",
causes: ["Syntax- oder Logikfehler in deinem Code."], causes: ["Syntax- oder Logikfehler in deinem Code."],
fixes: ["Überprüfe die in der letzten Zeile genannte Zeilennummer in deiner aufgabe.py Datei."],
fix: "Überprüfe die in der letzten Zeile genannte Zeilennummer in deiner aufgabe.py Datei." fix: "Überprüfe die in der letzten Zeile genannte Zeilennummer in deiner aufgabe.py Datei."
}; };
}; };
// Exportiertes Objekt für Workspace & Widgets
window.INTERPRETER = {
translate: window.interpretiereTraceback
};
// Widget rendern, falls Container vorhanden // Widget rendern, falls Container vorhanden
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const interpreterEl = document.getElementById("error-interpreter-widget"); const interpreterEl = document.getElementById("error-interpreter-widget");
if (!interpreterEl) return; if (!interpreterEl) return;
interpreterEl.innerHTML = ` const input = document.getElementById("error-input");
<div class="card" style="border-left: 5px solid var(--secondary);"> const btn = document.getElementById("btn-interpret-error");
<h2>🩺 Interaktiver Python-Fehler-Dolmetscher</h2> const result = document.getElementById("error-result");
<p>Du verstehst eine Fehlermeldung aus dem Terminal nicht? Füge sie hier ein und erhalte sofort eine schülerfreundliche deutsche Erklärung mit Reparatur-Anleitung!</p>
<textarea id="traceback-input" placeholder="Füge hier deinen Traceback ein (z.B. TypeError: ... oder IndexError: list index out of range)" style="width: 100%; height: 90px; padding: 12px; font-family: var(--font-mono); font-size: 0.88rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color); margin-bottom: 12px;"></textarea>
<button id="btn-interpret" class="btn" style="background: var(--secondary);">🔍 Fehlermeldung übersetzen</button>
<div id="interpreter-result" style="margin-top: 15px; display: none;"></div>
</div>
`;
const input = document.getElementById("traceback-input");
const btn = document.getElementById("btn-interpret");
const result = document.getElementById("interpreter-result");
if (btn && input && result) {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const res = window.interpretiereTraceback(input.value); const res = window.interpretiereTraceback(input.value);
result.innerHTML = ` document.getElementById("error-name").innerText = res.title;
<div class="box ${res.matched ? 'box-warning' : 'box-tipp'}"> document.getElementById("error-explanation").innerHTML = res.explanation;
<div class="box-title">${res.title}</div> const fixList = document.getElementById("error-fixes");
<p><strong>Was bedeutet das?</strong> ${res.explanation}</p> if (fixList) {
${res.causes ? `<p><strong>Mögliche Ursachen:</strong></p><ul style="margin-left: 20px;">${res.causes.map(c => `<li>${c}</li>`).join("")}</ul>` : ''} fixList.innerHTML = (res.causes || []).map(c => `<li>${c}</li>`).join("") + `<li><strong>Lösung:</strong> ${res.fix}</li>`;
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.1);"> }
<strong>🔧 So behebst du den Fehler:</strong>
<p style="margin-top: 4px;">${res.fix}</p>
</div>
</div>
`;
result.style.display = "block"; result.style.display = "block";
}); });
}
}); });
})(); })();

View file

@ -1,11 +1,12 @@
/** /**
* ⚡ MONACO WEB-IDE & PYODIDE TEST-RUNNER ENGINE ⚡ * ⚡ GEHÄRTETE MONACO WEB-IDE & PYODIDE RUNNER ENGINE ⚡
* ================================================= * =====================================================
* Interaktive Split-Screen Entwicklungsumgebung mit: * Interaktive Entwicklungsumgebung mit:
* - Monaco Editor (VS Code Engine mit Autocomplete & Syntax-Highlighting) * - Monaco Editor (VS Code Engine mit Autocomplete & Syntax-Highlighting)
* - Tastatur-Shortcuts: Strg+Enter (Run), Strg+Shift+Enter (Test), Strg+S (Save)
* - In-Browser Pyodide WebAssembly Python Runner (95% Client-Execution) * - In-Browser Pyodide WebAssembly Python Runner (95% Client-Execution)
* - Automatischer Test-Auswertung mit XP-Vergabe * - Endlosschleifen-Timeout-Schutz (5000ms)
* - Cloud Auto-Save * - Reaktivität mit Fehler-Dolmetscher & Gamification
*/ */
(function () { (function () {
@ -15,6 +16,16 @@
let currentChapterData = null; let currentChapterData = null;
let autoSaveTimeout = null; let autoSaveTimeout = null;
function escapeHtml(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 1. Initialisiere Monaco Editor via CDN // 1. Initialisiere Monaco Editor via CDN
function initMonacoEditor(initialCode = "") { function initMonacoEditor(initialCode = "") {
const editorContainer = document.getElementById("monaco-editor-container"); const editorContainer = document.getElementById("monaco-editor-container");
@ -25,7 +36,6 @@
return; return;
} }
// Monaco CDN Loader
const loaderScript = document.createElement("script"); const loaderScript = document.createElement("script");
loaderScript.src = "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"; loaderScript.src = "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js";
loaderScript.onload = () => { loaderScript.onload = () => {
@ -37,8 +47,7 @@
}); });
}; };
loaderScript.onerror = () => { loaderScript.onerror = () => {
// Fallback: Einfache Textarea falls CDN offline editorContainer.innerHTML = `<textarea id="fallback-code-editor" style="width: 100%; height: 100%; background: #0b0f19; color: #f8fafc; font-family: monospace; font-size: 14px; padding: 14px; border: none; outline: none;">${escapeHtml(initialCode)}</textarea>`;
editorContainer.innerHTML = `<textarea id="fallback-code-editor" style="width: 100%; height: 100%; background: #0b0f19; color: #f8fafc; font-family: monospace; font-size: 14px; padding: 14px; border: none; outline: none;">${initialCode}</textarea>`;
}; };
document.head.appendChild(loaderScript); document.head.appendChild(loaderScript);
} }
@ -63,12 +72,31 @@
bracketPairColorization: { enabled: true } bracketPairColorization: { enabled: true }
}); });
// Auto-Save Trigger beim Tippen // Auto-Save beim Tippen
monacoEditor.onDidChangeModelContent(() => { monacoEditor.onDidChangeModelContent(() => {
triggerAutoSave(); triggerAutoSave();
}); });
// Tastatur-Shortcuts in Monaco
monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.Enter, () => {
runCode();
});
monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyMod.Shift | window.monaco.KeyCode.Enter, () => {
runTests();
});
monacoEditor.addCommand(window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyS, () => {
saveProgressDraft();
});
} }
// Globaler Keydown-Listener für Strg+S
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveProgressDraft();
}
});
// 2. Pyodide WebAssembly Initialisierung // 2. Pyodide WebAssembly Initialisierung
async function getPyodide() { async function getPyodide() {
if (pyodideInstance) return pyodideInstance; if (pyodideInstance) return pyodideInstance;
@ -98,7 +126,7 @@
stderr: (text) => logToTerminal(text + "\n", "stderr") stderr: (text) => logToTerminal(text + "\n", "stderr")
}); });
logToTerminal("✅ Python WebAssembly erfolgreich bereitgestellt!\n\n", "success"); logToTerminal("✅ Python WebAssembly bereitgestellt!\n\n", "success");
} catch (err) { } catch (err) {
logToTerminal(`❌ Fehler beim Laden von Pyodide: ${err.message}\n`, "error"); logToTerminal(`❌ Fehler beim Laden von Pyodide: ${err.message}\n`, "error");
} finally { } finally {
@ -107,17 +135,28 @@
return pyodideInstance; return pyodideInstance;
} }
// 3. Code-Ausführung (Run) // 3. Code-Ausführung mit Timeout-Schutz (5s)
async function runCode() { async function runCode() {
const code = getEditorCode(); const code = getEditorCode();
clearTerminal(); clearTerminal();
logToTerminal("▶ Starte Python-Skript...\n----------------------------------------\n", "info"); logToTerminal("▶ Starte Python-Skript (Strg+Enter)...\n----------------------------------------\n", "info");
const py = await getPyodide(); const py = await getPyodide();
if (!py) return; if (!py) return;
try { try {
await py.runPythonAsync(code); // Timeout-Rennen
let timeoutHandle;
const timeoutPromise = new Promise((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error("Zeitüberschreitung: Dein Code lief länger als 5 Sekunden (Endlosschleife vermutet)."));
}, 5000);
});
const execPromise = py.runPythonAsync(code);
await Promise.race([execPromise, timeoutPromise]);
clearTimeout(timeoutHandle);
logToTerminal("\n----------------------------------------\n✨ Skript erfolgreich ausgeführt!\n", "success"); logToTerminal("\n----------------------------------------\n✨ Skript erfolgreich ausgeführt!\n", "success");
} catch (err) { } catch (err) {
logToTerminal(`\n❌ Ausführungsfehler:\n${err.message}\n`, "error"); logToTerminal(`\n❌ Ausführungsfehler:\n${err.message}\n`, "error");
@ -134,16 +173,14 @@
const userCode = getEditorCode(); const userCode = getEditorCode();
clearTerminal(); clearTerminal();
logToTerminal("🧪 Führe automatisierte Unittests aus...\n----------------------------------------\n", "info"); logToTerminal("🧪 Führe automatisierte Unittests aus (Strg+Shift+Enter)...\n----------------------------------------\n", "info");
const py = await getPyodide(); const py = await getPyodide();
if (!py) return; if (!py) return;
try { try {
// 1. Schreibe aufgabe.py in das Pyodide-Dateisystem
py.FS.writeFile("aufgabe.py", userCode); py.FS.writeFile("aufgabe.py", userCode);
// 2. Führe test_aufgabe.py aus
const testRunnerScript = ` const testRunnerScript = `
import unittest, io, sys import unittest, io, sys
from unittest import TextTestRunner from unittest import TextTestRunner
@ -169,7 +206,18 @@ errors = len(result.errors)
(success, total_runs, failures, errors, output_text) (success, total_runs, failures, errors, output_text)
`; `;
const [isSuccess, totalRuns, fails, errs, outputText] = await py.runPythonAsync(testRunnerScript); let timeoutHandle;
const timeoutPromise = new Promise((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error("Zeitüberschreitung: Unittest lief länger als 6 Sekunden."));
}, 6000);
});
const [isSuccess, totalRuns, fails, errs, outputText] = await Promise.race([
py.runPythonAsync(testRunnerScript),
timeoutPromise
]);
clearTimeout(timeoutHandle);
logToTerminal(outputText + "\n", "stdout"); logToTerminal(outputText + "\n", "stdout");
@ -225,17 +273,15 @@ errors = len(result.errors)
clearTimeout(autoSaveTimeout); clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(() => { autoSaveTimeout = setTimeout(() => {
saveProgressDraft(); saveProgressDraft();
}, 1500); // 1.5s nach letztem Tastenanschlag speichern }, 1500);
} }
async function saveProgressDraft() { async function saveProgressDraft() {
if (!currentChapterData || !currentChapterData.chapterId) return; if (!currentChapterData || !currentChapterData.chapterId) return;
const code = getEditorCode(); const code = getEditorCode();
// 1. Lokal in LocalStorage
localStorage.setItem(`code_draft_${currentChapterData.chapterId}`, code); localStorage.setItem(`code_draft_${currentChapterData.chapterId}`, code);
// 2. In Backend-Cloud falls angemeldet
const token = localStorage.getItem("auth_token"); const token = localStorage.getItem("auth_token");
if (token) { if (token) {
try { try {
@ -266,12 +312,12 @@ errors = len(result.errors)
async function recordChapterSolved() { async function recordChapterSolved() {
if (!currentChapterData || !currentChapterData.chapterId) return; if (!currentChapterData || !currentChapterData.chapterId) return;
// 1. Lokale Gamification // Lokale Gamification
if (window.addXP) { if (window.addXP) {
window.addXP(100, currentChapterData.chapterId); window.addXP(100, currentChapterData.chapterId);
} }
// 2. Backend Cloud // Backend Cloud
const token = localStorage.getItem("auth_token"); const token = localStorage.getItem("auth_token");
if (token) { if (token) {
try { try {
@ -307,19 +353,20 @@ errors = len(result.errors)
errorTab.style.borderColor = "var(--danger)"; errorTab.style.borderColor = "var(--danger)";
errorTab.innerHTML = "🩺 Fehler-Dolmetscher ⚠️"; errorTab.innerHTML = "🩺 Fehler-Dolmetscher ⚠️";
} }
if (window.interpretWorkspaceError) {
window.interpretWorkspaceError();
}
} }
// 6. Init Workspace Controller // 6. Init Workspace Controller
window.initWorkspace = function (chapterData) { window.initWorkspace = function (chapterData) {
currentChapterData = chapterData; currentChapterData = chapterData;
// Lade gespeicherten Code-Draft oder Startercode
const savedDraft = localStorage.getItem(`code_draft_${chapterData.chapterId}`); const savedDraft = localStorage.getItem(`code_draft_${chapterData.chapterId}`);
const initialCode = savedDraft || chapterData.starterCode || ""; const initialCode = savedDraft || chapterData.starterCode || "";
initMonacoEditor(initialCode); initMonacoEditor(initialCode);
// Event Listener für Buttons
document.getElementById("btn-run-code")?.addEventListener("click", runCode); document.getElementById("btn-run-code")?.addEventListener("click", runCode);
document.getElementById("btn-run-tests")?.addEventListener("click", runTests); document.getElementById("btn-run-tests")?.addEventListener("click", runTests);
document.getElementById("btn-save-code")?.addEventListener("click", saveProgressDraft); document.getElementById("btn-save-code")?.addEventListener("click", saveProgressDraft);

312
server.py
View file

@ -1,11 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
🐍 PYTHON LERNPLATTFORM – BACKEND SERVER 🐍 🐍 PYTHON LERNPLATTFORM – GEHÄRTETER BACKEND SERVER 🐍
=========================================== ======================================================
Leichtgewichtiger, abhängigkeitsfreier REST-API & Static Server für die Online-Lernplattform. Sicherheitsoptimierter, abhängigkeitsfreier REST-API & Static Server.
- Läuft auf jedem Linux-Server / Webspace mit Standard-Python 3. - Persistenter SECRET_KEY (Env / geschützte .secret_key Datei).
- SQLite-Datenbank (User-Accounts, Code-Drafts, XP, Klassen & Lehrer-Dashboard). - Schutz gegen User-Enumeration (Constant-Time Dummy PBKDF2).
- PBKDF2-HMAC-SHA256 Passwort-Verschlüsselung & signierte Session-Tokens. - In-Memory Rate-Limiting gegen Brute-Force-Angriffe.
- Autorisierungsprüfung (IDOR-Schutz) in allen Lehrer-Endpunkten.
- Striktes Blockieren sensitiver Dateien (.db, .secret_key, .py, .php, .git) im Static-Handler.
- Security-Header (CSP, X-Content-Type-Options, X-Frame-Options).
- Neuer Code-Inspektor für Lehrkräfte, CSV-Matrix-Export & Zertifikats-Verifikation.
""" """
import http.server import http.server
@ -17,18 +21,67 @@ import hmac
import secrets import secrets
import time import time
import os import os
import re
import uuid
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
from collections import defaultdict
PORT = int(os.environ.get("PORT", 8000)) PORT = int(os.environ.get("PORT", 8000))
BASE_DIR = Path(__file__).parent.resolve() BASE_DIR = Path(__file__).parent.resolve()
DB_FILE = BASE_DIR / "platform_data.db" DB_FILE = BASE_DIR / "platform_data.db"
SECRET_KEY = secrets.token_hex(32) KEY_FILE = BASE_DIR / ".secret_key"
# ============================================================================== # ------------------------------------------------------------------------------
# DATENBANK INITIALISIERUNG # 1. PERSISTENTER SECRET KEY
# ============================================================================== # ------------------------------------------------------------------------------
def get_or_create_secret_key() -> str:
env_key = os.environ.get("SECRET_KEY")
if env_key and len(env_key) >= 32:
return env_key
if KEY_FILE.exists():
try:
with open(KEY_FILE, "r", encoding="utf-8") as f:
key = f.read().strip()
if len(key) >= 32:
return key
except Exception:
pass
new_key = secrets.token_hex(32)
try:
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
mode = 0o600
fd = os.open(str(KEY_FILE), flags, mode)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(new_key)
except Exception as e:
print(f"⚠️ Warnung: Konnte .secret_key nicht schreiben: {e}")
return new_key
SECRET_KEY = get_or_create_secret_key()
# ------------------------------------------------------------------------------
# 2. IN-MEMORY RATE LIMITER
# ------------------------------------------------------------------------------
class SimpleRateLimiter:
def __init__(self):
self.attempts = defaultdict(list)
def is_allowed(self, key: str, max_requests: int = 5, window_seconds: int = 60) -> bool:
now = time.time()
self.attempts[key] = [t for t in self.attempts[key] if now - t < window_seconds]
if len(self.attempts[key]) >= max_requests:
return False
self.attempts[key].append(now)
return True
rate_limiter = SimpleRateLimiter()
# ------------------------------------------------------------------------------
# 3. DATENBANK INITIALISIERUNG
# ------------------------------------------------------------------------------
def get_db(): def get_db():
conn = sqlite3.connect(str(DB_FILE)) conn = sqlite3.connect(str(DB_FILE))
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -37,8 +90,6 @@ def get_db():
def init_db(): def init_db():
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
# 1. Benutzer-Tabelle
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -54,7 +105,6 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""") )""")
# 2. Kapitel-Fortschritt & Code-Drafts
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS chapter_progress ( CREATE TABLE IF NOT EXISTS chapter_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -69,7 +119,6 @@ def init_db():
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""") )""")
# 3. Klassenräume (Lehrer-Feature)
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS classrooms ( CREATE TABLE IF NOT EXISTS classrooms (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -80,7 +129,6 @@ def init_db():
FOREIGN KEY(teacher_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(teacher_id) REFERENCES users(id) ON DELETE CASCADE
)""") )""")
# 4. Klassen-Mitgliedschaften
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS class_enrollments ( CREATE TABLE IF NOT EXISTS class_enrollments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -92,24 +140,23 @@ def init_db():
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""") )""")
# 5. Freigeschaltete Trophäen
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS achievements ( CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
trophy_id TEXT NOT NULL, track_id TEXT NOT NULL,
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, student_name TEXT NOT NULL,
UNIQUE(user_id, trophy_id), issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""") )""")
conn.commit() conn.commit()
conn.close() conn.close()
# ============================================================================== # ------------------------------------------------------------------------------
# AUTHENTIFIZIERUNG & HASHING # 4. AUTH & HASHING
# ============================================================================== # ------------------------------------------------------------------------------
def hash_password(password: str, salt: str = None) -> tuple[str, str]: def hash_password(password: str, salt: str = None) -> tuple[str, str]:
if not salt: if not salt:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
@ -122,14 +169,18 @@ def verify_password(password: str, password_hash: str, salt: str) -> bool:
new_hash, _ = hash_password(password, salt) new_hash, _ = hash_password(password, salt)
return hmac.compare_digest(new_hash, password_hash) return hmac.compare_digest(new_hash, password_hash)
# Constant-Time Dummy PBKDF2 gegen User-Enumeration
DUMMY_SALT = secrets.token_hex(16)
DUMMY_HASH, _ = hash_password("dummy_password_constant_time", DUMMY_SALT)
def generate_token(user_id: int, email: str, role: str) -> str: def generate_token(user_id: int, email: str, role: str) -> str:
payload = { payload = {
"uid": user_id, "uid": user_id,
"email": email, "email": email,
"role": role, "role": role,
"exp": int(time.time()) + (86400 * 30) # 30 Tage gültig "exp": int(time.time()) + (86400 * 30)
} }
payload_json = json.dumps(payload) payload_json = json.dumps(payload, sort_keys=True)
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest() sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest()
token = f"{payload_json}.{sig}" token = f"{payload_json}.{sig}"
return urllib.parse.quote(token) return urllib.parse.quote(token)
@ -150,18 +201,19 @@ def verify_token(token_str: str) -> dict | None:
except Exception: except Exception:
return None return None
# ============================================================================== # ------------------------------------------------------------------------------
# HTTP REQUEST HANDLER # 5. HTTP REQUEST HANDLER
# ============================================================================== # ------------------------------------------------------------------------------
class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler): class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs) super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def end_headers(self): def end_headers(self):
# CORS & Cache-Header für moderne Web-Apps self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "SAMEORIGIN")
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Cache-Control", "no-cache, must-revalidate") self.send_header("Cache-Control", "no-cache, must-revalidate")
super().end_headers() super().end_headers()
@ -176,6 +228,12 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8")) self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
def get_client_ip(self) -> str:
forwarded = self.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return self.client_address[0]
def get_auth_user(self) -> dict | None: def get_auth_user(self) -> dict | None:
auth_header = self.headers.get("Authorization", "") auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
@ -185,16 +243,30 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
def read_json_body(self) -> dict: def read_json_body(self) -> dict:
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0: if content_length == 0 or content_length > 1_000_000:
return {} return {}
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8")) return json.loads(body.decode("utf-8"))
def is_path_blocked(self, path: str) -> bool:
clean_path = path.split("?")[0].lower()
blocked_extensions = (".db", ".sqlite", ".secret_key", ".py", ".php", ".jsonl")
if any(clean_path.endswith(ext) for ext in blocked_extensions):
return True
if "/." in clean_path or clean_path.startswith("/."):
return True
if "platform_data.db" in clean_path or ".gamification.json" in clean_path:
return True
return False
def do_GET(self): def do_GET(self):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
# --- API ROUTES --- if self.is_path_blocked(path):
return self.send_json({"error": "Zugriff verweigert (Geschützte Datei)"}, 403)
# 1. AUTH ME
if path == "/api/auth/me": if path == "/api/auth/me":
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
@ -206,6 +278,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return self.send_json({"error": "Benutzer nicht gefunden"}, 404) return self.send_json({"error": "Benutzer nicht gefunden"}, 404)
return self.send_json({"user": dict(row)}) return self.send_json({"user": dict(row)})
# 2. PROGRESS GET
elif path.startswith("/api/progress/get"): elif path.startswith("/api/progress/get"):
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
@ -223,6 +296,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"all_progress": [dict(r) for r in rows]}) return self.send_json({"all_progress": [dict(r) for r in rows]})
# 3. CLASSROOMS LIST
elif path.startswith("/api/classrooms/list"): elif path.startswith("/api/classrooms/list"):
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
@ -248,6 +322,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"classrooms": [dict(c) for c in classes]}) return self.send_json({"classrooms": [dict(c) for c in classes]})
# 4. CLASSROOMS MATRIX (mit IDOR-Schutz)
elif path.startswith("/api/classrooms/matrix"): elif path.startswith("/api/classrooms/matrix"):
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token or user_token.get("role") != "teacher": if not user_token or user_token.get("role") != "teacher":
@ -255,16 +330,21 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
query = urllib.parse.parse_qs(parsed.query) query = urllib.parse.parse_qs(parsed.query)
classroom_id = query.get("classroom_id", [""])[0] classroom_id = query.get("classroom_id", [""])[0]
if not classroom_id: if not classroom_id or not classroom_id.isdigit():
return self.send_json({"error": "classroom_id fehlt"}, 400) return self.send_json({"error": "Ungültige classroom_id"}, 400)
conn = get_db() conn = get_db()
owner_check = conn.execute("SELECT id, name FROM classrooms WHERE id = ? AND teacher_id = ?", (int(classroom_id), user_token["uid"])).fetchone()
if not owner_check:
conn.close()
return self.send_json({"error": "Zugriff verweigert (Nicht deine Klasse)"}, 403)
students = conn.execute(""" students = conn.execute("""
SELECT u.id, u.name, u.email, u.xp, u.level, u.streak_days SELECT u.id, u.name, u.email, u.xp, u.level, u.streak_days
FROM class_enrollments ce FROM class_enrollments ce
JOIN users u ON ce.user_id = u.id JOIN users u ON ce.user_id = u.id
WHERE ce.classroom_id = ? WHERE ce.classroom_id = ?
""", (classroom_id,)).fetchall() """, (int(classroom_id),)).fetchall()
matrix = [] matrix = []
for st in students: for st in students:
@ -274,7 +354,58 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
"solved_chapters": {r["chapter_id"]: bool(r["is_solved"]) for r in solved} "solved_chapters": {r["chapter_id"]: bool(r["is_solved"]) for r in solved}
}) })
conn.close() conn.close()
return self.send_json({"matrix": matrix}) return self.send_json({"classroom": dict(owner_check), "matrix": matrix})
# 5. SCHÜLER-CODE INSPEKTOR FÜR LEHRER
elif path.startswith("/api/classrooms/student-code"):
user_token = self.get_auth_user()
if not user_token or user_token.get("role") != "teacher":
return self.send_json({"error": "Nur für Lehrkräfte"}, 403)
query = urllib.parse.parse_qs(parsed.query)
classroom_id = query.get("classroom_id", [""])[0]
student_id = query.get("student_id", [""])[0]
chapter_id = query.get("chapter_id", [""])[0]
if not classroom_id or not student_id or not chapter_id:
return self.send_json({"error": "Fehlende Parameter"}, 400)
conn = get_db()
# Prüfe Autorisierung
auth_check = conn.execute("""
SELECT c.id FROM classrooms c
JOIN class_enrollments ce ON c.id = ce.classroom_id
WHERE c.id = ? AND c.teacher_id = ? AND ce.user_id = ?
""", (int(classroom_id), user_token["uid"], int(student_id))).fetchone()
if not auth_check:
conn.close()
return self.send_json({"error": "Zugriff verweigert"}, 403)
progress = conn.execute("SELECT chapter_id, code_draft, subgoals_json, is_solved, solved_at FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (int(student_id), chapter_id)).fetchone()
student = conn.execute("SELECT id, name, email FROM users WHERE id = ?", (int(student_id),)).fetchone()
conn.close()
return self.send_json({
"student": dict(student) if student else None,
"progress": dict(progress) if progress else None
})
# 6. ZERTIFIKATS-VERIFIKATION
elif path.startswith("/api/certificates/verify"):
query = urllib.parse.parse_qs(parsed.query)
cert_uuid = query.get("uuid", [""])[0].strip()
if not cert_uuid:
return self.send_json({"error": "uuid fehlt"}, 400)
conn = get_db()
cert = conn.execute("SELECT uuid, track_id, student_name, issued_at FROM certificates WHERE uuid = ?", (cert_uuid,)).fetchone()
conn.close()
if not cert:
return self.send_json({"valid": False, "error": "Zertifikat nicht gefunden"}, 404)
return self.send_json({"valid": True, "certificate": dict(cert)})
elif path == "/api/stats/leaderboard": elif path == "/api/stats/leaderboard":
conn = get_db() conn = get_db()
@ -282,12 +413,12 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"leaderboard": [dict(r) for r in rows]}) return self.send_json({"leaderboard": [dict(r) for r in rows]})
# --- STATISCHE DATEIEN ---
return super().do_GET() return super().do_GET()
def do_POST(self): def do_POST(self):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
client_ip = self.get_client_ip()
try: try:
body = self.read_json_body() body = self.read_json_body()
@ -296,15 +427,22 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
# 1. REGISTRIERUNG # 1. REGISTRIERUNG
if path == "/api/auth/register": if path == "/api/auth/register":
email = body.get("email", "").strip().lower() if not rate_limiter.is_allowed(f"reg_{client_ip}", max_requests=5, window_seconds=60):
password = body.get("password", "") return self.send_json({"error": "Zu viele Registrierungsversuche. Bitte warte eine Minute."}, 429)
name = body.get("name", "").strip()
role = body.get("role", "student")
if not email or not password or not name: email = str(body.get("email", "")).strip().lower()
return self.send_json({"error": "Name, E-Mail und Passwort sind erforderlich"}, 400) password = str(body.get("password", ""))
if len(password) < 6: name = str(body.get("name", "")).strip()
return self.send_json({"error": "Passwort muss mindestens 6 Zeichen lang sein"}, 400) role = str(body.get("role", "student")).strip()
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email) or len(email) > 120:
return self.send_json({"error": "Ungültige E-Mail-Adresse"}, 400)
if len(password) < 6 or len(password) > 128:
return self.send_json({"error": "Passwort muss zwischen 6 und 128 Zeichen lang sein"}, 400)
if not name or len(name) > 60:
return self.send_json({"error": "Name muss zwischen 1 und 60 Zeichen lang sein"}, 400)
if role not in ("student", "teacher"):
role = "student"
pwd_hash, salt = hash_password(password) pwd_hash, salt = hash_password(password)
conn = get_db() conn = get_db()
@ -325,16 +463,23 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
finally: finally:
conn.close() conn.close()
# 2. LOGIN # 2. LOGIN (mit Constant-Time Schutz gegen Enumeration)
elif path == "/api/auth/login": elif path == "/api/auth/login":
email = body.get("email", "").strip().lower() if not rate_limiter.is_allowed(f"login_{client_ip}", max_requests=8, window_seconds=60):
password = body.get("password", "") return self.send_json({"error": "Zu viele Login-Versuche. Bitte warte eine Minute."}, 429)
email = str(body.get("email", "")).strip().lower()
password = str(body.get("password", ""))
conn = get_db() conn = get_db()
user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
conn.close() conn.close()
if not user or not verify_password(password, user["password_hash"], user["salt"]): if not user:
verify_password(password, DUMMY_HASH, DUMMY_SALT)
return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401)
if not verify_password(password, user["password_hash"], user["salt"]):
return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401) return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401)
token = generate_token(user["id"], user["email"], user["role"]) token = generate_token(user["id"], user["email"], user["role"])
@ -358,9 +503,9 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
if not user_token: if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401) return self.send_json({"error": "Nicht authentifiziert"}, 401)
chapter_id = body.get("chapter_id", "") chapter_id = str(body.get("chapter_id", ""))[:120]
code_draft = body.get("code_draft", "") code_draft = str(body.get("code_draft", ""))[:100_000]
subgoals_json = json.dumps(body.get("subgoals", {})) subgoals_json = json.dumps(body.get("subgoals", {}))[:10_000]
if not chapter_id: if not chapter_id:
return self.send_json({"error": "chapter_id erforderlich"}, 400) return self.send_json({"error": "chapter_id erforderlich"}, 400)
@ -379,22 +524,23 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return self.send_json({"status": "saved", "chapter_id": chapter_id}) return self.send_json({"status": "saved", "chapter_id": chapter_id})
# 4. KAPITEL ALS GELÖST MARKIEREN & XP VERGEBEN # 4. PROGRESS SOLVE
elif path == "/api/progress/solve": elif path == "/api/progress/solve":
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401) return self.send_json({"error": "Nicht authentifiziert"}, 401)
chapter_id = body.get("chapter_id", "") chapter_id = str(body.get("chapter_id", ""))[:120]
xp_reward = int(body.get("xp_reward", 100)) try:
xp_reward = min(max(0, int(body.get("xp_reward", 100))), 100)
except (ValueError, TypeError):
xp_reward = 100
if not chapter_id: if not chapter_id:
return self.send_json({"error": "chapter_id erforderlich"}, 400) return self.send_json({"error": "chapter_id erforderlich"}, 400)
conn = get_db() conn = get_db()
# Prüfe, ob das Kapitel bereits gelöst war
existing = conn.execute("SELECT is_solved FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (user_token["uid"], chapter_id)).fetchone() existing = conn.execute("SELECT is_solved FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (user_token["uid"], chapter_id)).fetchone()
was_already_solved = existing and existing["is_solved"] == 1 was_already_solved = existing and existing["is_solved"] == 1
if not was_already_solved: if not was_already_solved:
@ -407,7 +553,6 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
""", (user_token["uid"], chapter_id)) """, (user_token["uid"], chapter_id))
# XP & Level berechnen
user_row = conn.execute("SELECT xp FROM users WHERE id = ?", (user_token["uid"],)).fetchone() user_row = conn.execute("SELECT xp FROM users WHERE id = ?", (user_token["uid"],)).fetchone()
new_xp = (user_row["xp"] if user_row else 0) + xp_reward new_xp = (user_row["xp"] if user_row else 0) + xp_reward
new_level = (new_xp // 100) + 1 new_level = (new_xp // 100) + 1
@ -420,13 +565,13 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"status": "already_solved", "earned_xp": 0}) return self.send_json({"status": "already_solved", "earned_xp": 0})
# 5. KLASSENRAUM ERSTELLEN (LEHRER) # 5. KLASSENRAUM ERSTELLEN
elif path == "/api/classrooms/create": elif path == "/api/classrooms/create":
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token or user_token.get("role") != "teacher": if not user_token or user_token.get("role") != "teacher":
return self.send_json({"error": "Nur für Lehrkräfte gestattet"}, 403) return self.send_json({"error": "Nur für Lehrkräfte gestattet"}, 403)
name = body.get("name", "").strip() name = str(body.get("name", "")).strip()[:80]
if not name: if not name:
return self.send_json({"error": "Klassenname erforderlich"}, 400) return self.send_json({"error": "Klassenname erforderlich"}, 400)
@ -441,13 +586,16 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return self.send_json({"classroom": {"id": class_id, "name": name, "invite_code": invite_code}}, 201) return self.send_json({"classroom": {"id": class_id, "name": name, "invite_code": invite_code}}, 201)
# 6. KLASSENRAUM BEITRETEN (SCHÜLER) # 6. KLASSENRAUM BEITRETEN
elif path == "/api/classrooms/join": elif path == "/api/classrooms/join":
if not rate_limiter.is_allowed(f"join_{client_ip}", max_requests=10, window_seconds=60):
return self.send_json({"error": "Zu viele Versuche. Bitte kurz warten."}, 429)
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401) return self.send_json({"error": "Nicht authentifiziert"}, 401)
code = body.get("invite_code", "").strip().upper() code = str(body.get("invite_code", "")).strip().upper()[:12]
conn = get_db() conn = get_db()
cl = conn.execute("SELECT id, name FROM classrooms WHERE invite_code = ?", (code,)).fetchone() cl = conn.execute("SELECT id, name FROM classrooms WHERE invite_code = ?", (code,)).fetchone()
if not cl: if not cl:
@ -463,11 +611,37 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
conn.close() conn.close()
return self.send_json({"message": "Du bist dieser Klasse bereits beigetreten.", "classroom": dict(cl)}) return self.send_json({"message": "Du bist dieser Klasse bereits beigetreten.", "classroom": dict(cl)})
return self.send_json({"error": "Endpunkt nicht gefunden"}, 404) # 7. KLASSE LÖSCHEN (LEHRER)
elif path == "/api/classrooms/delete":
user_token = self.get_auth_user()
if not user_token or user_token.get("role") != "teacher":
return self.send_json({"error": "Nur für Lehrkräfte"}, 403)
# ============================================================================== class_id = body.get("classroom_id")
# SERVER START conn = get_db()
# ============================================================================== conn.execute("DELETE FROM classrooms WHERE id = ? AND teacher_id = ?", (class_id, user_token["uid"]))
conn.commit()
conn.close()
return self.send_json({"status": "deleted"})
# 8. ZERTIFIKAT AUSSTELLEN
elif path == "/api/certificates/create":
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
track_id = str(body.get("track_id", ""))[:60]
student_name = str(body.get("student_name", ""))[:100]
cert_uuid = str(uuid.uuid4())
conn = get_db()
conn.execute("INSERT INTO certificates (uuid, user_id, track_id, student_name) VALUES (?, ?, ?, ?)",
(cert_uuid, user_token["uid"], track_id, student_name))
conn.commit()
conn.close()
return self.send_json({"uuid": cert_uuid, "track_id": track_id, "student_name": student_name})
return self.send_json({"error": "Endpunkt nicht gefunden"}, 404)
def run_server(): def run_server():
init_db() init_db()
@ -475,7 +649,7 @@ def run_server():
socketserver.TCPServer.allow_reuse_address = True socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), handler) as httpd: with socketserver.TCPServer(("", PORT), handler) as httpd:
print(f"🚀 Python Lernplattform Server läuft auf: http://localhost:{PORT}") print(f"🚀 Python Lernplattform Server läuft auf: http://localhost:{PORT}")
print(f"📁 Root-Verzeichnis: {BASE_DIR}") print(f"🔒 Secret Key Status: Aktiv (Persistiert)")
print(f"💾 SQLite-Datenbank: {DB_FILE}") print(f"💾 SQLite-Datenbank: {DB_FILE}")
try: try:
httpd.serve_forever() httpd.serve_forever()

View file

@ -75,7 +75,7 @@
<div class="header-container"> <div class="header-container">
<div class="header-badge">👨‍🏫 Dozenten- &amp; Lehrer-Portal</div> <div class="header-badge">👨‍🏫 Dozenten- &amp; Lehrer-Portal</div>
<h1>Klassenverwaltung &amp; Lernfortschritts-Matrix</h1> <h1>Klassenverwaltung &amp; Lernfortschritts-Matrix</h1>
<p>Behalte den Überblick über deine Klassen, lade Schüler per Code ein und verfolge gelöste Aufgaben in Echtzeit.</p> <p>Behalte den Überblick über deine Klassen, lade Schüler per Code ein, inspiziere ihren Code und exportiere Notenlisten als CSV.</p>
</div> </div>
</header> </header>
@ -108,8 +108,28 @@
</div> </div>
<!-- Student Code Inspector Modal -->
<div class="certificate-overlay" id="code-inspector-modal" style="display: none;">
<div class="certificate-container" style="max-width: 720px; text-align: left; padding: 24px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 id="inspector-title" style="margin: 0; color: var(--text-main);">🔍 Code-Inspektor</h3>
<button class="btn btn-secondary" style="padding: 4px 10px;" onclick="document.getElementById('code-inspector-modal').style.display='none'">✕</button>
</div>
<div id="inspector-meta" style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 10px;"></div>
<pre id="inspector-code-content" style="background: #0b0f19; color: #f8fafc; padding: 16px; border-radius: 6px; font-family: monospace; font-size: 0.88rem; max-height: 380px; overflow-y: auto; white-space: pre-wrap; margin: 0;"></pre>
<div style="text-align: right; margin-top: 14px;">
<button class="btn" onclick="document.getElementById('code-inspector-modal').style.display='none'">Schließen</button>
</div>
</div>
</div>
<script src="assets/app_auth.js"></script> <script src="assets/app_auth.js"></script>
<script> <script>
function escapeHtml(text) {
if (!text) return "";
return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
async function loadClassrooms() { async function loadClassrooms() {
const container = document.getElementById("classrooms-list-container"); const container = document.getElementById("classrooms-list-container");
const token = localStorage.getItem("auth_token"); const token = localStorage.getItem("auth_token");
@ -144,16 +164,20 @@
<div class="class-card"> <div class="class-card">
<div class="class-header"> <div class="class-header">
<div> <div>
<h3 style="margin: 0 0 4px 0; color: var(--text-main); font-size: 1.25rem;">${cl.name}</h3> <h3 style="margin: 0 0 4px 0; color: var(--text-main); font-size: 1.25rem;">${escapeHtml(cl.name)}</h3>
<span style="font-size: 0.85rem; color: var(--text-muted);">${cl.student_count || 0} Schüler eingeschrieben</span> <span style="font-size: 0.85rem; color: var(--text-muted);">${cl.student_count || 0} Schüler eingeschrieben</span>
</div> </div>
<div style="text-align: right;"> <div style="text-align: right;">
<div style="font-size: 0.72rem; color: var(--text-muted); margin-bottom: 2px;">EINLADUNGSCODE:</div> <div style="font-size: 0.72rem; color: var(--text-muted); margin-bottom: 2px;">EINLADUNGSCODE:</div>
<div class="invite-code-box">${cl.invite_code}</div> <div class="invite-code-box">${escapeHtml(cl.invite_code)}</div>
</div> </div>
</div> </div>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem; margin-top: 10px;" onclick="loadClassMatrix(${cl.id}, '${cl.name}')">📊 Lernmatrix &amp; Fortschritt anzeigen</button> <div style="display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;">
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="loadClassMatrix(${cl.id}, '${escapeHtml(cl.name)}')">📊 Lernmatrix anzeigen</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="exportClassCsv(${cl.id}, '${escapeHtml(cl.name)}')">📥 CSV-Export</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger); border-color: var(--danger);" onclick="deleteClassroom(${cl.id}, '${escapeHtml(cl.name)}')">🗑️ Löschen</button>
</div>
<div id="matrix-container-${cl.id}" style="display: none; margin-top: 14px;"></div> <div id="matrix-container-${cl.id}" style="display: none; margin-top: 14px;"></div>
</div> </div>
`).join(""); `).join("");
@ -195,16 +219,18 @@
<th>XP</th> <th>XP</th>
<th>Streak</th> <th>Streak</th>
<th>Gelöste Kapitel</th> <th>Gelöste Kapitel</th>
<th>Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${matrix.map(m => ` ${matrix.map(m => `
<tr> <tr>
<td><strong>${m.student.name}</strong><br><span style="font-size: 0.75rem; color: #64748b;">${m.student.email}</span></td> <td><strong>${escapeHtml(m.student.name)}</strong><br><span style="font-size: 0.75rem; color: #64748b;">${escapeHtml(m.student.email)}</span></td>
<td>⭐ Level ${m.student.level}</td> <td>⭐ Level ${m.student.level}</td>
<td><span style="font-weight: 700; color: #0284c7;">${m.student.xp} XP</span></td> <td><span style="font-weight: 700; color: #0284c7;">${m.student.xp} XP</span></td>
<td>🔥 ${m.student.streak_days} Tage</td> <td>🔥 ${m.student.streak_days} Tage</td>
<td><span style="background: #ecfdf5; color: #059669; padding: 2px 8px; border-radius: 99px; font-weight: 700; font-size: 0.8rem;">${Object.values(m.solved_chapters).filter(Boolean).length} / 27</span></td> <td><span style="background: #ecfdf5; color: #059669; padding: 2px 8px; border-radius: 99px; font-weight: 700; font-size: 0.8rem;">${Object.values(m.solved_chapters).filter(Boolean).length} / 27</span></td>
<td><button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.75rem;" onclick="inspectStudentCode(${classId}, ${m.student.id}, '${escapeHtml(m.student.name)}')">🔍 Code ansehen</button></td>
</tr> </tr>
`).join("")} `).join("")}
</tbody> </tbody>
@ -215,6 +241,68 @@
} }
} }
async function inspectStudentCode(classId, studentId, studentName) {
const chapterId = prompt("Welches Kapitel möchtest du inspizieren? (z.B. lehrpfad_1_grundlagen/01_erste_schritte_taschenrechner)", "lehrpfad_1_grundlagen/01_erste_schritte_taschenrechner");
if (!chapterId) return;
const token = localStorage.getItem("auth_token");
try {
const res = await fetch(`/api/classrooms/student-code?classroom_id=${classId}&student_id=${studentId}&chapter_id=${encodeURIComponent(chapterId)}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
document.getElementById("inspector-title").innerText = `🔍 Code von ${studentName}`;
document.getElementById("inspector-meta").innerText = `Kapitel: ${chapterId} | Gelöst: ${data.progress && data.progress.is_solved ? '✅ Ja' : '❌ Nein'}`;
document.getElementById("inspector-code-content").innerText = (data.progress && data.progress.code_draft) ? data.progress.code_draft : "# Kein Code-Entwurf für dieses Kapitel vorhanden.";
document.getElementById("code-inspector-modal").style.display = "flex";
} catch (err) {
alert("Fehler beim Abrufen des Codes: " + err.message);
}
}
async function exportClassCsv(classId, className) {
const token = localStorage.getItem("auth_token");
try {
const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
const matrix = data.matrix || [];
let csv = "Name,E-Mail,Level,XP,Streak,Geloeste_Kapitel_Anzahl\n";
matrix.forEach(m => {
const solved = Object.values(m.solved_chapters).filter(Boolean).length;
csv += `"${m.student.name}","${m.student.email}",${m.student.level},${m.student.xp},${m.student.streak_days},${solved}\n`;
});
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Notenmatrix_${className.replace(/\s+/g, "_")}.csv`;
a.click();
} catch (e) {
alert("CSV-Export fehlgeschlagen: " + e.message);
}
}
async function deleteClassroom(classId, className) {
if (!confirm(`Möchtest du die Klasse '${className}' wirklich unwiderruflich löschen?`)) return;
const token = localStorage.getItem("auth_token");
try {
await fetch("/api/classrooms/delete", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ classroom_id: classId })
});
alert("Klasse gelöscht.");
loadClassrooms();
} catch (e) {
alert("Fehler beim Löschen: " + e.message);
}
}
document.getElementById("create-class-form").onsubmit = async (e) => { document.getElementById("create-class-form").onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const name = document.getElementById("input-class-name").value.trim(); const name = document.getElementById("input-class-name").value.trim();

View file

@ -17,7 +17,6 @@
flex-direction: column; flex-direction: column;
} }
/* Workspace Topbar */
.workspace-topbar { .workspace-topbar {
height: 52px; height: 52px;
background: #0f172a; background: #0f172a;
@ -31,7 +30,7 @@
} }
.workspace-title { .workspace-title {
font-size: 0.95rem; font-size: 0.92rem;
font-weight: 800; font-weight: 800;
color: #ffffff; color: #ffffff;
display: flex; display: flex;
@ -45,7 +44,6 @@
gap: 8px; gap: 8px;
} }
/* Workspace Split Screen Layout */
.workspace-body { .workspace-body {
flex: 1; flex: 1;
display: grid; display: grid;
@ -54,7 +52,6 @@
overflow: hidden; overflow: hidden;
} }
/* Left Pane: Theory & Tasks */
.pane-left { .pane-left {
background: #ffffff; background: #ffffff;
color: var(--text-main); color: var(--text-main);
@ -78,7 +75,7 @@
padding: 10px 14px; padding: 10px 14px;
border: none; border: none;
background: transparent; background: transparent;
font-size: 0.85rem; font-size: 0.84rem;
font-weight: 700; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
@ -99,10 +96,9 @@
.pane-content { .pane-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 24px; padding: 22px;
} }
/* Right Pane: Code Editor & Terminal */
.pane-right { .pane-right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -162,6 +158,17 @@
.term-warning { color: #fbbf24; font-weight: bold; } .term-warning { color: #fbbf24; font-weight: bold; }
.term-info { color: #94a3b8; } .term-info { color: #94a3b8; }
/* Hint Scaffolding Modal */
.hint-step {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: var(--radius-sm);
padding: 12px 16px;
margin-bottom: 10px;
}
.hint-step h4 { margin: 0 0 6px 0; font-size: 0.95rem; color: var(--text-main); }
.hint-step-content { font-size: 0.88rem; color: var(--text-muted); display: none; margin-top: 8px; }
/* Success Modal */ /* Success Modal */
.modal-celebration { .modal-celebration {
display: none; display: none;
@ -190,14 +197,14 @@
<div class="workspace-title"> <div class="workspace-title">
<a href="index.html" style="color: #94a3b8; text-decoration: none; font-size: 0.88rem;">🏠 Dashboard</a> <a href="index.html" style="color: #94a3b8; text-decoration: none; font-size: 0.88rem;">🏠 Dashboard</a>
<span style="color: #64748b;">/</span> <span style="color: #64748b;">/</span>
<span id="workspace-chapter-name">Lerneinheit wird geladen...</span> <span id="workspace-chapter-name">Lerneinheit...</span>
<span id="editor-save-indicator" style="font-size: 0.75rem; color: #10b981; opacity: 0.5; margin-left: 10px;">💾 Gespeichert</span> <span id="editor-save-indicator" style="font-size: 0.75rem; color: #10b981; opacity: 0.5; margin-left: 10px;">💾 Gespeichert</span>
</div> </div>
<div class="workspace-actions"> <div class="workspace-actions">
<button type="button" class="btn" id="btn-run-code" style="padding: 6px 14px; font-size: 0.84rem; background: #0284c7;">▶ Code ausführen</button> <button type="button" class="btn" id="btn-run-code" style="padding: 6px 14px; font-size: 0.84rem; background: #0284c7;" title="Tastenkürzel: Strg+Enter">▶ Code ausführen</button>
<button type="button" class="btn" id="btn-run-tests" style="padding: 6px 16px; font-size: 0.84rem; background: #10b981;">🧪 Tests starten</button> <button type="button" class="btn" id="btn-run-tests" style="padding: 6px 16px; font-size: 0.84rem; background: #10b981;" title="Tastenkürzel: Strg+Shift+Enter">🧪 Tests starten</button>
<button type="button" class="btn btn-secondary" id="btn-show-solution" style="padding: 6px 12px; font-size: 0.84rem; background: #1e293b; color: white; border: none;">💡 Lösung</button> <button type="button" class="btn btn-secondary" id="btn-show-hints" style="padding: 6px 12px; font-size: 0.84rem; background: #1e293b; color: white; border: none;">💡 Tipps &amp; Lösung</button>
<button type="button" class="btn btn-secondary" id="btn-reset-code" style="padding: 6px 10px; font-size: 0.84rem; background: transparent; color: #94a3b8; border: none;" title="Auf Startercode zurücksetzen">🔄</button> <button type="button" class="btn btn-secondary" id="btn-reset-code" style="padding: 6px 10px; font-size: 0.84rem; background: transparent; color: #94a3b8; border: none;" title="Auf Startercode zurücksetzen">🔄</button>
</div> </div>
</div> </div>
@ -220,18 +227,20 @@
<div class="pane-content" id="tab-content-subgoals" style="display: none;"> <div class="pane-content" id="tab-content-subgoals" style="display: none;">
<h3 style="margin-top: 0; font-size: 1.15rem; color: var(--text-main);">🎯 Nummerierte Teilziele (Subgoals)</h3> <h3 style="margin-top: 0; font-size: 1.15rem; color: var(--text-main);">🎯 Nummerierte Teilziele (Subgoals)</h3>
<p style="font-size: 0.9rem; color: var(--text-muted);">Folge diesen strukturierten Schritten in <code>aufgabe.py</code>:</p> <p style="font-size: 0.88rem; color: var(--text-muted);">Hake deine gelösten Schritte ab:</p>
<div id="subgoals-list-container"></div> <div id="subgoals-list-container"></div>
</div> </div>
<div class="pane-content" id="tab-content-puzzle" style="display: none;"> <div class="pane-content" id="tab-content-puzzle" style="display: none;">
<h3 style="margin-top: 0; font-size: 1.15rem; color: var(--text-main);">🧩 Parsons-Puzzle: Code ordnen</h3>
<p style="font-size: 0.88rem; color: var(--text-muted);">Bringe die Zeilen in die logisch richtige Reihenfolge, bevor du programmierst!</p>
<div id="puzzle-container"></div> <div id="puzzle-container"></div>
</div> </div>
<div class="pane-content" id="tab-content-interpreter" style="display: none;"> <div class="pane-content" id="tab-content-interpreter" style="display: none;">
<h3 style="margin-top: 0; font-size: 1.15rem; color: var(--text-main);">🩺 Python Fehler-Dolmetscher</h3> <h3 style="margin-top: 0; font-size: 1.15rem; color: var(--text-main);">🩺 Python Fehler-Dolmetscher</h3>
<p style="font-size: 0.88rem; color: var(--text-muted);">Füge eine Traceback-Fehlermeldung ein:</p> <p style="font-size: 0.88rem; color: var(--text-muted);">Füge eine Traceback-Fehlermeldung ein:</p>
<textarea id="workspace-error-input" style="width: 100%; height: 90px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 8px; font-family: var(--font-mono); font-size: 0.88rem;"></textarea> <textarea id="workspace-error-input" placeholder="Fehlermeldung hier einfügen..." style="width: 100%; height: 90px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 8px; font-family: var(--font-mono); font-size: 0.88rem;"></textarea>
<button type="button" class="btn" style="margin-top: 8px;" onclick="interpretWorkspaceError()">🔍 Übersetzen &amp; Erklären</button> <button type="button" class="btn" style="margin-top: 8px;" onclick="interpretWorkspaceError()">🔍 Übersetzen &amp; Erklären</button>
<div id="workspace-error-result" style="display: none; margin-top: 14px; padding: 12px; background: #f0fdf4; border-radius: var(--radius-sm); border: 1px solid #bbf7d0;"></div> <div id="workspace-error-result" style="display: none; margin-top: 14px; padding: 12px; background: #f0fdf4; border-radius: var(--radius-sm); border: 1px solid #bbf7d0;"></div>
</div> </div>
@ -248,12 +257,56 @@
<span>🖥️ Terminal / Test-Ausgabe</span> <span>🖥️ Terminal / Test-Ausgabe</span>
<button type="button" id="btn-clear-terminal" style="background: transparent; border: none; color: #94a3b8; font-size: 0.75rem; cursor: pointer;">🧹 Leeren</button> <button type="button" id="btn-clear-terminal" style="background: transparent; border: none; color: #94a3b8; font-size: 0.75rem; cursor: pointer;">🧹 Leeren</button>
</div> </div>
<div id="workspace-terminal-output">Klicke oben auf '▶ Code ausführen' oder '🧪 Tests starten'...</div> <div id="workspace-terminal-output">Klicke oben auf '▶ Code ausführen' (Strg+Enter) oder '🧪 Tests starten' (Strg+Shift+Enter)...</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Graduated Hint Scaffolding Modal -->
<div class="modal-celebration" id="hints-modal-overlay">
<div class="certificate-container" style="max-width: 540px; padding: 26px; text-align: left;">
<h3 style="margin-top: 0; color: var(--text-main); font-size: 1.3rem;">💡 4-Stufen-Hilfesystem</h3>
<p style="font-size: 0.88rem; color: var(--text-muted); margin-bottom: 16px;">Versuche zuerst Stufe 1 und 2, um die Lösung selbst zu erarbeiten:</p>
<div class="hint-step">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>1. 💡 Konzept &amp; Leitfrage</h4>
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 0.78rem;" onclick="toggleHint('hint-1')">Aufklappen</button>
</div>
<div id="hint-1" class="hint-step-content">Lies die Aufgabenstellung sorgfältig. Welche Ein- und Ausgabetypen verlangt die Funktion? Gibt es Randfälle wie leere Eingaben oder Division durch 0?</div>
</div>
<div class="hint-step">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>2. 🔧 Syntax &amp; Python-Befehle</h4>
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 0.78rem;" onclick="toggleHint('hint-2')">Aufklappen</button>
</div>
<div id="hint-2" class="hint-step-content">Verwende <code>return</code> am Ende deiner Funktionen. Bei Klassen denke an <code>def __init__(self, ...):</code> und nutze <code>self.attribut</code>.</div>
</div>
<div class="hint-step">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>3. 🧩 Struktur-Gerüst (Pseudocode)</h4>
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 0.78rem;" onclick="toggleHint('hint-3')">Aufklappen</button>
</div>
<div id="hint-3" class="hint-step-content"><pre id="hint-pseudocode" style="background:#0b0f19;color:#f8fafc;padding:10px;border-radius:4px;font-family:monospace;font-size:0.85rem;margin:0;"></pre></div>
</div>
<div class="hint-step" style="border-left: 3px solid var(--primary);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>4. 📖 Vollständige Musterlösung</h4>
<button class="btn btn-secondary" style="padding: 4px 10px; font-size: 0.78rem;" onclick="toggleHint('hint-4')">Lösung anzeigen</button>
</div>
<div id="hint-4" class="hint-step-content"><pre id="hint-solution" style="background:#0b0f19;color:#f8fafc;padding:10px;border-radius:4px;font-family:monospace;font-size:0.85rem;margin:0;max-height:200px;overflow-y:auto;"></pre></div>
</div>
<div style="text-align: right; margin-top: 14px;">
<button class="btn" onclick="document.getElementById('hints-modal-overlay').style.display = 'none';">Schließen</button>
</div>
</div>
</div>
<!-- Success Modal --> <!-- Success Modal -->
<div class="modal-celebration" id="success-modal-banner"> <div class="modal-celebration" id="success-modal-banner">
<div class="certificate-container" style="max-width: 480px; text-align: center; padding: 32px;"> <div class="certificate-container" style="max-width: 480px; text-align: center; padding: 32px;">
@ -277,7 +330,39 @@
<script src="assets/monaco_ide.js"></script> <script src="assets/monaco_ide.js"></script>
<script> <script>
// Tab Switcher // Manifest aller Kapitel für sequentielle Navigation
const CHAPTERS_MANIFEST = [
{ track: "lehrpfad_1_grundlagen", folder: "01_erste_schritte_taschenrechner", title: "G01. Taschenrechner" },
{ track: "lehrpfad_1_grundlagen", folder: "02_variablen_und_datentypen", title: "G02. Variablen & Typen" },
{ track: "lehrpfad_1_grundlagen", folder: "03_ein_und_ausgabe", title: "G03. Ein- & Ausgabe" },
{ track: "lehrpfad_1_grundlagen", folder: "04_verzweigungen_und_bedingungen", title: "G04. Bedingungen" },
{ track: "lehrpfad_1_grundlagen", folder: "05_schleifen_und_wiederholungen", title: "G05. Schleifen" },
{ track: "lehrpfad_1_grundlagen", folder: "06_funktionen_und_module", title: "G06. Funktionen & Module" },
{ track: "lehrpfad_1_grundlagen", folder: "07_listen_und_sequenzen", title: "G07. Listen" },
{ track: "lehrpfad_1_grundlagen", folder: "08_textverarbeitung_und_strings", title: "G08. Strings" },
{ track: "lehrpfad_1_grundlagen", folder: "09_dictionaries_und_sets", title: "G09. Dicts & Sets" },
{ track: "lehrpfad_1_grundlagen", folder: "10_comprehensions_datum_algorithmen", title: "G10. Comprehensions" },
{ track: "lehrpfad_2_oop_einstieg", folder: "00_fehlersuche_und_grundlagen", title: "OOP 00. Debugging" },
{ track: "lehrpfad_2_oop_einstieg", folder: "01_einstieg_klassen", title: "OOP 01. Klassen" },
{ track: "lehrpfad_2_oop_einstieg", folder: "02_init_und_self", title: "OOP 02. Konstruktor" },
{ track: "lehrpfad_2_oop_einstieg", folder: "03_methoden_und_verhalten", title: "OOP 03. Methoden" },
{ track: "lehrpfad_2_oop_einstieg", folder: "04_str_und_darstellung", title: "OOP 04. __str__" },
{ track: "lehrpfad_2_oop_einstieg", folder: "04b_umstieg_vscode", title: "Tooling 04b. VS Code" },
{ track: "lehrpfad_2_oop_einstieg", folder: "04c_git_und_versionskontrolle", title: "Tooling 04c. Git" },
{ track: "lehrpfad_2_oop_einstieg", folder: "05_objekte_kombinieren", title: "OOP 05. Komposition" },
{ track: "lehrpfad_2_oop_einstieg", folder: "06_abschlussprojekt_tamagotchi", title: "Projekt 06. Tamagotchi" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "07_referenzen_und_speicher", title: "Adv 07. Speicher" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "08_operator_overloading_dunder", title: "Adv 08. Dunder" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "09_eigene_unit_tests_schreiben", title: "Adv 09. TDD Tests" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "10_vererbung_und_super", title: "Adv 10. Vererbung" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "11_polymorphie_und_interfaces", title: "Adv 11. Polymorphie" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "12_exceptions_und_fehlerbehandlung", title: "Adv 12. Exceptions" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "13_persistenz_json_und_csv", title: "Adv 13. Persistenz" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "14_gui_mit_tkinter", title: "Adv 14. Tkinter" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "15_parameter_und_container", title: "Adv 15. Container" },
{ track: "lehrpfad_3_fortgeschrittenes_oop", folder: "16_master_abschlussprojekt", title: "Master 16. Abschlussprojekt" }
];
function switchTab(tabId) { function switchTab(tabId) {
document.querySelectorAll(".pane-tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".pane-tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".pane-content").forEach(c => c.style.display = "none"); document.querySelectorAll(".pane-content").forEach(c => c.style.display = "none");
@ -288,55 +373,115 @@
if (targetContent) targetContent.style.display = "block"; if (targetContent) targetContent.style.display = "block";
} }
// URL Parameters function toggleHint(id) {
const el = document.getElementById(id);
if (el) el.style.display = el.style.display === "block" ? "none" : "block";
}
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const trackFolder = urlParams.get("track") || "lehrpfad_1_grundlagen"; const trackFolder = urlParams.get("track") || "lehrpfad_1_grundlagen";
const chapterFolder = urlParams.get("chapter") || "01_erste_schritte_taschenrechner"; const chapterFolder = urlParams.get("chapter") || "01_erste_schritte_taschenrechner";
const chapterId = `${trackFolder}/${chapterFolder}`; const chapterId = `${trackFolder}/${chapterFolder}`;
// Load Chapter Files // Nächstes Kapitel ermitteln
const currentIndex = CHAPTERS_MANIFEST.findIndex(c => c.folder === chapterFolder);
const nextChapter = (currentIndex >= 0 && currentIndex < CHAPTERS_MANIFEST.length - 1) ? CHAPTERS_MANIFEST[currentIndex + 1] : null;
document.getElementById("btn-next-chapter")?.addEventListener("click", () => {
if (nextChapter) {
window.location.href = `workspace.html?track=${nextChapter.track}&chapter=${nextChapter.folder}`;
} else {
window.location.href = "index.html#zertifikate";
}
});
async function loadWorkspaceData() { async function loadWorkspaceData() {
try { try {
const [aufgabeCode, testCode, loesungCode, htmlDoc] = await Promise.all([ let taskFile = "aufgabe.py";
fetch(`${trackFolder}/${chapterFolder}/aufgabe.py`).then(r => r.text()), let testFile = "test_aufgabe.py";
fetch(`${trackFolder}/${chapterFolder}/test_aufgabe.py`).then(r => r.text()),
fetch(`${trackFolder}/${chapterFolder}/musterloesung.py`).then(r => r.text()).catch(() => ""), if (chapterFolder === "04b_umstieg_vscode") {
fetch(`${trackFolder}/${chapterFolder}/index.html`).then(r => r.text()).catch(() => "") taskFile = "vscode_test.py";
]); testFile = "";
} else if (chapterFolder === "04c_git_und_versionskontrolle") {
taskFile = "git_uebung.py";
testFile = "";
}
const promises = [
fetch(`${trackFolder}/${chapterFolder}/${taskFile}`).then(r => r.ok ? r.text() : "# Aufgabe konnte nicht geladen werden"),
testFile ? fetch(`${trackFolder}/${chapterFolder}/${testFile}`).then(r => r.ok ? r.text() : "") : Promise.resolve(""),
fetch(`${trackFolder}/${chapterFolder}/musterloesung.py`).then(r => r.ok ? r.text() : "").catch(() => ""),
fetch(`${trackFolder}/${chapterFolder}/index.html`).then(r => r.ok ? r.text() : "").catch(() => "")
];
const [aufgabeCode, testCode, loesungCode, htmlDoc] = await Promise.all(promises);
document.getElementById("workspace-chapter-name").innerText = chapterFolder.replace(/_/g, " ").toUpperCase(); document.getElementById("workspace-chapter-name").innerText = chapterFolder.replace(/_/g, " ").toUpperCase();
// Extract theory content from chapter index.html // 1. Theorie-Inhalt
if (htmlDoc) { if (htmlDoc) {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(htmlDoc, "text/html"); const doc = parser.parseFromString(htmlDoc, "text/html");
const mainContent = doc.querySelector("main.container") || doc.body; const mainContent = doc.querySelector("main.container") || doc.body;
document.getElementById("tab-content-theorie").innerHTML = mainContent.innerHTML; document.getElementById("tab-content-theorie").innerHTML = mainContent.innerHTML;
}
// Extract Subgoals // 2. Subgoals (Exakte Regex ohne Duplikate)
const subgoals = aufgabeCode.match(/# 🎯 TEILZIEL.*$/gm) || []; const subgoals = aufgabeCode.match(/^# 🎯 TEILZIEL \d+ \(TODO \d+\): (.+)$/gm) || [];
const subContainer = document.getElementById("subgoals-list-container"); const subContainer = document.getElementById("subgoals-list-container");
const savedSubgoals = JSON.parse(localStorage.getItem(`subgoals_${chapterId}`) || "{}");
if (subgoals.length > 0) { if (subgoals.length > 0) {
subContainer.innerHTML = subgoals.map((s, idx) => ` subContainer.innerHTML = subgoals.map((s, idx) => `
<div class="task-item" style="margin-bottom: 8px;"> <div class="task-item" style="margin-bottom: 8px;">
<input type="checkbox" class="task-checkbox" id="sub_check_${idx}"> <input type="checkbox" class="task-checkbox" id="sub_check_${idx}" ${savedSubgoals[idx] ? 'checked' : ''}>
<label class="task-label" for="sub_check_${idx}"><strong>${s.replace('#', '').trim()}</strong></label> <label class="task-label" for="sub_check_${idx}"><strong>${s.replace(/^#\s*/, '')}</strong></label>
</div> </div>
`).join(""); `).join("");
subContainer.querySelectorAll(".task-checkbox").forEach((cb, idx) => {
cb.addEventListener("change", () => {
savedSubgoals[idx] = cb.checked;
localStorage.setItem(`subgoals_${chapterId}`, JSON.stringify(savedSubgoals));
});
});
} else { } else {
subContainer.innerHTML = "<p style='color: var(--text-muted);'>Keine nummerierten Teilziele vorhanden.</p>"; subContainer.innerHTML = "<p style='color: var(--text-muted); font-size: 0.88rem;'>Keine nummerierten Teilziele in dieser Einheit.</p>";
}
// 3. Dynamisches Parsons-Puzzle generieren
const puzzleContainer = document.getElementById("puzzle-container");
if (loesungCode && puzzleContainer) {
const lines = loesungCode
.split("\n")
.filter(l => l.trim().length > 0 && !l.trim().startsWith("#") && !l.trim().startsWith('"""'))
.slice(0, 8);
if (lines.length >= 3) {
puzzleContainer.innerHTML = `
<div class="parsons-puzzle" data-lines='${JSON.stringify(lines)}' data-solution='${JSON.stringify(lines)}' data-xp="25">
<div class="parsons-target-area" style="min-height: 120px; background: #0b0f19; padding: 10px; border-radius: 4px; margin-bottom: 10px; font-family: monospace; font-size: 0.85rem; color: #f8fafc;">
<div style="color: #64748b; font-size: 0.8rem;">Klicke auf die Zeilen unten, um sie in die richtige Reihenfolge zu bringen:</div>
</div>
<div class="parsons-source-area" style="display: flex; flex-direction: column; gap: 6px;"></div>
<button class="btn" style="margin-top: 12px; font-size: 0.82rem;" onclick="checkParsonsPuzzle()">🧩 Puzzle prüfen (+25 XP)</button>
<div class="parsons-feedback" style="margin-top: 8px; font-size: 0.85rem; display: none;"></div>
</div>
`;
initDynamicParsons(lines);
} }
} }
// Musterlösung Button // 4. Hints Modal befüllen
document.getElementById("btn-show-solution")?.addEventListener("click", () => { document.getElementById("hint-solution").innerText = loesungCode || "# Keine Musterlösung verfügbar";
if (confirm("Möchtest du einen Blick in die Musterlösung werfen? Versuche zuerst, es selbst zu lösen!")) { document.getElementById("hint-pseudocode").innerText = aufgabeCode.replace(/def [a-zA-Z0-9_]+\([^)]*\):[\s\S]*?return[^\n]*/g, "def ...:\n # Logik hier\n return ergebnis");
const w = window.open("", "_blank");
w.document.write(`<pre style="background:#0b0f19;color:#f8fafc;padding:20px;font-family:monospace;font-size:14px;">${loesungCode}</pre>`); document.getElementById("btn-show-hints")?.addEventListener("click", () => {
} document.getElementById("hints-modal-overlay").style.display = "flex";
}); });
// Initialize Monaco IDE // 5. Monaco IDE initialisieren
window.initWorkspace({ window.initWorkspace({
chapterId: chapterId, chapterId: chapterId,
starterCode: aufgabeCode, starterCode: aufgabeCode,
@ -345,26 +490,73 @@
}); });
} catch (err) { } catch (err) {
document.getElementById("tab-content-theorie").innerHTML = `<div class="box box-danger">❌ Fehler beim Laden der Einheit: ${err.message}</div>`; document.getElementById("tab-content-theorie").innerHTML = `<div class="box box-danger">Fehler beim Laden: ${err.message}</div>`;
} }
} }
function interpretWorkspaceError() { function initDynamicParsons(correctLines) {
const sourceArea = document.querySelector(".parsons-source-area");
const targetArea = document.querySelector(".parsons-target-area");
if (!sourceArea || !targetArea) return;
const shuffled = [...correctLines].sort(() => Math.random() - 0.5);
sourceArea.innerHTML = "";
shuffled.forEach((line) => {
const item = document.createElement("div");
item.className = "parsons-line";
item.style.cssText = "background: #f1f5f9; color: #1e293b; padding: 6px 10px; border-radius: 4px; border: 1px solid #cbd5e1; cursor: pointer; font-family: monospace; font-size: 0.85rem;";
item.innerText = line;
item.onclick = () => {
if (item.parentElement === sourceArea) {
targetArea.appendChild(item);
item.style.background = "#1e293b";
item.style.color = "#38bdf8";
} else {
sourceArea.appendChild(item);
item.style.background = "#f1f5f9";
item.style.color = "#1e293b";
}
};
sourceArea.appendChild(item);
});
}
function checkParsonsPuzzle() {
const targetArea = document.querySelector(".parsons-target-area");
const feedback = document.querySelector(".parsons-feedback");
const selected = Array.from(targetArea.querySelectorAll(".parsons-line")).map(el => el.innerText);
if (selected.length === 0) {
feedback.style.display = "block";
feedback.style.color = "var(--danger)";
feedback.innerText = "Klicke zuerst die Codezeilen an!";
return;
}
feedback.style.display = "block";
feedback.style.color = "var(--success)";
feedback.innerHTML = "🎉 Großartig! Puzzle korrekt gelöst (+25 XP)!";
if (window.addXP) window.addXP(25, chapterId + "_puzzle");
}
window.interpretWorkspaceError = function () {
const errText = document.getElementById("workspace-error-input").value; const errText = document.getElementById("workspace-error-input").value;
const resContainer = document.getElementById("workspace-error-result"); const resContainer = document.getElementById("workspace-error-result");
if (window.INTERPRETER && window.INTERPRETER.translate) { if (window.INTERPRETER && window.INTERPRETER.translate) {
const info = window.INTERPRETER.translate(errText); const info = window.INTERPRETER.translate(errText);
resContainer.style.display = "block"; resContainer.style.display = "block";
resContainer.innerHTML = ` resContainer.innerHTML = `
<h4 style="margin-top: 0; color: #166534;">${info.name}</h4> <h4 style="margin-top: 0; color: #166534;">${info.title || info.name}</h4>
<p style="margin: 6px 0; color: #1e293b;">${info.explanation}</p> <p style="margin: 6px 0; color: #1e293b;">${info.explanation}</p>
<strong>Tipps:</strong> <strong>Tipps:</strong>
<ul style="padding-left: 20px; margin-top: 4px;"> <ul style="padding-left: 20px; margin-top: 4px;">
${info.fixes.map(f => `<li>${f}</li>`).join("")} ${(info.causes || []).map(c => `<li>${c}</li>`).join("")}
<li><strong>Reparatur:</strong> ${info.fix}</li>
</ul> </ul>
`; `;
} }
} };
document.addEventListener("DOMContentLoaded", loadWorkspaceData); document.addEventListener("DOMContentLoaded", loadWorkspaceData);
</script> </script>