ff889865e1
Teil 1 — Schueler-Lizenz-Gate (gilt fuer alle Klassen-Schueler ohne gueltige Lizenz = zugewiesen, nicht storniert, nicht abgelaufen): - Server-Gate zentral im Front-Controller (index.php + php/lib/license_gate.php): eingeloggte Klassen-Schueler ohne Lizenz sehen bei JEDER Sim-Route statt der Simulation eine Hinweis-Seite "Deine Lehrperson muss dir noch eine gueltige Lizenz zuweisen". Schnell-Ausstieg fuer Nicht-Schueler/Nicht-Sim (kein Cookie -> keine DB-Abfrage). Autodidakt ohne Klasse: nicht lizenzpflichtig. - modules.php?student=1 liefert 'licensed'; Schueler-Cockpit (schueler.html) zeigt oben die Meldung und sperrt die Start-Buttons. Teil 2 — Lehrer-Cockpit: - Rote Warnleiste ganz oben, wenn Schueler:innen ohne gueltige Lizenz in der Klasse sind (+ Link "Jetzt zuweisen"). licenses?class_id=X liefert dazu die Liste 'unlicensed'. - Lizenzen-Lasche: neue Sektion "Ohne gueltige Lizenz" mit Einzel- und Sammel-Zuweisung (auto_assign) — sonst fuehrt die Warnung ins Leere. Kunden-Vereinfachung (Feedback): keine Auto-Erkennung/Dedup mehr (Ansprech- partner koennen wechseln). Kunde = Freitext "Name/Verlag/Schule" + Mailadresse, je Bestellung gespeichert. Editierbares Kundennummer-Feld + Autocomplete entfernt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
342 lines
15 KiB
PHP
342 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* API: Modulfreigabe (Klasse + individuell pro Schüler)
|
||
* GET /api/modules?class_id=X → Modulstatus für Klasse
|
||
* GET /api/modules?class_id=X&matrix=1 → Matrix: alle Schüler × alle Module
|
||
* POST /api/modules {action} → Modul freigeben/sperren
|
||
*
|
||
* Bootstrap: idempotent, damit auch direkter /php/api/modules.php-Aufruf funktioniert
|
||
* (live-client.js pollt von der Sim aus diesen Direkt-Pfad).
|
||
*/
|
||
require_once __DIR__ . '/../config/app.php';
|
||
require_once __DIR__ . '/../lib/Database.php';
|
||
require_once __DIR__ . '/../lib/Session.php';
|
||
require_once __DIR__ . '/../lib/Response.php';
|
||
|
||
$method = $_SERVER['REQUEST_METHOD'];
|
||
$db = Database::get();
|
||
|
||
// Default-Modus für eine Klasse OHNE expliziten Eintrag: eine neue Klasse startet
|
||
// mit NUR Weltküche frei (Einstiegs-Sim), alles andere gesperrt. Die Lehrperson
|
||
// gibt weitere Module über die Freigabe-Matrix frei.
|
||
function _defaultMode($moduleId) { return $moduleId === 'weltkueche' ? 'free' : 'locked'; }
|
||
|
||
// Modul-Katalog dynamisch aus module_info (Source of Truth)
|
||
// Nur aktive + beta + geplante Module anzeigen (keine archivierten)
|
||
$ALL_MODULES = $db->fetchAll(
|
||
"SELECT module_id AS id, title AS name, short_desc AS `desc`, icon,
|
||
card_image, status, play_url, sort_order, available_from
|
||
FROM module_info
|
||
WHERE status IN ('aktiv','beta','geplant')
|
||
ORDER BY sort_order, title"
|
||
);
|
||
|
||
if ($method === 'GET') {
|
||
// Schüler*innen-Ansicht: welche Module sind für mich freigeschaltet?
|
||
if (isset($_GET['student']) && $_GET['student'] === '1') {
|
||
$sessionId = Session::requireStudent();
|
||
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
|
||
if (!$session || !$session['class_id']) {
|
||
// Kein Klasse = alle Module frei (Autodidakt)
|
||
$result = [];
|
||
foreach ($ALL_MODULES as $mod) {
|
||
$result[] = [
|
||
'id' => $mod['id'],
|
||
'name' => $mod['name'],
|
||
'desc' => $mod['desc'],
|
||
'icon' => $mod['icon'],
|
||
'card_image' => $mod['card_image'],
|
||
'status' => $mod['status'],
|
||
'play_url' => $mod['play_url'],
|
||
'available_from' => $mod['available_from'],
|
||
'mode' => $mod['status'] === 'geplant' ? 'coming_soon' : 'free',
|
||
'licensed' => true, // Autodidakt ohne Klasse: nicht lizenzpflichtig
|
||
];
|
||
}
|
||
Response::ok($result);
|
||
}
|
||
$classId = $session['class_id'];
|
||
$classMap = [];
|
||
$settings = $db->fetchAll(
|
||
'SELECT module_id, mode, current_level, quiz_enabled, audio_info, due_date, started_at, paused
|
||
FROM class_modules WHERE class_id = ?', [$classId]);
|
||
foreach ($settings as $s) $classMap[$s['module_id']] = $s;
|
||
|
||
// Pro Sim den letzten Abschluss-Zeitpunkt des Schülers laden (assessments).
|
||
// Wird gegen class_modules.started_at verglichen, um teacher_started-
|
||
// Aufträge als "erledigt" zu markieren, sobald der Schüler nach dem
|
||
// Auftrag-Start einmal abgeschlossen hat.
|
||
$lastDone = [];
|
||
$rows = $db->fetchAll(
|
||
'SELECT sim_id, MAX(submitted_at) AS last_at FROM assessments WHERE session_id = ? GROUP BY sim_id',
|
||
[$sessionId]
|
||
);
|
||
foreach ($rows as $r) $lastDone[$r['sim_id']] = $r['last_at'];
|
||
|
||
// Individuelle Overrides für diesen Schüler
|
||
$studentRow = $db->fetchOne(
|
||
'SELECT student_id AS id FROM student_sessions WHERE id = ?',
|
||
[$sessionId]
|
||
);
|
||
$overrides = [];
|
||
if ($studentRow) {
|
||
$rows = $db->fetchAll('SELECT module_id, mode FROM student_modules WHERE student_id = ?', [$studentRow['id']]);
|
||
foreach ($rows as $r) $overrides[$r['module_id']] = $r['mode'];
|
||
}
|
||
|
||
// Lizenz-Status des Schülers (fürs Cockpit-Gate). Gültig = zugewiesen,
|
||
// nicht storniert, nicht abgelaufen (valid_until >= heute; NULL = Alt-Code).
|
||
$licensed = false;
|
||
if ($studentRow && !empty($studentRow['id'])) {
|
||
$lic = $db->fetchOne(
|
||
"SELECT 1 AS ok FROM licenses WHERE student_id = ? AND canceled_at IS NULL
|
||
AND (valid_until IS NULL OR valid_until >= CURDATE()) LIMIT 1",
|
||
[$studentRow['id']]);
|
||
$licensed = (bool)$lic;
|
||
}
|
||
|
||
// Einzelner Modul-Filter
|
||
$filterId = $_GET['module_id'] ?? null;
|
||
|
||
$result = [];
|
||
foreach ($ALL_MODULES as $mod) {
|
||
if ($filterId && $mod['id'] !== $filterId) continue;
|
||
$cm = $classMap[$mod['id']] ?? null;
|
||
$mode = $overrides[$mod['id']] ?? ($cm['mode'] ?? _defaultMode($mod['id']));
|
||
// 'geplant' überschreibt jeden mode-Eintrag — Modul ist nicht spielbar.
|
||
if ($mod['status'] === 'geplant') $mode = 'coming_soon';
|
||
|
||
// Lehrer-Auftrag erledigt? (assessment-submit nach class_modules.started_at)
|
||
$assignmentCompleted = false;
|
||
if ($mode === 'teacher_started' && $cm && !empty($cm['started_at'])) {
|
||
$done = $lastDone[$mod['id']] ?? null;
|
||
if ($done && $done > $cm['started_at']) $assignmentCompleted = true;
|
||
}
|
||
|
||
$result[] = [
|
||
'id' => $mod['id'],
|
||
'name' => $mod['name'],
|
||
'desc' => $mod['desc'],
|
||
'icon' => $mod['icon'],
|
||
'card_image' => $mod['card_image'],
|
||
'status' => $mod['status'],
|
||
'play_url' => $mod['play_url'],
|
||
'available_from' => $mod['available_from'],
|
||
'mode' => $mode,
|
||
'forcedLevel' => ($mode === 'teacher_started' && $cm) ? (int)($cm['current_level'] ?? 1) : null,
|
||
'quizEnabled' => (bool)($cm['quiz_enabled'] ?? 0),
|
||
'audioInfo' => (bool)($cm['audio_info'] ?? 1),
|
||
'dueDate' => $cm['due_date'] ?? null,
|
||
'paused' => (bool)($cm['paused'] ?? 0),
|
||
'assignmentCompleted' => $assignmentCompleted,
|
||
'licensed' => $licensed,
|
||
];
|
||
}
|
||
// Wenn einzelnes Modul, liefere Objekt statt Array
|
||
if ($filterId) {
|
||
Response::ok($result[0] ?? ['id' => $filterId, 'mode' => 'locked', 'error' => 'not_found']);
|
||
}
|
||
Response::ok($result);
|
||
}
|
||
|
||
$teacherId = Session::requireTeacher();
|
||
$classId = (int)($_GET['class_id'] ?? 0);
|
||
if (!$classId) Response::error('class_id erforderlich');
|
||
|
||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||
|
||
// Klassen-Defaults laden (inkl. Level + Pause-Status für die Lehrer-UI)
|
||
$settings = $db->fetchAll(
|
||
'SELECT module_id, mode, current_level, paused, audio_info, due_date, started_at FROM class_modules WHERE class_id = ?',
|
||
[$classId]
|
||
);
|
||
$classMap = [];
|
||
$classMeta = [];
|
||
foreach ($settings as $s) {
|
||
$classMap[$s['module_id']] = $s['mode'];
|
||
$classMeta[$s['module_id']] = $s;
|
||
}
|
||
|
||
// Matrix-Modus: alle Schüler × alle Module
|
||
if (isset($_GET['matrix']) && $_GET['matrix'] === '1') {
|
||
$students = $db->fetchAll(
|
||
'SELECT id, username, display_name, emoji_avatar FROM students WHERE class_id = ? ORDER BY username',
|
||
[$classId]
|
||
);
|
||
|
||
// Individuelle Overrides laden
|
||
$studentIds = array_column($students, 'id');
|
||
$overrides = [];
|
||
if (!empty($studentIds)) {
|
||
$placeholders = implode(',', array_fill(0, count($studentIds), '?'));
|
||
$rows = $db->fetchAll(
|
||
"SELECT student_id, module_id, mode FROM student_modules WHERE student_id IN ($placeholders)",
|
||
$studentIds
|
||
);
|
||
foreach ($rows as $r) {
|
||
$overrides[$r['student_id']][$r['module_id']] = $r['mode'];
|
||
}
|
||
}
|
||
|
||
// Matrix aufbauen
|
||
$matrix = [];
|
||
foreach ($students as $s) {
|
||
$row = [
|
||
'id' => (int)$s['id'],
|
||
'username' => $s['username'],
|
||
'displayName' => $s['display_name'] ?: $s['username'],
|
||
'emoji' => $s['emoji_avatar'] ?: '🧑🎓',
|
||
'modules' => [],
|
||
];
|
||
foreach ($ALL_MODULES as $mod) {
|
||
$row['modules'][$mod['id']] = $overrides[$s['id']][$mod['id']] ?? $classMap[$mod['id']] ?? _defaultMode($mod['id']);
|
||
}
|
||
$matrix[] = $row;
|
||
}
|
||
|
||
// Modul-Infos mit Klassen-Defaults
|
||
$modulesInfo = [];
|
||
foreach ($ALL_MODULES as $mod) {
|
||
$meta = $classMeta[$mod['id']] ?? null;
|
||
$modulesInfo[] = [
|
||
'id' => $mod['id'],
|
||
'name' => $mod['name'],
|
||
'desc' => $mod['desc'],
|
||
'icon' => $mod['icon'],
|
||
'card_image' => $mod['card_image'],
|
||
'status' => $mod['status'],
|
||
'available_from' => $mod['available_from'],
|
||
'classMode' => $classMap[$mod['id']] ?? _defaultMode($mod['id']),
|
||
'currentLevel' => $meta ? (int)($meta['current_level'] ?? 1) : 1,
|
||
'paused' => $meta ? (bool)($meta['paused'] ?? 0) : false,
|
||
'audioInfo' => $meta ? (bool)($meta['audio_info'] ?? 1) : true,
|
||
'dueDate' => $meta['due_date'] ?? null,
|
||
'startedAt' => $meta['started_at'] ?? null,
|
||
];
|
||
}
|
||
|
||
Response::ok(['modules' => $modulesInfo, 'students' => $matrix]);
|
||
}
|
||
|
||
// Standard: nur Klassen-Defaults
|
||
$result = [];
|
||
foreach ($ALL_MODULES as $mod) {
|
||
$result[] = [
|
||
'id' => $mod['id'],
|
||
'name' => $mod['name'],
|
||
'desc' => $mod['desc'],
|
||
'icon' => $mod['icon'],
|
||
'status' => $mod['status'],
|
||
'mode' => $classMap[$mod['id']] ?? _defaultMode($mod['id']),
|
||
];
|
||
}
|
||
Response::ok($result);
|
||
}
|
||
|
||
if ($method === 'POST') {
|
||
$teacherId = Session::requireTeacher();
|
||
$body = json_decode(file_get_contents('php://input'), true);
|
||
$action = $body['action'] ?? '';
|
||
$classId = (int)($body['classId'] ?? 0);
|
||
$moduleId = $body['moduleId'] ?? '';
|
||
|
||
if (!$classId || !$moduleId) Response::error('classId und moduleId erforderlich');
|
||
|
||
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
|
||
if (!$class) Response::error('Klasse nicht gefunden', 404);
|
||
|
||
// Schutz: 'geplant'-Module dürfen NICHT freigeschaltet werden — sie sind
|
||
// im Lehrer-UI auch nur als 'In Vorbereitung' sichtbar.
|
||
$modStatus = $db->fetchOne('SELECT status FROM module_info WHERE module_id = ?', [$moduleId]);
|
||
if (!$modStatus || $modStatus['status'] === 'geplant' || $modStatus['status'] === 'archiv') {
|
||
Response::error('Modul ist noch in Vorbereitung und kann nicht freigeschaltet werden.');
|
||
}
|
||
|
||
// Klassen-Default setzen
|
||
if ($action === 'set_mode') {
|
||
$mode = $body['mode'] ?? 'locked';
|
||
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
|
||
$level = isset($body['currentLevel']) ? max(1, min(3, (int)$body['currentLevel'])) : 1;
|
||
$dueDate = !empty($body['dueDate']) ? $body['dueDate'] : null;
|
||
$paused = !empty($body['paused']) ? 1 : 0;
|
||
|
||
$db->execute(
|
||
'INSERT INTO class_modules (class_id, module_id, enabled, mode, current_level, due_date, paused)
|
||
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE mode = VALUES(mode), enabled = 1,
|
||
current_level = VALUES(current_level), due_date = VALUES(due_date), paused = VALUES(paused)',
|
||
[$classId, $moduleId, $mode, $level, $dueDate, $paused]
|
||
);
|
||
// Bei "teacher_started" started_at frisch setzen — markiert Auftragsstart
|
||
// und macht alte assessments unter dieser started_at zu "vorher".
|
||
if ($mode === 'teacher_started') {
|
||
$db->execute(
|
||
'UPDATE class_modules SET started_at = NOW() WHERE class_id = ? AND module_id = ?',
|
||
[$classId, $moduleId]
|
||
);
|
||
} else {
|
||
$db->execute(
|
||
'UPDATE class_modules SET started_at = NULL, paused = 0 WHERE class_id = ? AND module_id = ?',
|
||
[$classId, $moduleId]
|
||
);
|
||
}
|
||
Response::ok();
|
||
}
|
||
|
||
// Vorlese-Modus (Audio-Info) für die Klasse erlauben/sperren
|
||
if ($action === 'set_audio_info') {
|
||
$on = !empty($body['enabled']) ? 1 : 0;
|
||
$db->execute(
|
||
'INSERT INTO class_modules (class_id, module_id, enabled, audio_info)
|
||
VALUES (?, ?, 1, ?)
|
||
ON DUPLICATE KEY UPDATE audio_info = VALUES(audio_info)',
|
||
[$classId, $moduleId, $on]
|
||
);
|
||
Response::ok();
|
||
}
|
||
|
||
// Auftrag pausieren / fortsetzen
|
||
if ($action === 'pause' || $action === 'resume') {
|
||
$paused = $action === 'pause' ? 1 : 0;
|
||
$db->execute(
|
||
'UPDATE class_modules SET paused = ? WHERE class_id = ? AND module_id = ?',
|
||
[$paused, $classId, $moduleId]
|
||
);
|
||
Response::ok();
|
||
}
|
||
|
||
// Individuell pro Schüler setzen
|
||
if ($action === 'set_student_mode') {
|
||
$studentId = (int)($body['studentId'] ?? 0);
|
||
$mode = $body['mode'] ?? 'locked';
|
||
if (!$studentId) Response::error('studentId erforderlich');
|
||
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
|
||
|
||
// Prüfen dass Schüler zur Klasse gehört
|
||
$student = $db->fetchOne('SELECT id FROM students WHERE id = ? AND class_id = ?', [$studentId, $classId]);
|
||
if (!$student) Response::error('Schüler nicht in dieser Klasse');
|
||
|
||
$db->execute(
|
||
'INSERT INTO student_modules (student_id, module_id, mode)
|
||
VALUES (?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE mode = VALUES(mode)',
|
||
[$studentId, $moduleId, $mode]
|
||
);
|
||
Response::ok();
|
||
}
|
||
|
||
// Klassen-Default für ALLE Schüler übernehmen (reset overrides)
|
||
if ($action === 'reset_overrides') {
|
||
$db->execute(
|
||
'DELETE sm FROM student_modules sm JOIN students s ON s.id = sm.student_id WHERE s.class_id = ? AND sm.module_id = ?',
|
||
[$classId, $moduleId]
|
||
);
|
||
Response::ok();
|
||
}
|
||
|
||
Response::error('Unbekannte Aktion');
|
||
}
|
||
|
||
Response::error('Methode nicht erlaubt', 405);
|