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:
parent
759e64666a
commit
698e6625ac
11 changed files with 981 additions and 357 deletions
|
|
@ -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
20
.htaccess
Normal 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
136
api.php
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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} • <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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
// 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
312
server.py
|
|
@ -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()
|
||||||
|
|
|
||||||
98
teacher.html
98
teacher.html
|
|
@ -75,7 +75,7 @@
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<div class="header-badge">👨🏫 Dozenten- & Lehrer-Portal</div>
|
<div class="header-badge">👨🏫 Dozenten- & Lehrer-Portal</div>
|
||||||
<h1>Klassenverwaltung & Lernfortschritts-Matrix</h1>
|
<h1>Klassenverwaltung & 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
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 & 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();
|
||||||
|
|
|
||||||
274
workspace.html
274
workspace.html
|
|
@ -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 & 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 & Erklären</button>
|
<button type="button" class="btn" style="margin-top: 8px;" onclick="interpretWorkspaceError()">🔍 Übersetzen & 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 & 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 & 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue