it-praxisportal/server.py

486 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
🐍 PYTHON LERNPLATTFORM – BACKEND SERVER 🐍
===========================================
Leichtgewichtiger, abhängigkeitsfreier REST-API & Static Server für die Online-Lernplattform.
- Läuft auf jedem Linux-Server / Webspace mit Standard-Python 3.
- SQLite-Datenbank (User-Accounts, Code-Drafts, XP, Klassen & Lehrer-Dashboard).
- PBKDF2-HMAC-SHA256 Passwort-Verschlüsselung & signierte Session-Tokens.
"""
import http.server
import socketserver
import json
import sqlite3
import hashlib
import hmac
import secrets
import time
import os
import urllib.parse
from pathlib import Path
PORT = int(os.environ.get("PORT", 8000))
BASE_DIR = Path(__file__).parent.resolve()
DB_FILE = BASE_DIR / "platform_data.db"
SECRET_KEY = secrets.token_hex(32)
# ==============================================================================
# DATENBANK INITIALISIERUNG
# ==============================================================================
def get_db():
conn = sqlite3.connect(str(DB_FILE))
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
c = conn.cursor()
# 1. Benutzer-Tabelle
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'student',
xp INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
streak_days INTEGER DEFAULT 1,
last_active_date TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
# 2. Kapitel-Fortschritt & Code-Drafts
c.execute("""
CREATE TABLE IF NOT EXISTS chapter_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
chapter_id TEXT NOT NULL,
code_draft TEXT,
subgoals_json TEXT,
is_solved INTEGER DEFAULT 0,
solved_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, chapter_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""")
# 3. Klassenräume (Lehrer-Feature)
c.execute("""
CREATE TABLE IF NOT EXISTS classrooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teacher_id INTEGER NOT NULL,
name TEXT NOT NULL,
invite_code TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(teacher_id) REFERENCES users(id) ON DELETE CASCADE
)""")
# 4. Klassen-Mitgliedschaften
c.execute("""
CREATE TABLE IF NOT EXISTS class_enrollments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
classroom_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(classroom_id, user_id),
FOREIGN KEY(classroom_id) REFERENCES classrooms(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""")
# 5. Freigeschaltete Trophäen
c.execute("""
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
trophy_id TEXT NOT NULL,
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, trophy_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)""")
conn.commit()
conn.close()
# ==============================================================================
# AUTHENTIFIZIERUNG & HASHING
# ==============================================================================
def hash_password(password: str, salt: str = None) -> tuple[str, str]:
if not salt:
salt = secrets.token_hex(16)
pwd_bytes = password.encode("utf-8")
salt_bytes = salt.encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", pwd_bytes, salt_bytes, 100000)
return key.hex(), salt
def verify_password(password: str, password_hash: str, salt: str) -> bool:
new_hash, _ = hash_password(password, salt)
return hmac.compare_digest(new_hash, password_hash)
def generate_token(user_id: int, email: str, role: str) -> str:
payload = {
"uid": user_id,
"email": email,
"role": role,
"exp": int(time.time()) + (86400 * 30) # 30 Tage gültig
}
payload_json = json.dumps(payload)
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest()
token = f"{payload_json}.{sig}"
return urllib.parse.quote(token)
def verify_token(token_str: str) -> dict | None:
try:
raw_token = urllib.parse.unquote(token_str)
if "." not in raw_token:
return None
payload_json, sig = raw_token.rsplit(".", 1)
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
payload = json.loads(payload_json)
if payload.get("exp", 0) < time.time():
return None
return payload
except Exception:
return None
# ==============================================================================
# HTTP REQUEST HANDLER
# ==============================================================================
class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def end_headers(self):
# CORS & Cache-Header für moderne Web-Apps
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-Headers", "Content-Type, Authorization")
self.send_header("Cache-Control", "no-cache, must-revalidate")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.end_headers()
def send_json(self, data: dict, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
def get_auth_user(self) -> dict | None:
auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
return verify_token(token)
return None
def read_json_body(self) -> dict:
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
return {}
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
# --- API ROUTES ---
if path == "/api/auth/me":
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
conn = get_db()
row = conn.execute("SELECT id, email, name, role, xp, level, streak_days FROM users WHERE id = ?", (user_token["uid"],)).fetchone()
conn.close()
if not row:
return self.send_json({"error": "Benutzer nicht gefunden"}, 404)
return self.send_json({"user": dict(row)})
elif path.startswith("/api/progress/get"):
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
query = urllib.parse.parse_qs(parsed.query)
chapter_id = query.get("chapter_id", [""])[0]
conn = get_db()
if chapter_id:
row = conn.execute("SELECT chapter_id, code_draft, subgoals_json, is_solved, solved_at FROM chapter_progress WHERE user_id = ? AND chapter_id = ?", (user_token["uid"], chapter_id)).fetchone()
conn.close()
return self.send_json({"progress": dict(row) if row else None})
else:
rows = conn.execute("SELECT chapter_id, is_solved, solved_at FROM chapter_progress WHERE user_id = ?", (user_token["uid"],)).fetchall()
conn.close()
return self.send_json({"all_progress": [dict(r) for r in rows]})
elif path.startswith("/api/classrooms/list"):
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
conn = get_db()
if user_token.get("role") == "teacher":
classes = conn.execute("SELECT id, name, invite_code, created_at FROM classrooms WHERE teacher_id = ?", (user_token["uid"],)).fetchall()
result = []
for cl in classes:
student_count = conn.execute("SELECT COUNT(*) FROM class_enrollments WHERE classroom_id = ?", (cl["id"],)).fetchone()[0]
result.append({**dict(cl), "student_count": student_count})
conn.close()
return self.send_json({"classrooms": result})
else:
classes = conn.execute("""
SELECT c.id, c.name, u.name as teacher_name, ce.joined_at
FROM class_enrollments ce
JOIN classrooms c ON ce.classroom_id = c.id
JOIN users u ON c.teacher_id = u.id
WHERE ce.user_id = ?
""", (user_token["uid"],)).fetchall()
conn.close()
return self.send_json({"classrooms": [dict(c) for c in classes]})
elif path.startswith("/api/classrooms/matrix"):
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]
if not classroom_id:
return self.send_json({"error": "classroom_id fehlt"}, 400)
conn = get_db()
students = conn.execute("""
SELECT u.id, u.name, u.email, u.xp, u.level, u.streak_days
FROM class_enrollments ce
JOIN users u ON ce.user_id = u.id
WHERE ce.classroom_id = ?
""", (classroom_id,)).fetchall()
matrix = []
for st in students:
solved = conn.execute("SELECT chapter_id, is_solved FROM chapter_progress WHERE user_id = ?", (st["id"],)).fetchall()
matrix.append({
"student": dict(st),
"solved_chapters": {r["chapter_id"]: bool(r["is_solved"]) for r in solved}
})
conn.close()
return self.send_json({"matrix": matrix})
elif path == "/api/stats/leaderboard":
conn = get_db()
rows = conn.execute("SELECT name, xp, level, role FROM users WHERE role = 'student' ORDER BY xp DESC LIMIT 20").fetchall()
conn.close()
return self.send_json({"leaderboard": [dict(r) for r in rows]})
# --- STATISCHE DATEIEN ---
return super().do_GET()
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
try:
body = self.read_json_body()
except Exception:
return self.send_json({"error": "Ungültiges JSON"}, 400)
# 1. REGISTRIERUNG
if path == "/api/auth/register":
email = body.get("email", "").strip().lower()
password = body.get("password", "")
name = body.get("name", "").strip()
role = body.get("role", "student")
if not email or not password or not name:
return self.send_json({"error": "Name, E-Mail und Passwort sind erforderlich"}, 400)
if len(password) < 6:
return self.send_json({"error": "Passwort muss mindestens 6 Zeichen lang sein"}, 400)
pwd_hash, salt = hash_password(password)
conn = get_db()
try:
c = conn.cursor()
c.execute("INSERT INTO users (email, password_hash, salt, name, role) VALUES (?, ?, ?, ?, ?)",
(email, pwd_hash, salt, name, role))
user_id = c.lastrowid
conn.commit()
token = generate_token(user_id, email, role)
return self.send_json({
"message": "Erfolgreich registriert!",
"token": token,
"user": {"id": user_id, "email": email, "name": name, "role": role, "xp": 0, "level": 1}
}, 201)
except sqlite3.IntegrityError:
return self.send_json({"error": "Ein Benutzer mit dieser E-Mail existiert bereits"}, 409)
finally:
conn.close()
# 2. LOGIN
elif path == "/api/auth/login":
email = body.get("email", "").strip().lower()
password = body.get("password", "")
conn = get_db()
user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
conn.close()
if not user or not verify_password(password, user["password_hash"], user["salt"]):
return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401)
token = generate_token(user["id"], user["email"], user["role"])
return self.send_json({
"message": "Erfolgreich angemeldet!",
"token": token,
"user": {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"role": user["role"],
"xp": user["xp"],
"level": user["level"],
"streak_days": user["streak_days"]
}
})
# 3. CODE SPEICHERN (AUTO-SAVE)
elif path == "/api/progress/save":
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
chapter_id = body.get("chapter_id", "")
code_draft = body.get("code_draft", "")
subgoals_json = json.dumps(body.get("subgoals", {}))
if not chapter_id:
return self.send_json({"error": "chapter_id erforderlich"}, 400)
conn = get_db()
conn.execute("""
INSERT INTO chapter_progress (user_id, chapter_id, code_draft, subgoals_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, chapter_id) DO UPDATE SET
code_draft = excluded.code_draft,
subgoals_json = excluded.subgoals_json,
updated_at = CURRENT_TIMESTAMP
""", (user_token["uid"], chapter_id, code_draft, subgoals_json))
conn.commit()
conn.close()
return self.send_json({"status": "saved", "chapter_id": chapter_id})
# 4. KAPITEL ALS GELÖST MARKIEREN & XP VERGEBEN
elif path == "/api/progress/solve":
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
chapter_id = body.get("chapter_id", "")
xp_reward = int(body.get("xp_reward", 100))
if not chapter_id:
return self.send_json({"error": "chapter_id erforderlich"}, 400)
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()
was_already_solved = existing and existing["is_solved"] == 1
if not was_already_solved:
conn.execute("""
INSERT INTO chapter_progress (user_id, chapter_id, is_solved, solved_at, updated_at)
VALUES (?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, chapter_id) DO UPDATE SET
is_solved = 1,
solved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
""", (user_token["uid"], chapter_id))
# XP & Level berechnen
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_level = (new_xp // 100) + 1
conn.execute("UPDATE users SET xp = ?, level = ? WHERE id = ?", (new_xp, new_level, user_token["uid"]))
conn.commit()
conn.close()
return self.send_json({"status": "solved", "earned_xp": xp_reward, "total_xp": new_xp, "level": new_level})
else:
conn.close()
return self.send_json({"status": "already_solved", "earned_xp": 0})
# 5. KLASSENRAUM ERSTELLEN (LEHRER)
elif path == "/api/classrooms/create":
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 gestattet"}, 403)
name = body.get("name", "").strip()
if not name:
return self.send_json({"error": "Klassenname erforderlich"}, 400)
invite_code = "PY-" + secrets.token_hex(3).upper()
conn = get_db()
c = conn.cursor()
c.execute("INSERT INTO classrooms (teacher_id, name, invite_code) VALUES (?, ?, ?)",
(user_token["uid"], name, invite_code))
class_id = c.lastrowid
conn.commit()
conn.close()
return self.send_json({"classroom": {"id": class_id, "name": name, "invite_code": invite_code}}, 201)
# 6. KLASSENRAUM BEITRETEN (SCHÜLER)
elif path == "/api/classrooms/join":
user_token = self.get_auth_user()
if not user_token:
return self.send_json({"error": "Nicht authentifiziert"}, 401)
code = body.get("invite_code", "").strip().upper()
conn = get_db()
cl = conn.execute("SELECT id, name FROM classrooms WHERE invite_code = ?", (code,)).fetchone()
if not cl:
conn.close()
return self.send_json({"error": "Ungültiger Einladungscode"}, 404)
try:
conn.execute("INSERT INTO class_enrollments (classroom_id, user_id) VALUES (?, ?)", (cl["id"], user_token["uid"]))
conn.commit()
conn.close()
return self.send_json({"message": f"Erfolgreich Klasse '{cl['name']}' beigetreten!", "classroom": dict(cl)})
except sqlite3.IntegrityError:
conn.close()
return self.send_json({"message": "Du bist dieser Klasse bereits beigetreten.", "classroom": dict(cl)})
return self.send_json({"error": "Endpunkt nicht gefunden"}, 404)
# ==============================================================================
# SERVER START
# ==============================================================================
def run_server():
init_db()
handler = PlatformRequestHandler
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), handler) as httpd:
print(f"🚀 Python Lernplattform Server läuft auf: http://localhost:{PORT}")
print(f"📁 Root-Verzeichnis: {BASE_DIR}")
print(f"💾 SQLite-Datenbank: {DB_FILE}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n🛑 Server gestoppt.")
if __name__ == "__main__":
run_server()