Refactor (Welle A): Pins, Konstanten, gezieltes CSRF, robustes load_lessons

- requirements.txt: exakte Versions-Pins; fehlendes 'markdown'
  ergänzt (wird in routes/main.py importiert)
- statistics_service: Magic Numbers (max ZPM/WPM, Mindestzeit,
  Wortlänge) als benannte Konstanten
- app.py: CSRF-Ausnahme gezielt nur für /shutdown statt ganzes
  utils-Blueprint -> /repair_json und /recreate_lessons bleiben geschützt
- helpers.load_lessons: automatische JSON-Reparatur-Heuristik entfernt;
  bei Fehler laut loggen + In-Memory-Fallback, fehlerhafte Datei bleibt
  unverändert (behebt latenten Datenverlust durch Überschreiben).
  Manueller Endpoint POST /repair_json bleibt erhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-22 12:34:38 +02:00
commit 9c3d58b04f
4 changed files with 31 additions and 40 deletions

7
app.py
View file

@ -49,7 +49,7 @@ from routes.training import training_bp
from routes.statistics import statistics_bp
from routes.settings import settings_bp
from routes.session import session_bp
from routes.utils import utils_bp
from routes.utils import utils_bp, shutdown
app.register_blueprint(main_bp)
app.register_blueprint(training_bp)
@ -58,8 +58,9 @@ app.register_blueprint(settings_bp)
app.register_blueprint(session_bp)
app.register_blueprint(utils_bp)
# CSRF-Ausnahme für Shutdown-Route
csrf.exempt(utils_bp)
# CSRF-Ausnahme gezielt nur für die Shutdown-Route.
# /repair_json und /recreate_lessons bleiben damit CSRF-geschützt.
csrf.exempt(shutdown)
# Datenbank-Status prüfen und Lektionen laden (nur wenn nicht im Test-Modus)

View file

@ -1,4 +1,5 @@
flask>=2.3.0
flask-sqlalchemy>=3.0.0
flask-wtf>=1.2.0
alembic>=1.13.0
Flask==3.1.2
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.2
alembic==1.17.0
Markdown==3.9

View file

@ -120,38 +120,21 @@ def load_lessons() -> None:
with open(DATA_PATH, "r", encoding="utf-8") as f:
content = f.read().strip()
# Versuche zuerst das originale JSON zu laden
# JSON laden. Bei Fehler NICHT automatisch raten/überschreiben,
# sondern laut scheitern und In-Memory-Fallback nutzen. Die
# fehlerhafte Datei bleibt für Diagnose/Reparatur unverändert.
try:
lessons = json.loads(content)
logger.info(f"Erfolgreich {len(lessons)} Lektionen aus {DATA_PATH} geladen")
return
except json.JSONDecodeError as e:
logger.error(f"JSON-Fehler in {DATA_PATH}: {e}")
logger.error(f"Position: Zeile {e.lineno}, Spalte {e.colno}")
# Versuche das JSON zu reparieren
logger.info("Versuche JSON automatisch zu reparieren...")
repaired_content = repair_json(content)
try:
lessons = json.loads(repaired_content)
logger.info(f"Erfolgreich {len(lessons)} Lektionen nach Reparatur geladen")
# Speichere die reparierte Version als Backup
backup_path = DATA_PATH + ".repaired"
with open(backup_path, "w", encoding="utf-8") as f:
json.dump(lessons, f, indent=2, ensure_ascii=False)
logger.info(f"Reparierte Version gespeichert als: {backup_path}")
return
except json.JSONDecodeError as e2:
logger.error(f"Automatische Reparatur fehlgeschlagen: {e2}")
logger.warning("Verwende Fallback-Lektionen und erstelle neue Lektionen-Datei")
lessons = FALLBACK_LESSONS
# Speichere Fallback-Lektionen für zukünftige Verwendung
with open(DATA_PATH, "w", encoding="utf-8") as f:
json.dump(lessons, f, indent=2, ensure_ascii=False)
logger.info(f"Fallback-Lektionen gespeichert in: {DATA_PATH}")
logger.error(f"JSON-Fehler in {DATA_PATH}: Zeile {e.lineno}, Spalte {e.colno}: {e.msg}")
logger.warning(
"Verwende Fallback-Lektionen (in-memory). Die fehlerhafte Datei "
"bleibt unverändert; zur Reparatur den Endpoint POST /repair_json nutzen."
)
lessons = FALLBACK_LESSONS
return
else:
logger.warning(f"Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen und erstelle Datei.")
lessons = FALLBACK_LESSONS

View file

@ -10,6 +10,12 @@ from models import db, LessonStatistic, DailyPractice
logger = logging.getLogger(__name__)
# Konstanten für die Statistik-Berechnung
MIN_ELAPSED_TIME_SECONDS = 1.0 # Mindestzeit; darunter unrealistische Werte
MAX_CHARS_PER_MINUTE = 1000 # Plausibilitäts-Obergrenze für ZPM
MAX_WORDS_PER_MINUTE = 200 # Plausibilitäts-Obergrenze für WPM
CHARS_PER_WORD = 5 # Standard-Wortlänge für die WPM-Berechnung
class StatisticsService:
"""Service for managing statistics"""
@ -43,18 +49,18 @@ class StatisticsService:
incorrect_chars += 1
# Calculate metrics based on real time
# Minimum 1 second to avoid unrealistic values
if total_chars > 0 and elapsed_time >= 1.0:
# Minimum time to avoid unrealistic values
if total_chars > 0 and elapsed_time >= MIN_ELAPSED_TIME_SECONDS:
# Characters per minute (integer)
chars_per_minute = (total_chars / elapsed_time) * 60
# Words per minute: (correct characters / 5) / (time in minutes)
words_per_minute = (correct_chars / 5) / (elapsed_time / 60)
# Words per minute: (correct characters / CHARS_PER_WORD) / (time in minutes)
words_per_minute = (correct_chars / CHARS_PER_WORD) / (elapsed_time / 60)
# Average time per character
duration_per_char = elapsed_time / total_chars
# Limit to realistic maximum values
chars_per_minute = min(chars_per_minute, 1000)
words_per_minute = min(words_per_minute, 200)
chars_per_minute = min(chars_per_minute, MAX_CHARS_PER_MINUTE)
words_per_minute = min(words_per_minute, MAX_WORDS_PER_MINUTE)
else:
chars_per_minute = 0
words_per_minute = 0