#!/usr/bin/env python3 """PrepodovAI runtime server — единая точка входа teacher/student UI. КАК ЗАПУСТИТЬ ------------- python server.py # с дефолтами из runtime_config.py cp .env.example .env && edit .env # предварительно задать параметры docker compose up -d runtime # в контейнере с persistent volume Адреса: http://127.0.0.1:8000/teacher — UI преподавателя http://127.0.0.1:8000/student — UI студента http://127.0.0.1:8000/login — регистрация / вход http://127.0.0.1:8000/health/live — kubernetes liveness probe http://127.0.0.1:8000/health/ready — readiness probe (DB + disk) http://127.0.0.1:8000/health/llm — статус активного LLM-провайдера ХРАНИЛИЩЕ --------- data/app.db — SQLite (WAL-режим) data/uploads/ — загруженные файлы (uuid-префикс) data/sample-course-*.json — fallback-курс, если LLM недоступен На облаке задать абсолютный путь через PREPODOV_DATA_DIR=/data и смонтировать persistent volume на эту папку. LLM-БЭКЕНДЫ (одна переменная PREPODOV_LLM_BACKEND) -------------------------------------------------- gemini — Google Gemini REST (требует PREPODOV_GEMINI_API_KEY) openai — любой OpenAI-API (Ollama / vLLM / OpenAI / Groq / ...) local — offline mlx-lm Qwen (macOS Apple Silicon, pip install ".[local-llm]") hosted — устаревший псевдоним для gemini (сохранён для совместимости) Каждый вызов модели обёрнут в ResilientLLMClient: ретраи с экспоненциальным backoff + circuit breaker + жёсткий per-call timeout. Падение провайдера никогда не валит бизнес-поток — UI получает sample/fallback-draft. АРХИТЕКТУРА ----------- 1. stdlib BaseHTTPRequestHandler + ThreadingHTTPServer (без FastAPI/uvicorn). 2. SQLite с tuned PRAGMAs (WAL, busy_timeout, mmap) — подходит до ~500 юзеров. 3. Аутентификация через prepodov_ai.webauth: - scrypt-хэш пароля (stdlib hashlib); - HttpOnly / SameSite=Lax session-cookie (30 дней TTL по умолчанию). 4. Мультикурсовая изоляция: X-Course-Id header scope-ит каждый запрос преподавателя; студент видит только свои submission-ы. 5. Security headers (CSP, X-Frame-Options, X-Content-Type-Options) на всё. 6. Graceful shutdown: SIGINT / SIGTERM / SIGBREAK → корректное завершение. Подробнее в README.md и docs/runtime-flow.md. """ import contextvars import json import logging import os import re import shutil import sqlite3 import sys import tempfile import threading import traceback import xml.etree.ElementTree as ET import zipfile from dataclasses import dataclass from datetime import UTC, datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse from prepodov_ai.infrastructure.policies import IngestionPolicy from prepodov_ai.ingestion.extraction_router import ( FileExtractionRouter as RuntimeFileExtractionRouter, ) from prepodov_ai.ingestion.extraction_types import StructuredExtractionResult BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" EXTRACTION_ROUTER = RuntimeFileExtractionRouter(policy=IngestionPolicy()) # --------------------------------------------------------------------------- # Конфигурация — единый источник правды. # Все параметры читаются из .env (или переменных окружения). # Для изменения настроек редактируй .env (скопируй из .env.example). # --------------------------------------------------------------------------- from prepodov_ai.runtime_config import cfg # noqa: E402 # Resolve data paths. Order of precedence: # 1. PREPODOV_DATA_DIR env var (absolute path — for cloud persistent volumes) # 2. BASE_DIR / cfg.data_dir_name (legacy relative path, default "data") # Upload dir is derived but overridable via PREPODOV_UPLOAD_DIR. DATA_DIR = ( Path(cfg.data_dir).resolve() if cfg.data_dir else (BASE_DIR / cfg.data_dir_name).resolve() ) UPLOAD_DIR = Path(cfg.upload_dir).resolve() if cfg.upload_dir else (DATA_DIR / "uploads") DB_PATH = DATA_DIR / "app.db" SAMPLE_COURSE_PATH = DATA_DIR / "sample-course-bioinformatics.json" # LLM — unified client; module-level for monkeypatch compatibility GEMINI_API_KEY: str | None = cfg.gemini_api_key GEMINI_MODEL: str = cfg.gemini_model GEMINI_API_BASE: str = cfg.gemini_api_base # "hosted" is the legacy alias for "gemini" _LLM_BACKEND_RAW: str = cfg.llm_backend.strip().lower() LLM_BACKEND: str = "gemini" if _LLM_BACKEND_RAW == "hosted" else _LLM_BACKEND_RAW MAX_LLM_INPUT_CHARS: int = cfg.max_llm_input_chars from prepodov_ai.llm import LLMConfig, LLMError, LLMRequest, build_llm_client # noqa: E402 from prepodov_ai.llm.feedback_pipeline import ( # noqa: E402 ChunkingOptions, generate_chunked_feedback, ) from prepodov_ai.llm.prompts import build_course_prompt, build_feedback_prompt # noqa: E402 from prepodov_ai.llm.resilient import ResiliencePolicy # noqa: E402 from prepodov_ai.webauth import ( # noqa: E402 UserAlreadyExistsError, create_session, create_user, delete_session, find_user_by_email, parse_session_cookie, resolve_session, verify_password, ) from prepodov_ai.webauth.passwords import PasswordError # noqa: E402 from prepodov_ai.webauth.repository import ValidationError # noqa: E402 from prepodov_ai.webauth.sessions import ( # noqa: E402 format_expire_cookie, format_set_cookie, ) _RESILIENCE_POLICY = ResiliencePolicy( max_attempts=cfg.llm_retry_max_attempts, base_delay_ms=cfg.llm_retry_base_delay_ms, max_delay_ms=cfg.llm_retry_max_delay_ms, per_call_timeout_s=cfg.llm_per_call_timeout_seconds, breaker_failure_threshold=cfg.llm_breaker_failure_threshold, breaker_reset_seconds=cfg.llm_breaker_reset_seconds, ) try: LLM_CLIENT = build_llm_client( LLMConfig( backend=LLM_BACKEND, # type: ignore[arg-type] gemini_api_key=cfg.gemini_api_key or "", gemini_model=cfg.gemini_model, gemini_api_base=cfg.gemini_api_base, openai_base_url=cfg.openai_base_url, openai_api_key=cfg.openai_api_key, openai_model=cfg.openai_model, local_model_id=cfg.local_model_id, timeout_seconds=cfg.llm_timeout_seconds, max_input_chars=cfg.max_llm_input_chars, temperature=cfg.llm_temperature, max_output_tokens=cfg.llm_max_output_tokens, ), policy=_RESILIENCE_POLICY, ) except Exception as _llm_init_exc: sys.stderr.write( f"[server] LLM init failed ({_llm_init_exc}); requests will use sample fallback.\n" ) LLM_CLIENT = None # type: ignore[assignment] TESSERACT_CMD: str | None = shutil.which("tesseract") _OCR_PORT = None if TESSERACT_CMD is not None: from prepodov_ai.infrastructure.ocr import TesseractOCRPort from prepodov_ai.infrastructure.settings import Settings as _InfraSettings _OCR_PORT = TesseractOCRPort(_InfraSettings()) _MIME_TO_SUFFIX: dict[str, str] = { "image/png": ".png", "image/jpeg": ".jpg", "image/tiff": ".tif", "image/webp": ".webp", } # The canonical regex/filter definitions live in `prepodov_ai.llm.prompts`. # These module-level aliases exist so legacy test modules that import # `server._MD_NOISE` / `server._INJECTION_MARKERS` / `server._sanitize_for_prompt` # keep working after the helpers were centralized. Assigned from the prompts # module (not re-imported) so `ruff --fix` does not strip them as "unused". from prepodov_ai.llm import prompts as _prompts # noqa: E402 _MD_NOISE = _prompts._MD_NOISE _INJECTION_MARKERS = _prompts._INJECTION_MARKERS _sanitize_for_prompt = _prompts.sanitize_for_prompt strip_markdown = _prompts.strip_markdown DEFAULT_RUBRIC = { "criteria": [ { "title": "Точность", "weight": 0.4, "levels": { "5": "Ошибок нет, все факты корректны", "4": "Незначительные неточности", "3": "Заметные ошибки", "2": "Критические ошибки", }, }, { "title": "Полнота", "weight": 0.3, "levels": { "5": "Все ключевые пункты раскрыты", "4": "Есть мелкие упущения", "3": "Часть важных пунктов пропущена", "2": "Существенные пробелы", }, }, { "title": "Логика и структура", "weight": 0.3, "levels": { "5": "Отличная структура и аргументация", "4": "В целом логично", "3": "Структура слабая", "2": "Ответ фрагментарный", }, }, ] } VALID_GRADES = frozenset({"2", "3", "4", "5"}) @dataclass(frozen=True, slots=True) class RuntimeExtraction: text: str warnings: list[str] fallback_trace: list[str] capabilities_used: list[str] requires_ocr: bool = False ocr_applied: bool = False _PROCESS_START = __import__("time").monotonic() def _uptime_seconds() -> int: import time as _time return int(_time.monotonic() - _PROCESS_START) def now_iso(): return datetime.now(UTC).isoformat().replace("+00:00", "Z") # SQLite production tuning. Values chosen for a ~100-student teacher tool on a # single server with persistent volume. WAL enables concurrent readers while a # writer holds the write lock; busy_timeout lets sqlite auto-retry locked # statements for up to 5 s before raising; synchronous=NORMAL is safe with WAL # (trades an infinitesimal crash-window for ~5× write throughput); mmap and # cache tuning pay for themselves as soon as the DB gets more than ~100 rows. _SQLITE_STARTUP_PRAGMAS = ( "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA busy_timeout=5000", "PRAGMA cache_size=-20000", # kibibytes; negative ⇒ 20 MB "PRAGMA mmap_size=268435456", # 256 MB memory-mapped I/O window "PRAGMA temp_store=MEMORY", "PRAGMA wal_autocheckpoint=1000", "PRAGMA foreign_keys=ON", ) # Per-connection PRAGMAs — the ones SQLite requires on every open. _SQLITE_CONNECTION_PRAGMAS = ("PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000") def connect_db() -> sqlite3.Connection: """Open a tuned SQLite connection suitable for the request-handler threads. Each connection lives for the duration of one request — SQLite connections are cheap (sub-millisecond on WAL mode) and thread-local ownership avoids the "cannot reuse across threads" trap from the stdlib driver. """ conn = sqlite3.connect( DB_PATH, timeout=5.0, # seconds the driver will wait for locks before raising ) for pragma in _SQLITE_CONNECTION_PRAGMAS: conn.execute(pragma) return conn def with_db_retry(fn, *, max_attempts: int = 3, base_delay_ms: int = 50): """Run ``fn(conn)`` under a transient-lock retry loop. SQLite raises ``OperationalError: database is locked`` under write contention; retrying with a small back-off is the canonical fix for a write-heavy moment (bulk draft generation, concurrent enrolments). The helper opens + closes the connection for the caller so leaks are impossible on the retry path. """ import time as _time last_exc: BaseException | None = None for attempt in range(1, max_attempts + 1): conn = connect_db() try: return fn(conn) except sqlite3.OperationalError as exc: last_exc = exc message = str(exc).lower() if "locked" not in message and "busy" not in message: raise if attempt == max_attempts: raise _time.sleep((base_delay_ms / 1000.0) * (2 ** (attempt - 1))) finally: try: conn.close() except Exception: pass if last_exc is not None: raise last_exc raise RuntimeError("with_db_retry exhausted attempts without an error") def _apply_startup_pragmas(conn: sqlite3.Connection) -> None: """Apply one-time server-startup tuning PRAGMAs. Idempotent: re-running is cheap, so we call this on every ``init_db()``. """ for pragma in _SQLITE_STARTUP_PRAGMAS: try: conn.execute(pragma) except sqlite3.OperationalError as exc: sys.stderr.write(f"[db] pragma failed: {pragma!r} — {exc}\n") def init_db(): conn = connect_db() _apply_startup_pragmas(conn) conn.executescript( """ CREATE TABLE IF NOT EXISTS course ( id INTEGER PRIMARY KEY, json TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS assignment ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, lesson_title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', rubric_json TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS submission ( id INTEGER PRIMARY KEY, assignment_id INTEGER NOT NULL, student_name TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL, FOREIGN KEY (assignment_id) REFERENCES assignment(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS feedback_draft ( id INTEGER PRIMARY KEY, submission_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (submission_id) REFERENCES submission(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS feedback_final ( id INTEGER PRIMARY KEY, submission_id INTEGER NOT NULL, content TEXT NOT NULL, grade TEXT, created_at TEXT NOT NULL, FOREIGN KEY (submission_id) REFERENCES submission(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('teacher','student','admin')), display_name TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_user_email ON user(email); CREATE TABLE IF NOT EXISTS session ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_session_user ON session(user_id); CREATE TABLE IF NOT EXISTS enrollment ( id INTEGER PRIMARY KEY, course_id INTEGER NOT NULL, student_id INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(course_id, student_id), FOREIGN KEY (course_id) REFERENCES course(id) ON DELETE CASCADE, FOREIGN KEY (student_id) REFERENCES user(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_enrollment_student ON enrollment(student_id); """ ) # --- Schema evolutions ------------------------------------------------- cols = [row[1] for row in conn.execute("PRAGMA table_info(assignment)").fetchall()] if "description" not in cols: conn.execute("ALTER TABLE assignment ADD COLUMN description TEXT NOT NULL DEFAULT ''") if "course_id" not in cols: conn.execute("ALTER TABLE assignment ADD COLUMN course_id INTEGER NOT NULL DEFAULT 1") sub_cols = [row[1] for row in conn.execute("PRAGMA table_info(submission)").fetchall()] if "domain" not in sub_cols: conn.execute("ALTER TABLE submission ADD COLUMN domain TEXT NOT NULL DEFAULT 'general'") if "attachment_name" not in sub_cols: conn.execute("ALTER TABLE submission ADD COLUMN attachment_name TEXT") if "extraction_warnings" not in sub_cols: conn.execute("ALTER TABLE submission ADD COLUMN extraction_warnings TEXT") if "student_id" not in sub_cols: conn.execute("ALTER TABLE submission ADD COLUMN student_id INTEGER") course_cols = [row[1] for row in conn.execute("PRAGMA table_info(course)").fetchall()] if "teacher_id" not in course_cols: conn.execute("ALTER TABLE course ADD COLUMN teacher_id INTEGER") if "title" not in course_cols: conn.execute("ALTER TABLE course ADD COLUMN title TEXT NOT NULL DEFAULT 'Курс'") if "join_code" not in course_cols: conn.execute("ALTER TABLE course ADD COLUMN join_code TEXT") if "subject_area" not in course_cols: conn.execute("ALTER TABLE course ADD COLUMN subject_area TEXT NOT NULL DEFAULT 'general'") if "grade_level" not in course_cols: conn.execute("ALTER TABLE course ADD COLUMN grade_level TEXT") conn.commit() conn.close() def empty_course(): return { "course": { "title": "", "semester": None, "credits": None, "totalHours": None, "finalControl": "", "competencies": [], "modules": [], } } def load_sample_course(): if SAMPLE_COURSE_PATH.exists(): return json.loads(SAMPLE_COURSE_PATH.read_text(encoding="utf-8")) return empty_course() LEGACY_COURSE_ID = 1 def get_course(conn, course_id: int = LEGACY_COURSE_ID): row = conn.execute("SELECT json FROM course WHERE id=?", (course_id,)).fetchone() if row: return json.loads(row[0]) return empty_course() def save_course( conn, course_json, rubric_by_lesson=None, description_by_lesson=None, *, course_id: int = LEGACY_COURSE_ID, ): # Ensure the course row exists. For the legacy singleton we INSERT OR UPDATE on id=1; # for multi-course we update an existing row (created via POST /api/my/courses). existing = conn.execute("SELECT 1 FROM course WHERE id=?", (course_id,)).fetchone() payload = json.dumps(course_json, ensure_ascii=False) if existing: conn.execute( "UPDATE course SET json=?, updated_at=? WHERE id=?", (payload, now_iso(), course_id), ) else: conn.execute( "INSERT INTO course(id, json, updated_at, title) VALUES(?,?,?,'Курс')", (course_id, payload, now_iso()), ) sync_assignments( conn, course_json, rubric_by_lesson=rubric_by_lesson or {}, description_by_lesson=description_by_lesson or {}, course_id=course_id, ) conn.commit() def extract_lessons(course_json): lessons = [] modules = course_json.get("course", {}).get("modules", []) for m in modules: for t in m.get("topics", []): for lesson in t.get("lessons", []): lessons.append(lesson) return lessons def sync_assignments( conn, course_json, rubric_by_lesson=None, description_by_lesson=None, *, course_id: int = LEGACY_COURSE_ID, ): conn.execute("DELETE FROM assignment WHERE course_id=?", (course_id,)) lessons = extract_lessons(course_json) rubric_by_lesson = rubric_by_lesson or {} description_by_lesson = description_by_lesson or {} for lesson in lessons: if lesson.get("type") != "practice": continue lesson_title = lesson.get("title", "Задание") title = ( lesson_title if str(lesson_title).strip().lower().startswith("практика") else f"Практика: {lesson_title}" ) rubric = rubric_by_lesson.get(lesson_title, DEFAULT_RUBRIC) description = description_by_lesson.get(lesson_title, "") conn.execute( "INSERT INTO assignment(title, lesson_title, description, rubric_json, course_id) " "VALUES(?, ?, ?, ?, ?)", ( title, lesson_title, description, json.dumps(rubric, ensure_ascii=False), course_id, ), ) def list_assignments(conn, course_id: int = LEGACY_COURSE_ID): rows = conn.execute( "SELECT id, title, lesson_title, description, rubric_json " "FROM assignment WHERE course_id=?", (course_id,), ).fetchall() return [ { "id": r[0], "title": r[1], "lesson_title": r[2], "description": r[3], "rubric": json.loads(r[4]), } for r in rows ] def list_submissions(conn, course_id: int = LEGACY_COURSE_ID): rows = conn.execute( "SELECT s.id, s.assignment_id, s.student_name, s.content, s.created_at, s.status, a.title, " "s.domain, s.attachment_name " "FROM submission s JOIN assignment a ON s.assignment_id = a.id " "WHERE a.course_id = ? ORDER BY s.created_at DESC", (course_id,), ).fetchall() return [ { "id": r[0], "assignment_id": r[1], "student_name": r[2], "content": r[3], "created_at": r[4], "status": r[5], "assignment_title": r[6], "domain": r[7] or "general", "attachment_name": r[8], } for r in rows ] def get_submission(conn, submission_id): row = conn.execute( "SELECT s.id, s.assignment_id, s.student_name, s.content, s.created_at, s.status, " "a.title, a.rubric_json, a.description, s.domain, s.attachment_name, s.extraction_warnings " "FROM submission s JOIN assignment a ON s.assignment_id = a.id WHERE s.id=?", (submission_id,), ).fetchone() if not row: return None warnings = row[11] try: warnings_list = json.loads(warnings) if warnings else [] except json.JSONDecodeError: warnings_list = [] return { "id": row[0], "assignment_id": row[1], "student_name": row[2], "content": row[3], "created_at": row[4], "status": row[5], "assignment_title": row[6], "rubric": json.loads(row[7]), "assignment_description": row[8], "domain": row[9] or "general", "attachment_name": row[10], "extraction_warnings": warnings_list, } def assignment_exists(conn: sqlite3.Connection, assignment_id: object) -> bool: row = conn.execute("SELECT 1 FROM assignment WHERE id=?", (assignment_id,)).fetchone() return row is not None def normalize_student_name(value: object) -> str: name = str(value or "").strip() return name or "Student" def normalize_domain(value: object) -> str: """Canonicalize a user-supplied domain key via the shared registry.""" from prepodov_ai.llm.domains import normalize_domain_key return normalize_domain_key(str(value or "")) def validate_final_grade(value: object) -> str | None: if value is None: return None grade = str(value).strip() if not grade: return None if grade not in VALID_GRADES: raise ValueError("grade must be one of: 2, 3, 4, 5") return grade def set_submission_status(conn, submission_id, status): conn.execute("UPDATE submission SET status=? WHERE id=?", (status, submission_id)) def store_feedback_draft(conn, submission_id, content): conn.execute( "INSERT INTO feedback_draft(submission_id, content, created_at) VALUES(?, ?, ?)", (submission_id, content, now_iso()), ) set_submission_status(conn, submission_id, "draft_ready") def store_feedback_final(conn, submission_id, content, grade): conn.execute( "INSERT INTO feedback_final(submission_id, content, grade, created_at) VALUES(?, ?, ?, ?)", (submission_id, content, grade, now_iso()), ) set_submission_status(conn, submission_id, "finalized") def list_feedback_draft(conn, submission_id): row = conn.execute( "SELECT content FROM feedback_draft WHERE submission_id=? ORDER BY id DESC LIMIT 1", (submission_id,), ).fetchone() return row[0] if row else None def list_feedback_final(conn, submission_id): row = conn.execute( "SELECT content, grade FROM feedback_final WHERE submission_id=? ORDER BY id DESC LIMIT 1", (submission_id,), ).fetchone() if not row: return None return {"content": row[0], "grade": row[1]} def list_students(conn, course_id: int = LEGACY_COURSE_ID): # We aggregate on student_id when available (authenticated enrollments) # and fall back to student_name for legacy submissions from unauthenticated # posters. The returned ``student_id`` is the FK users.id — it's the # stable handle the UI needs to wire the "remove from course" button. rows = conn.execute( """ SELECT COALESCE(s.student_id, -1) AS uid, s.student_name, COUNT(s.id) AS total, SUM(CASE WHEN s.status='submitted' THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN s.status='draft_ready' THEN 1 ELSE 0 END) AS drafts, SUM(CASE WHEN s.status='finalized' THEN 1 ELSE 0 END) AS finalized, AVG(CAST(ff.grade AS REAL)) AS avg_grade, MAX(s.created_at) AS last_activity FROM submission s JOIN assignment a ON a.id = s.assignment_id LEFT JOIN ( SELECT f1.submission_id, f1.grade FROM feedback_final f1 JOIN ( SELECT submission_id, MAX(id) AS max_id FROM feedback_final GROUP BY submission_id ) f2 ON f1.id = f2.max_id ) ff ON ff.submission_id = s.id WHERE a.course_id = ? GROUP BY uid, s.student_name ORDER BY last_activity DESC """, (course_id,), ).fetchall() return [ { # -1 sentinel is swapped to None so the UI does an honest # "unenroll" affordance only for authenticated students. "student_id": int(r[0]) if int(r[0]) > 0 else None, "student_name": r[1], "total": r[2] or 0, "pending": r[3] or 0, "drafts": r[4] or 0, "finalized": r[5] or 0, "avg_grade": round(r[6], 2) if r[6] is not None else None, "last_activity": r[7], } for r in rows ] def get_submission_detail(conn, submission_id): base = get_submission(conn, submission_id) if not base: return None draft = list_feedback_draft(conn, submission_id) final = list_feedback_final(conn, submission_id) base["draft"] = draft base["final"] = final return base def delete_submission(conn, submission_id) -> bool: row = conn.execute("SELECT 1 FROM submission WHERE id=?", (submission_id,)).fetchone() if not row: return False conn.execute("DELETE FROM submission WHERE id=?", (submission_id,)) conn.commit() return True def update_assignment(conn, assignment_id, *, description=None, rubric=None, title=None) -> bool: row = conn.execute("SELECT 1 FROM assignment WHERE id=?", (assignment_id,)).fetchone() if not row: return False sets = [] params: list = [] if description is not None: sets.append("description=?") params.append(str(description)) if rubric is not None: sets.append("rubric_json=?") params.append(json.dumps(rubric, ensure_ascii=False)) if title is not None and str(title).strip(): sets.append("title=?") params.append(str(title).strip()) if not sets: return True params.append(assignment_id) conn.execute(f"UPDATE assignment SET {', '.join(sets)} WHERE id=?", params) conn.commit() return True def dashboard_summary(conn, course_id: int = LEGACY_COURSE_ID): def _scalar(sql, *params): row = conn.execute(sql, params).fetchone() return row[0] if row and row[0] is not None else 0 total = _scalar( "SELECT COUNT(*) FROM submission s JOIN assignment a ON s.assignment_id=a.id " "WHERE a.course_id=?", course_id, ) pending = _scalar( "SELECT COUNT(*) FROM submission s JOIN assignment a ON s.assignment_id=a.id " "WHERE a.course_id=? AND s.status='submitted'", course_id, ) # Drafts and finals are counted from their respective history tables — this # preserves the "at least one draft/final ever existed" semantic even after # a submission has been finalized (status transitioned to 'finalized'). drafts = _scalar( "SELECT COUNT(DISTINCT fd.submission_id) FROM feedback_draft fd " "JOIN submission s ON s.id = fd.submission_id " "JOIN assignment a ON a.id = s.assignment_id " "WHERE a.course_id=?", course_id, ) finals = _scalar( "SELECT COUNT(DISTINCT ff.submission_id) FROM feedback_final ff " "JOIN submission s ON s.id = ff.submission_id " "JOIN assignment a ON a.id = s.assignment_id " "WHERE a.course_id=?", course_id, ) row = conn.execute( "SELECT AVG(CAST(f.grade AS REAL)) FROM feedback_final f " "JOIN submission s ON s.id = f.submission_id " "JOIN assignment a ON a.id = s.assignment_id " "WHERE a.course_id=? AND f.grade IS NOT NULL", (course_id,), ).fetchone() raw_avg = row[0] if row else None if raw_avg is None: avg_grade = None else: avg_grade = round(raw_avg, 2) # Keep as int when the result is an exact whole number — test invariants # and UI both prefer "4" over "4.0". if float(avg_grade).is_integer(): avg_grade = int(avg_grade) recent = conn.execute( "SELECT s.id, s.assignment_id, s.student_name, s.created_at, s.status, a.title " "FROM submission s JOIN assignment a ON s.assignment_id=a.id " "WHERE a.course_id=? ORDER BY s.created_at DESC LIMIT 5", (course_id,), ).fetchall() assignments = _scalar("SELECT COUNT(*) FROM assignment WHERE course_id=?", course_id) students = _scalar( "SELECT COUNT(DISTINCT COALESCE(s.student_id, s.student_name)) FROM submission s " "JOIN assignment a ON a.id = s.assignment_id WHERE a.course_id=?", course_id, ) return { "total_submissions": total, "pending": pending, "drafts": drafts, "finals": finals, "finalized": finals, "avg_grade": round(avg_grade, 2) if avg_grade is not None else None, "assignments": assignments, "students": students, "llm_backend": LLM_BACKEND, "recent": [ { "id": r[0], "assignment_id": r[1], "student_name": r[2], "created_at": r[3], "status": r[4], "assignment_title": r[5], } for r in recent ], } def list_student_feedback( conn, *, student_name: str | None = None, student_id: int | None = None, course_id: int | None = None, ): """Return a student's own submissions+feedback. Authenticated callers pass ``student_id`` for a privacy-safe lookup by user ID; the ``student_name`` path is kept for legacy unauthenticated clients (scoped to LEGACY_COURSE_ID only by the HTTP layer). """ clauses: list[str] = [] params: list = [] if student_id is not None: clauses.append("s.student_id = ?") params.append(int(student_id)) elif student_name is not None: clauses.append("s.student_name = ?") params.append(str(student_name)) else: return [] if course_id is not None: clauses.append("a.course_id = ?") params.append(int(course_id)) where_clause = " AND ".join(clauses) rows = conn.execute( f""" SELECT s.id, s.assignment_id, s.content, s.created_at, s.status, a.title, ff.content, ff.grade FROM submission s JOIN assignment a ON s.assignment_id = a.id LEFT JOIN ( SELECT f1.submission_id, f1.content, f1.grade FROM feedback_final f1 JOIN ( SELECT submission_id, MAX(id) AS max_id FROM feedback_final GROUP BY submission_id ) f2 ON f1.id = f2.max_id ) ff ON ff.submission_id = s.id WHERE {where_clause} ORDER BY s.created_at DESC """, tuple(params), ).fetchall() return [ { "submission_id": r[0], "assignment_id": r[1], "submission_content": r[2], "created_at": r[3], "status": r[4], "assignment_title": r[5], "feedback": r[6], "grade": r[7], } for r in rows ] _SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9._\-]+$") _WINDOWS_RESERVED_NAMES = frozenset( { "CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10)), } ) def _safe_upload_filename(raw: str) -> str | None: """Sanitize a user-supplied filename. Returns a server-safe variant or ``None`` if the name is impossible to sanitize (empty, path-traversing, reserved). Keeps the original extension when it's alphanumeric; otherwise drops it. """ if not raw: return None # Strip path components from both POSIX and Windows-style names. candidate = os.path.basename(raw.replace("\\", "/")).strip() if not candidate or candidate in {".", ".."}: return None if candidate.startswith(".") or "\x00" in candidate: return None # Chop too-long names while keeping the extension. stem, dot, ext = candidate.rpartition(".") if stem and dot and ext and _SAFE_FILENAME_RE.match(ext) and len(ext) <= 12: base, extension = stem, f".{ext}" else: base, extension = candidate, "" # Replace any character outside the allowlist with underscore. base = re.sub(r"[^A-Za-z0-9._\-]", "_", base) base = base.strip("._-") or "file" if base.upper().split(".")[0] in _WINDOWS_RESERVED_NAMES: base = f"_{base}" safe = (base + extension)[:128] return safe or None def _generate_join_code(length: int = 6) -> str: """Human-friendly course invite code: 6 chars, avoids ambiguous ones. Uses ``secrets.choice`` to make codes unguessable by timing/random oracles (L2 from the security audit) — still short enough to dictate over the phone. """ import secrets alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I for readability return "".join(secrets.choice(alphabet) for _ in range(length)) def _generate_unique_join_code( conn: sqlite3.Connection, *, exclude_course_id: int | None = None ) -> str: """Return a fresh join-code that doesn't collide with any existing course. The alphabet gives 32^6 ≈ 10⁹ possibilities, so collisions are vanishingly rare — but we still retry a few times so the rare case can't corrupt DB invariants by writing a duplicate. """ for _ in range(8): candidate = _generate_join_code() if exclude_course_id is None: hit = conn.execute("SELECT 1 FROM course WHERE join_code=?", (candidate,)).fetchone() else: hit = conn.execute( "SELECT 1 FROM course WHERE join_code=? AND id <> ?", (candidate, exclude_course_id), ).fetchone() if not hit: return candidate return candidate # extremely unlikely — best effort, caller handles collision def list_teacher_courses(conn: sqlite3.Connection, teacher_id: int) -> list[dict]: rows = conn.execute( "SELECT c.id, c.title, c.updated_at, c.join_code, c.subject_area, c.grade_level, " " (SELECT COUNT(*) FROM enrollment WHERE course_id = c.id) AS students_cnt, " " (SELECT COUNT(*) FROM assignment WHERE course_id = c.id) AS assignments_cnt, " " (SELECT COUNT(*) FROM submission s JOIN assignment a ON a.id = s.assignment_id " " WHERE a.course_id = c.id AND s.status = 'submitted') AS pending_cnt " "FROM course c WHERE c.teacher_id = ? ORDER BY c.updated_at DESC", (teacher_id,), ).fetchall() return [ { "id": r[0], "title": r[1], "updated_at": r[2], "join_code": r[3], "subject_area": r[4] or "general", "grade_level": r[5], "students": r[6] or 0, "assignments": r[7] or 0, "pending": r[8] or 0, } for r in rows ] def list_student_courses(conn: sqlite3.Connection, student_id: int) -> list[dict]: rows = conn.execute( "SELECT c.id, c.title, c.updated_at, u.display_name AS teacher_name, " " c.subject_area, c.grade_level, " " (SELECT COUNT(*) FROM assignment WHERE course_id = c.id) AS assignments_cnt " "FROM enrollment e JOIN course c ON c.id = e.course_id " "LEFT JOIN user u ON u.id = c.teacher_id " "WHERE e.student_id = ? ORDER BY c.updated_at DESC", (student_id,), ).fetchall() return [ { "id": r[0], "title": r[1], "updated_at": r[2], "teacher_name": r[3], "subject_area": r[4] or "general", "grade_level": r[5], "assignments": r[6] or 0, } for r in rows ] def create_course( conn: sqlite3.Connection, *, teacher_id: int, title: str, subject_area: str = "general", grade_level: str | None = None, ) -> dict: clean_title = (title or "").strip() or "Новый курс" subject = normalize_domain(subject_area) grade = (grade_level or "").strip()[:40] or None join_code = _generate_unique_join_code(conn) cursor = conn.execute( "INSERT INTO course(json, updated_at, teacher_id, title, join_code, subject_area, grade_level) " "VALUES(?,?,?,?,?,?,?)", ( json.dumps(empty_course(), ensure_ascii=False), now_iso(), teacher_id, clean_title, join_code, subject, grade, ), ) conn.commit() course_id = int(cursor.lastrowid or 0) return { "id": course_id, "title": clean_title, "join_code": join_code, "subject_area": subject, "grade_level": grade, } def get_course_subject_area(conn: sqlite3.Connection, course_id: int) -> str: row = conn.execute("SELECT subject_area FROM course WHERE id=?", (course_id,)).fetchone() return str(row[0]) if row and row[0] else "general" def get_course_owner(conn: sqlite3.Connection, course_id: int) -> int | None: row = conn.execute("SELECT teacher_id FROM course WHERE id=?", (course_id,)).fetchone() if not row: return None return int(row[0]) if row[0] is not None else None def is_enrolled(conn: sqlite3.Connection, course_id: int, student_id: int) -> bool: row = conn.execute( "SELECT 1 FROM enrollment WHERE course_id=? AND student_id=?", (course_id, student_id), ).fetchone() return row is not None def join_course_by_code(conn: sqlite3.Connection, *, student_id: int, code: str) -> dict | None: clean = (code or "").strip().upper() if not clean: return None row = conn.execute("SELECT id, title FROM course WHERE join_code=?", (clean,)).fetchone() if not row: return None course_id, title = int(row[0]), row[1] if not is_enrolled(conn, course_id, student_id): conn.execute( "INSERT INTO enrollment(course_id, student_id, created_at) VALUES(?,?,?)", (course_id, student_id, now_iso()), ) conn.commit() return {"id": course_id, "title": title} _PPTX_MAX_SLIDE_XML_BYTES = 8 * 1024 * 1024 # 8 MiB per slide is generous; real slides are <100 KiB _PPTX_MAX_SLIDES = 1024 def extract_text_from_pptx(path: Path): """Extract slide text from a `.pptx` file. Defensive bounds: * Reject zip-bombed slides whose decompressed XML exceeds ``_PPTX_MAX_SLIDE_XML_BYTES`` — a real slide is well under 1 MiB; an 8 MiB ceiling has plenty of headroom while preventing a single malicious slide from forcing the parser to allocate gigabytes. * Cap the number of slides parsed to ``_PPTX_MAX_SLIDES`` so a deck with a million entries cannot exhaust memory just from name iteration. * Python 3.7+ ``xml.etree.ElementTree`` already disables external entity resolution by default — we don't add ``defusedxml`` to keep the stdlib-first dependency story, but the size cap above bounds the worst case for quadratic-blowup ("billion laughs") payloads. """ texts: list[str] = [] with zipfile.ZipFile(path, "r") as z: slide_files = sorted( [f for f in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", f)], key=lambda s: int(re.search(r"slide(\d+)\.xml$", s).group(1)), )[:_PPTX_MAX_SLIDES] for sf in slide_files: info = z.getinfo(sf) if info.file_size > _PPTX_MAX_SLIDE_XML_BYTES: # Skip oversized slides instead of failing the whole upload — # caller will see a shorter extraction and the warnings list # in extract_runtime_text will surface this. continue xml_data = z.read(sf) root = ET.fromstring(xml_data) for t in root.iter(): if t.tag.endswith("}t") and t.text: texts.append(t.text) return "\n".join(texts) def canonical_type_for_upload(path: Path) -> str | None: extension = path.suffix.lower() if extension in {".txt", ".md"}: return "txt" if extension in {".pdf", ".doc", ".docx"}: return extension.lstrip(".") if extension == ".xlsx": return "xlsx" if extension == ".xlsb": return "xlsb" if extension in {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".webp"}: return "image" return None def join_extracted_text(result: StructuredExtractionResult) -> str: parts: list[str] = [] for fragment in result.text_fragments: text = fragment.content.strip() if not text and fragment.kind == "sheet": cells = fragment.metadata.get("cells", []) sheet_name = fragment.metadata.get("sheet_name", "") cell_lines = [ f"{c['cell_address']}: {c['display_value']}" for c in cells if isinstance(c, dict) and c.get("display_value") ] if cell_lines: text = f"[Sheet: {sheet_name}]\n" + "\n".join(cell_lines) if text: parts.append(text) return "\n\n".join(parts) def _run_ocr_for_fragment(fragment, raw_payload: bytes | None) -> str: """Return OCR text for a single fragment, or empty string if unavailable.""" if _OCR_PORT is None or raw_payload is None: return "" port = _OCR_PORT img_bytes: bytes img_suffix: str if fragment.kind == "pdf_page": from prepodov_ai.ingestion.pdf_processing import PdfProcessingService page_number = int(fragment.metadata.get("page_number", 1)) try: img_bytes = PdfProcessingService().render_page_image(raw_payload, page_number) except Exception as exc: sys.stderr.write(f"[ocr] render failed for page {page_number}: {exc}\n") return "" img_suffix = ".png" elif fragment.kind == "image": img_bytes = raw_payload mime = str(fragment.metadata.get("mime", "image/png")) img_suffix = _MIME_TO_SUFFIX.get(mime, ".png") else: return "" with tempfile.NamedTemporaryFile(suffix=img_suffix, delete=False) as tmp: tmp.write(img_bytes) tmp_path = tmp.name try: result = port.extract_text(Path(tmp_path)) return result.text.strip() except Exception as exc: sys.stderr.write(f"[ocr] tesseract failed: {exc}\n") return "" finally: Path(tmp_path).unlink(missing_ok=True) def extract_runtime_text(path: Path) -> RuntimeExtraction: """Extract user-visible text through the production ingestion router. PPTX is still handled by the legacy XML parser because the ingestion router does not own presentation parsing yet. All other supported upload types share the production extraction services so runtime behavior matches the ingestion contract and exposes actionable warnings. """ extension = path.suffix.lower() if extension == ".pptx": text = extract_text_from_pptx(path) warnings = [] if text.strip() else ["pptx_no_text_found"] return RuntimeExtraction( text=text, warnings=warnings, fallback_trace=["parser:pptx:xml"], capabilities_used=["pptx_xml_text"], ) canonical_type = canonical_type_for_upload(path) if canonical_type is None: return RuntimeExtraction( text="", warnings=[f"unsupported_upload_type:{extension or 'none'}"], fallback_trace=[], capabilities_used=[], ) raw_payload = path.read_bytes() result = EXTRACTION_ROUTER.extract( canonical_type=canonical_type, filename=path.name, payload=raw_payload, ) all_fragments = [*result.text_fragments, *result.image_fragments] ocr_texts: list[str] = [] ocr_applied = False if _OCR_PORT is not None: ocr_raw = raw_payload if canonical_type in {"pdf", "image"} else None for fragment in all_fragments: if fragment.requires_ocr: ocr_text = _run_ocr_for_fragment(fragment, ocr_raw) if ocr_text: ocr_texts.append(ocr_text) ocr_applied = True base_text = join_extracted_text(result) full_text = base_text + ("\n\n" + "\n\n".join(ocr_texts) if ocr_texts else "") requires_ocr = any(f.requires_ocr for f in all_fragments) if _OCR_PORT is None and requires_ocr: ocr_warning = ["ocr_unavailable_tesseract_missing"] elif _OCR_PORT is not None and requires_ocr and not ocr_applied: ocr_warning = ["ocr_no_text_extracted"] else: ocr_warning = [] return RuntimeExtraction( text=full_text, warnings=result.warnings + ocr_warning, fallback_trace=result.fallback_trace, capabilities_used=result.capabilities_used + (["ocr_tesseract"] if ocr_applied else []), requires_ocr=requires_ocr, ocr_applied=ocr_applied, ) def parse_content_disposition(value: str): out = {} if not value: return out parts = [p.strip() for p in value.split(";")] for p in parts: if "=" in p: k, v = p.split("=", 1) out[k.strip().lower()] = v.strip().strip('"') else: out[p.lower()] = True return out def parse_multipart_formdata(content_type: str, body: bytes): boundary = None for piece in content_type.split(";"): piece = piece.strip() if piece.startswith("boundary="): boundary = piece.split("=", 1)[1].strip().strip('"') if not boundary: return [] bnd = ("--" + boundary).encode() parts = body.split(bnd) result = [] for part in parts: part = part.strip(b"\r\n") if not part or part == b"--": continue header_end = part.find(b"\r\n\r\n") if header_end < 0: continue try: headers_raw = part[:header_end].decode("utf-8") except UnicodeDecodeError: headers_raw = part[:header_end].decode("latin1", errors="replace") content = part[header_end + 4 :] if content.endswith(b"\r\n"): content = content[:-2] headers = {} for line in headers_raw.split("\r\n"): if ":" in line: k, v = line.split(":", 1) headers[k.strip().lower()] = v.strip() disp = parse_content_disposition(headers.get("content-disposition", "")) result.append( { "name": disp.get("name"), "filename": disp.get("filename"), "headers": headers, "content": content, } ) return result def extract_json(text: str): if not text: return None try: return json.loads(text) except Exception: pass start = text.find("{") end = text.rfind("}") if start >= 0 and end > start: try: return json.loads(text[start : end + 1]) except Exception: return None return None def generate_course_from_text(raw_text: str): """Return ``(course_dict, rubric_by_lesson, description_by_lesson, fallback_used)``.""" if LLM_CLIENT is None: return load_sample_course(), {}, {}, True prompt = build_course_prompt(raw_text[:MAX_LLM_INPUT_CHARS]) try: response = LLM_CLIENT.generate( LLMRequest(prompt=prompt, max_input_chars=MAX_LLM_INPUT_CHARS) ) data = response.data sys.stderr.write( f"[llm] backend={response.backend} " f"in={response.input_tokens} out={response.output_tokens}\n" ) except LLMError as exc: sys.stderr.write(f"[llm] generate_course_from_text failed: {exc}\n") return load_sample_course(), {}, {}, True if data and "course" in data: rubric_by_lesson = data.get("rubric_by_lesson", {}) or {} description_by_lesson = data.get("description_by_lesson", {}) or {} return data, rubric_by_lesson, description_by_lesson, False return load_sample_course(), {}, {}, True def generate_feedback_draft(assignment_title, rubric, submission_text): """Backward-compatible feedback draft — returns a string. Kept for tests and external callers. Uses the single-call fallback path. """ if LLM_CLIENT is None: return _generic_feedback_draft() prompt = build_feedback_prompt(assignment_title, rubric, submission_text[:MAX_LLM_INPUT_CHARS]) try: response = LLM_CLIENT.generate( LLMRequest( prompt=prompt, response_format="text", max_input_chars=MAX_LLM_INPUT_CHARS, ) ) if response.raw_text: return "(AI draft)\n" + response.raw_text.strip() except LLMError as exc: sys.stderr.write(f"[llm] generate_feedback_draft failed: {exc}\n") return _generic_feedback_draft() def generate_feedback_draft_chunked( assignment_title, rubric, submission_text, *, assignment_description: str = "", domain: str = "general", ) -> tuple[str, dict]: """Chunked feedback draft with metadata (chunk count, synthesis flag). Used by HTTP handlers so the UI can explain to the teacher how the draft was assembled. Returns ``(content, meta)``. """ if LLM_CLIENT is None: return _generic_feedback_draft(), { "chunk_count": 0, "synthesis_used": False, "fallback_used": True, } options = ChunkingOptions( chunk_chars=cfg.feedback_chunk_chars, overlap_chars=cfg.feedback_chunk_overlap, parallel=cfg.feedback_parallel, enable_synthesis=cfg.feedback_enable_synthesis, domain=domain or "general", llm_timeout_seconds=cfg.llm_timeout_seconds, max_input_chars=MAX_LLM_INPUT_CHARS, ) try: result = generate_chunked_feedback( LLM_CLIENT, assignment_title=assignment_title, assignment_description=assignment_description or "", rubric=rubric, submission_text=submission_text, options=options, ) except LLMError as exc: sys.stderr.write(f"[llm] chunked feedback failed: {exc}\n") return _generic_feedback_draft(), { "chunk_count": 0, "synthesis_used": False, "fallback_used": True, "error": str(exc), } meta = { "chunk_count": result.chunk_count, "synthesis_used": result.synthesis_used, "fallback_used": result.fallback_used, } content = result.content if not content or not content.strip(): return _generic_feedback_draft(), {**meta, "fallback_used": True} if not content.startswith("(AI draft)"): content = "(AI draft)\n" + content return content, meta def _generic_feedback_draft() -> str: return ( "(AI draft)\n" "Сильные стороны: видны базовые попытки решить задачу.\n" "Замечания: требуется больше точности и полноты по критериям.\n" "Что улучшить: ответить на все пункты задания и структурировать решение.\n" "Следующий шаг: доработать ключевые элементы и привести примеры." ) def _write_security_headers(handler) -> None: """Common defensive headers applied to every response. Header set documented: * ``X-Content-Type-Options: nosniff`` — disable MIME sniffing. * ``X-Frame-Options: DENY`` — legacy clickjacking guard (modern equivalent is the ``frame-ancestors 'none'`` CSP directive below). * ``Referrer-Policy`` — strict, send full URL only on same-origin. * ``Content-Security-Policy`` — strict allow-list of script/style/image sources; ``style-src`` allows inline because the design system inlines a handful of small style hints; scripts must come from /static. * ``Cross-Origin-Opener-Policy: same-origin`` — isolates this window from cross-origin opener pages (closes window.opener attack surface). * ``Cross-Origin-Embedder-Policy: credentialless`` — modern default that lets us embed cross-origin resources only without credentials. Safe because the app loads zero cross-origin assets at runtime. * ``Cross-Origin-Resource-Policy: same-origin`` — only same-origin pages may embed any resource we serve. * ``Permissions-Policy`` — explicitly disable browser APIs we don't use (camera, microphone, geolocation, payments, sensors, USB). Even if a page is hijacked, these capabilities aren't reachable. """ handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("X-Frame-Options", "DENY") handler.send_header("Referrer-Policy", "strict-origin-when-cross-origin") handler.send_header("Cross-Origin-Opener-Policy", "same-origin") handler.send_header("Cross-Origin-Embedder-Policy", "credentialless") handler.send_header("Cross-Origin-Resource-Policy", "same-origin") handler.send_header( "Permissions-Policy", ( "accelerometer=(), autoplay=(), camera=(), display-capture=(), " "encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), " "magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), " "publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), " "xr-spatial-tracking=()" ), ) rid = REQUEST_ID.get() if rid and rid != "-": handler.send_header("X-Request-Id", rid) # CSP: self + inline styles (needed for CSS file + potential design tweaks); # no inline scripts, no object embedding, no cross-origin form submission. handler.send_header( "Content-Security-Policy", ( "default-src 'self'; " "script-src 'self'; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "connect-src 'self'; " "base-uri 'none'; " "form-action 'self'; " "frame-ancestors 'none'; " "object-src 'none'" ), ) def json_response(handler, payload, status=200, *, extra_headers: dict | None = None): """Serialize ``payload`` as JSON and write the HTTP response. ``extra_headers`` lets callers append response-specific headers (e.g. the ``Retry-After`` hint for 503 responses) without reaching into low-level ``BaseHTTPRequestHandler`` methods. """ data = json.dumps(payload, ensure_ascii=False).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(data))) _write_security_headers(handler) for name, value in (extra_headers or {}).items(): handler.send_header(str(name), str(value)) for cookie in getattr(handler, "_extra_cookies", []) or []: handler.send_header("Set-Cookie", cookie) handler.end_headers() handler.wfile.write(data) # Static assets that benefit from short-lived browser caching. HTML and the # index page are intentionally NOT cached so users always see the freshest # auth/onboarding flow when we push a fix; CSS/JS/SVG/images are bumped via # query string when needed. ``no-cache`` forces revalidation but allows ETag. _CACHEABLE_STATIC_EXTENSIONS = frozenset({".css", ".js", ".svg", ".png", ".jpg", ".jpeg", ".ico"}) _STATIC_CACHE_MAX_AGE = 60 * 60 # 1 hour in seconds — small enough to ship fast fixes _GZIPPABLE_CONTENT_TYPES = ( "text/", "application/javascript", "application/json", "image/svg+xml", ) _GZIP_MIN_BYTES = 1024 def _accepts_gzip(handler) -> bool: accept = (handler.headers.get("Accept-Encoding") or "").lower() return "gzip" in accept def _maybe_gzip(content: bytes, content_type: str, handler) -> tuple[bytes, str | None]: """Return (body, content-encoding) — gzip-compress if it makes sense. Skips small bodies (the CPU+overhead of gzip rarely beats raw transfer under 1 KiB) and content types that are already compressed (PNG/JPEG). """ if len(content) < _GZIP_MIN_BYTES: return content, None if not _accepts_gzip(handler): return content, None if not any(content_type.startswith(prefix) for prefix in _GZIPPABLE_CONTENT_TYPES): return content, None import gzip compressed = gzip.compress(content, compresslevel=6) # Only use the compressed body if it's actually smaller (rare edge case). if len(compressed) >= len(content): return content, None return compressed, "gzip" def file_response(handler, path: Path): if not path.exists() or not path.is_file(): handler.send_error(404) return content = path.read_bytes() ext = path.suffix.lower() content_type = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", }.get(ext, "application/octet-stream") body, encoding = _maybe_gzip(content, content_type, handler) handler.send_response(200) handler.send_header("Content-Type", content_type) handler.send_header("Content-Length", str(len(body))) if encoding: handler.send_header("Content-Encoding", encoding) handler.send_header("Vary", "Accept-Encoding") if ext in _CACHEABLE_STATIC_EXTENSIONS: handler.send_header( "Cache-Control", f"public, max-age={_STATIC_CACHE_MAX_AGE}, stale-while-revalidate=60", ) else: # HTML and unknown types: never cache — keeps shell pages always fresh. handler.send_header("Cache-Control", "no-cache, no-store, must-revalidate") _write_security_headers(handler) handler.end_headers() handler.wfile.write(body) MAX_JSON_BODY_BYTES = 1 * 1024 * 1024 # 1 MB default cap for JSON endpoints _LOGIN_ATTEMPT_BUCKETS: dict[str, list[float]] = {} _LOGIN_ATTEMPT_WINDOW = 60.0 * 15 # 15 minutes _LOGIN_ATTEMPT_LIMIT = 8 # A fixed dummy hash so the login path always pays a scrypt cost regardless of # whether the email existed — blunts timing-based user-enumeration. _DUMMY_PASSWORD_HASH_CACHE: str | None = None def _dummy_password_hash() -> str: """Return a cached scrypt-format hash to run verify against on unknown emails.""" global _DUMMY_PASSWORD_HASH_CACHE if _DUMMY_PASSWORD_HASH_CACHE is None: from prepodov_ai.webauth.passwords import hash_password _DUMMY_PASSWORD_HASH_CACHE = hash_password("dummy-placeholder-timing-only") return _DUMMY_PASSWORD_HASH_CACHE class Handler(BaseHTTPRequestHandler): # ``BaseHTTPRequestHandler`` defaults ``Server: BaseHTTP/0.6 Python/X.Y.Z`` # which leaks both the framework and Python version. Override to a single # opaque token. (Security scanners flag the default as info disclosure.) server_version = "PrepodovAI" sys_version = "" def version_string(self) -> str: return self.server_version def send_error(self, code, message=None, explain=None): # type: ignore[override] """Wrap stdlib ``send_error`` so error responses still carry our security headers. The default 404/500 paths bypass ``json_response`` and ``file_response``, so without this override the security scanner rightly reports headers missing on /robots.txt, /sitemap.xml, etc. """ try: short, long = self.responses.get(code, ("???", "???")) except Exception: short, long = "Error", str(code) if message is None: message = short body = (explain or long or message or "").encode("utf-8", "replace") self.send_response(code, message) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") # Match production posture for 404/500 — no caches in front of error # bodies, and never carry stale state between users. self.send_header("Cache-Control", "no-store") _write_security_headers(self) self.end_headers() if self.command != "HEAD" and code >= 200 and code not in (204, 304): self.wfile.write(body) # ---------- Auth helpers ---------- def _is_secure_request(self) -> bool: # Behind a reverse proxy: trust X-Forwarded-Proto if explicitly set. proto = (self.headers.get("X-Forwarded-Proto") or "").lower() return proto == "https" def _current_session_token(self) -> str | None: return parse_session_cookie(self.headers.get("Cookie")) def _client_ip(self) -> str: forwarded = (self.headers.get("X-Forwarded-For") or "").strip() if forwarded: # Last hop is closest to us; earlier hops are client-controlled. return forwarded.split(",")[-1].strip() or self.client_address[0] return self.client_address[0] def _check_login_rate_limit(self) -> bool: """Return True if the caller is allowed to attempt login.""" import time ip = self._client_ip() now = time.time() bucket = _LOGIN_ATTEMPT_BUCKETS.get(ip, []) bucket = [t for t in bucket if now - t < _LOGIN_ATTEMPT_WINDOW] if len(bucket) >= _LOGIN_ATTEMPT_LIMIT: _LOGIN_ATTEMPT_BUCKETS[ip] = bucket return False bucket.append(now) _LOGIN_ATTEMPT_BUCKETS[ip] = bucket return True def _current_user(self, conn: sqlite3.Connection | None = None) -> dict | None: """Resolve the authenticated user from the session cookie, or ``None``. Silently returns ``None`` for anonymous / expired / invalid sessions — callers choose whether to serve a legacy public response or respond with 401. Use ``_require_user`` when the endpoint is strictly private. """ token = self._current_session_token() if not token: return None owns_conn = conn is None if conn is None: conn = connect_db() try: return resolve_session(conn, token) finally: if owns_conn: conn.close() def _require_user(self, *, role: str | None = None) -> dict | None: """Return the authenticated user dict or send 401/403 and return None.""" user = self._current_user() if not user: json_response(self, {"error": "authentication required"}, status=401) return None if role and user.get("role") != role and user.get("role") != "admin": json_response(self, {"error": f"role {role!r} required"}, status=403) return None return user def _read_json_body(self, max_bytes: int = MAX_JSON_BODY_BYTES) -> dict | None: length_raw = self.headers.get("Content-Length", "0") try: length = int(length_raw) except (TypeError, ValueError): json_response(self, {"error": "invalid Content-Length"}, status=400) return None if length < 0: json_response(self, {"error": "invalid Content-Length"}, status=400) return None if length > max_bytes: json_response(self, {"error": f"payload too large (max {max_bytes} bytes)"}, status=413) return None if not length: return {} body = self.rfile.read(length).decode("utf-8") if not body.strip(): return {} try: data = json.loads(body) except json.JSONDecodeError: json_response(self, {"error": "invalid JSON"}, status=400) return None if not isinstance(data, dict): json_response(self, {"error": "JSON object expected"}, status=400) return None return data # ---------- Course scope ---------- def _resolve_course_scope(self, conn: sqlite3.Connection, user: dict | None) -> int | None: """Pick the course this request is scoped to. Precedence: ``X-Course-Id`` header → ``course_id`` query param → (authenticated only) user's first available course. Falls back to the legacy singleton course (id=1) so pre-auth callers and existing tests continue to work exactly as before. Returns None if the user explicitly requested a course they cannot access — after responding with 403. Callers must check. """ requested_raw: str | None = None header_value = self.headers.get("X-Course-Id") if header_value: requested_raw = header_value.strip() if requested_raw is None: parsed = urlparse(self.path) query_values = parse_qs(parsed.query or "").get("course_id") if query_values: requested_raw = query_values[0] if requested_raw: try: requested = int(requested_raw) except (TypeError, ValueError): json_response(self, {"error": "invalid course_id"}, status=400) return None if not self._assert_course_access(conn, user, requested): return None return requested # Legacy fallback: singleton course when no explicit scope is provided. return LEGACY_COURSE_ID def _assert_course_access( self, conn: sqlite3.Connection, user: dict | None, course_id: int ) -> bool: """Return True if ``user`` may read/write data inside ``course_id``. Teachers must own the course; students must be enrolled. Admin role bypasses ownership. Writes 403/404 and returns False on failure. """ if course_id == LEGACY_COURSE_ID: # Singleton is shared / legacy — always accessible. return True owner = get_course_owner(conn, course_id) if owner is None: json_response(self, {"error": "course not found"}, status=404) return False role = (user or {}).get("role") user_id = (user or {}).get("id") if role == "admin": return True if role == "teacher" and owner == user_id: return True if role == "student" and user_id is not None and is_enrolled(conn, course_id, user_id): return True json_response(self, {"error": "forbidden"}, status=403) return False def _set_session_cookie(self, token: str, expires_at) -> None: self._extra_cookies = getattr(self, "_extra_cookies", []) self._extra_cookies.append( format_set_cookie(token, expires_at, secure=self._is_secure_request()) ) def _clear_session_cookie(self) -> None: self._extra_cookies = getattr(self, "_extra_cookies", []) self._extra_cookies.append(format_expire_cookie(secure=self._is_secure_request())) def _safe(self, fn): """Run a request method; turn any uncaught error into a 500 without leaking details. Also manages per-request context: assigns (or honours the client-supplied) ``X-Request-Id`` so every log line, exception trace and 5xx response body can be correlated during incident response. """ import secrets as _secrets incoming = self.headers.get("X-Request-Id") if self.headers else None rid = (incoming or _secrets.token_urlsafe(8))[:24] token = REQUEST_ID.set(rid) try: return fn() except BrokenPipeError: # Client disconnected — logging is noisy, the response is moot. return None except Exception: log.exception("unhandled exception in request handler") try: return json_response( self, {"error": "internal error", "request_id": rid}, status=500, ) except Exception: return None finally: REQUEST_ID.reset(token) def do_GET(self): return self._safe(self._do_GET) def do_POST(self): if not self._origin_check(): return return self._safe(self._do_POST) def do_PATCH(self): if not self._origin_check(): return return self._safe(self._do_PATCH) def do_DELETE(self): if not self._origin_check(): return return self._safe(self._do_DELETE) def _origin_check(self) -> bool: """Reject mutating requests whose Origin/Referer points off-host. SameSite=Lax already blocks the common cross-site CSRF surface, but an explicit Origin/Referer guard is cheap defence-in-depth: it covers clients that haven't shipped strict SameSite, mis-configured proxies, and browser-extension-mediated requests. Same-origin requests have Origin == request URL host (or no Origin and a Referer that matches); anything else is rejected with 403. Skip the check on auth endpoints — login/register pages are reachable cold and will set Origin to the same host once posted from /login. Skip when the runtime hasn't received any host header at all (CLI smoke tests, unit-test fixtures). """ host_header = (self.headers.get("Host") or "").strip().lower() if not host_header: return True origin = (self.headers.get("Origin") or "").strip() referer = (self.headers.get("Referer") or "").strip() if not origin and not referer: # Some clients (curl, scripts, CLI tools) send neither. Allow — # SameSite=Lax cookie won't be carried by a malicious form post # in any modern browser anyway. return True try: from urllib.parse import urlparse as _urlparse for raw in (origin, referer): if not raw: continue parsed = _urlparse(raw) if parsed.netloc.lower() == host_header: return True except Exception: return True log.warning( "rejected cross-origin mutating request: method=%s path=%s origin=%r referer=%r host=%r", self.command, self.path, origin, referer, host_header, ) try: json_response(self, {"error": "cross-origin request rejected"}, status=403) except Exception: pass return False def do_HEAD(self): # Minimal HEAD: let the standard Handler probe static files and return # headers only. For /api/* we reuse GET and strip the body. return self._safe(self._do_HEAD) def do_OPTIONS(self): return self._safe(self._do_OPTIONS) def _do_HEAD(self): # noqa: N802 - mirrors BaseHTTPRequestHandler.do_HEAD casing # Re-use GET's response headers; discard the body. parsed = urlparse(self.path) path = parsed.path if path == "/api/auth/me": user = self._current_user() self.send_response(204 if user else 401) _write_security_headers(self) self.end_headers() return # For any other HEAD, defer to a lightweight 200 with security headers. self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") _write_security_headers(self) self.end_headers() def _do_OPTIONS(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing """Advertise allowed methods per endpoint class. Kept simple (no CORS yet — same-origin deployment). Helps API explorers and lets clients probe availability. """ parsed = urlparse(self.path) path = parsed.path if path.startswith("/api/auth/"): allow = "GET, POST, PATCH, OPTIONS" elif path.startswith("/api/courses/") or path.startswith("/api/my/"): allow = "GET, POST, DELETE, OPTIONS" elif path.startswith("/api/assignments/"): allow = "GET, PATCH, OPTIONS" elif path.startswith("/api/submissions/"): allow = "GET, DELETE, OPTIONS" elif path.startswith("/api/"): allow = "GET, POST, OPTIONS" else: allow = "GET, OPTIONS" self.send_response(204) self.send_header("Allow", allow) _write_security_headers(self) self.end_headers() def _do_GET(self): # noqa: N802 - mirrors BaseHTTPRequestHandler.do_GET casing parsed = urlparse(self.path) path = parsed.path if path == "/": return file_response(self, STATIC_DIR / "index.html") if path == "/teacher": return file_response(self, STATIC_DIR / "teacher.html") if path == "/student": return file_response(self, STATIC_DIR / "student.html") if path.startswith("/static/"): rel = path.replace("/static/", "") safe = (STATIC_DIR / rel).resolve() static_root = STATIC_DIR.resolve() # Correct containment check: resolve both sides and require prefix relationship. try: safe.relative_to(static_root) except ValueError: return self.send_error(403) return file_response(self, safe) if path == "/api/course": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return course = get_course(conn, course_id) finally: conn.close() return json_response(self, course) if path == "/api/assignments": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return items = list_assignments(conn, course_id) finally: conn.close() return json_response(self, {"assignments": items}) if path == "/api/submissions": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return # Students only see their own submissions; teachers see the whole queue. if user and user.get("role") == "student": items = list_submissions(conn, course_id) items = [ s for s in items if s.get("student_name") == user.get("display_name") or s.get("student_id") == user.get("id") ] else: items = list_submissions(conn, course_id) finally: conn.close() return json_response(self, {"submissions": items}) if path == "/api/analytics": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return summary = dashboard_summary(conn, course_id) finally: conn.close() return json_response( self, { "total_submissions": summary["total_submissions"], "drafts": summary["drafts"], "finals": summary["finals"], "avg_grade": summary["avg_grade"], "llm_backend": LLM_BACKEND, }, ) if path == "/api/runtime": from prepodov_ai.llm.domains import available_domains return json_response( self, { "backend": LLM_BACKEND, "llm_initialized": LLM_CLIENT is not None, "gemini_model": cfg.gemini_model if LLM_BACKEND == "gemini" else None, "openai_model": cfg.openai_model if LLM_BACKEND == "openai" else None, "local_model": cfg.local_model_id if LLM_BACKEND == "local" else None, "feedback": { "chunk_chars": cfg.feedback_chunk_chars, "chunk_overlap": cfg.feedback_chunk_overlap, "parallel": cfg.feedback_parallel, "synthesis_enabled": cfg.feedback_enable_synthesis, }, "resilience": { "max_attempts": cfg.llm_retry_max_attempts, "breaker_threshold": cfg.llm_breaker_failure_threshold, "breaker_reset_seconds": cfg.llm_breaker_reset_seconds, "per_call_timeout_s": cfg.llm_per_call_timeout_seconds, }, "submission_upload_max_bytes": cfg.submission_upload_max_bytes, "ocr_available": _OCR_PORT is not None, "domains": available_domains(), }, ) if path == "/health/llm": ok = LLM_CLIENT is not None and LLM_CLIENT.health_check() return json_response( self, { "status": "ok" if ok else "degraded", "backend": LLM_BACKEND, "client_initialized": LLM_CLIENT is not None, }, status=200 if ok else 503, ) if path == "/health/live": # Kubernetes/Kube-style liveness probe: "is this process responsive?" # MUST be fast and MUST NOT touch external services. A failed live # probe triggers a pod restart, so we only fail when the runtime # itself is broken — not when the DB or LLM is briefly unavailable. return json_response(self, {"status": "ok", "uptime_s": _uptime_seconds()}) if path == "/health/ready": # Readiness probe: "can we currently serve traffic?" — DB + writable # disk. Failing this only removes us from a load-balancer; it does # NOT restart the process. LLM health is intentionally excluded # (transient provider outage shouldn't drop us from the LB). db_ok = True db_error: str | None = None try: with_db_retry(lambda c: c.execute("SELECT 1").fetchone(), max_attempts=1) except Exception as exc: db_ok = False db_error = str(exc)[:200] upload_ok = os.access(str(UPLOAD_DIR), os.W_OK) ready = db_ok and upload_ok return json_response( self, { "status": "ok" if ready else "not_ready", "db": db_ok, "db_error": db_error, "uploads_writable": upload_ok, }, status=200 if ready else 503, ) if path == "/api/student_feedback": qs = parse_qs(parsed.query or "") student_name = (qs.get("student_name") or [None])[0] conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return # Privacy fix: authenticated students can only see their own feedback, # regardless of the student_name parameter. Teachers in scope can query # any student by name (for UI search). Anonymous legacy callers still # need the old student_name=? behaviour. if user and user.get("role") == "student": items = list_student_feedback(conn, student_id=user["id"], course_id=course_id) elif user and user.get("role") in ("teacher", "admin"): if not student_name: return json_response(self, {"error": "student_name required"}, status=400) items = list_student_feedback( conn, student_name=student_name, course_id=course_id ) else: if not student_name: return json_response(self, {"error": "student_name required"}, status=400) items = list_student_feedback( conn, student_name=student_name, course_id=course_id ) finally: conn.close() return json_response(self, {"items": items}) if path == "/api/dashboard": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return summary = dashboard_summary(conn, course_id) finally: conn.close() return json_response(self, summary) if path == "/api/students": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return items = list_students(conn, course_id) finally: conn.close() return json_response(self, {"students": items}) if path.startswith("/api/submissions/"): rest = path[len("/api/submissions/") :] if rest.isdigit(): conn = connect_db() try: user = self._current_user(conn) detail = get_submission_detail(conn, int(rest)) if not detail: return json_response(self, {"error": "not found"}, status=404) # Scope the submission through its assignment's course. assignment_course = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (detail["assignment_id"],), ).fetchone() sub_course_id = ( int(assignment_course[0]) if assignment_course else LEGACY_COURSE_ID ) if sub_course_id != LEGACY_COURSE_ID: if not self._assert_course_access(conn, user, sub_course_id): return # Students can only read their own submissions. if user and user.get("role") == "student": if detail.get("student_id") != user.get("id") and detail.get( "student_name" ) != user.get("display_name"): return json_response(self, {"error": "forbidden"}, status=403) finally: conn.close() return json_response(self, detail) # ------------------------------------------------------------------ # Auth + multi-course GET endpoints # ------------------------------------------------------------------ if path == "/api/auth/me": user = self._current_user() if not user: return json_response(self, {"authenticated": False}, status=200) return json_response(self, {"authenticated": True, "user": user}) if path == "/api/my/courses": user = self._require_user() if not user: return conn = connect_db() try: if user["role"] == "teacher": items = list_teacher_courses(conn, user["id"]) else: items = list_student_courses(conn, user["id"]) finally: conn.close() return json_response(self, {"courses": items, "role": user["role"]}) if path == "/api/my/submissions": # Students: every submission they've ever sent, across all enrolled # courses (or plain-name legacy submissions matching display_name). # Teachers: every submission across courses they own (scoped queue). user = self._require_user() if not user: return qs = parse_qs(parsed.query or "") try: limit = min(int((qs.get("limit") or ["50"])[0]), 200) offset = max(int((qs.get("offset") or ["0"])[0]), 0) except (TypeError, ValueError): return json_response(self, {"error": "invalid pagination"}, status=400) conn = connect_db() try: if user["role"] == "teacher": rows = conn.execute( "SELECT s.id, s.assignment_id, s.student_name, s.content, " " s.created_at, s.status, a.title, a.course_id, c.title, " " s.domain, s.attachment_name " "FROM submission s " "JOIN assignment a ON a.id = s.assignment_id " "JOIN course c ON c.id = a.course_id " "WHERE c.teacher_id = ? " "ORDER BY s.created_at DESC LIMIT ? OFFSET ?", (user["id"], limit, offset), ).fetchall() else: rows = conn.execute( "SELECT s.id, s.assignment_id, s.student_name, s.content, " " s.created_at, s.status, a.title, a.course_id, c.title, " " s.domain, s.attachment_name " "FROM submission s " "JOIN assignment a ON a.id = s.assignment_id " "JOIN course c ON c.id = a.course_id " "WHERE s.student_id = ? OR " " (s.student_name = ? AND (c.teacher_id IS NULL OR " " EXISTS (SELECT 1 FROM enrollment e WHERE e.course_id = c.id " " AND e.student_id = ?))) " "ORDER BY s.created_at DESC LIMIT ? OFFSET ?", ( user["id"], user["display_name"], user["id"], limit, offset, ), ).fetchall() items = [ { "id": r[0], "assignment_id": r[1], "student_name": r[2], "content": r[3], "created_at": r[4], "status": r[5], "assignment_title": r[6], "course_id": r[7], "course_title": r[8], "domain": r[9] or "general", "attachment_name": r[10], } for r in rows ] finally: conn.close() return json_response( self, {"submissions": items, "limit": limit, "offset": offset}, ) if path == "/login": return file_response(self, STATIC_DIR / "login.html") self.send_error(404) def _do_POST(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing parsed = urlparse(self.path) path = parsed.path if path == "/api/course": data = self._read_json_body() if data is None: return conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return # Mutating a non-legacy course requires teacher ownership. if course_id != LEGACY_COURSE_ID and ( not user or user.get("role") not in ("teacher", "admin") ): return json_response(self, {"error": "forbidden"}, status=403) save_course(conn, data, course_id=course_id) finally: conn.close() return json_response(self, {"ok": True}) if path == "/api/upload_syllabus": content_type = self.headers.get("Content-Type") if not content_type or "multipart/form-data" not in content_type: return json_response(self, {"error": "Expected multipart/form-data"}, status=400) try: length = int(self.headers.get("Content-Length", "0")) except (TypeError, ValueError): return json_response(self, {"error": "invalid Content-Length"}, status=400) if length > cfg.submission_upload_max_bytes: return json_response( self, { "error": f"file exceeds limit ({length} > " f"{cfg.submission_upload_max_bytes} bytes)" }, status=413, ) body = self.rfile.read(length) if length else b"" parts = parse_multipart_formdata(content_type, body) file_part = None for p in parts: if p.get("name") == "file": file_part = p break if not file_part or not file_part.get("filename"): return json_response(self, {"error": "file is required"}, status=400) original_name = str(file_part.get("filename")) safe_name = _safe_upload_filename(original_name) if safe_name is None: return json_response(self, {"error": "invalid filename"}, status=400) # Always write under a UUID prefix to prevent name collisions and # path traversal across users (H3 from the security audit). import uuid as _uuid dest = UPLOAD_DIR / f"syllabus_{_uuid.uuid4().hex}_{safe_name}" # Belt-and-braces: verify the resolved path is still inside UPLOAD_DIR. try: dest.resolve().relative_to(UPLOAD_DIR.resolve()) except ValueError: return json_response(self, {"error": "invalid filename"}, status=400) dest.write_bytes(file_part.get("content") or b"") extracted = extract_runtime_text(dest) return json_response( self, { "filename": safe_name, "text": extracted.text, "warnings": extracted.warnings, "fallback_trace": extracted.fallback_trace, "capabilities_used": extracted.capabilities_used, "requires_ocr": extracted.requires_ocr, "ocr_applied": extracted.ocr_applied, "note": ( "Текст извлечён. Проверьте предупреждения ниже, если часть материала " "не распознана." ), }, ) if path == "/api/generate_course": data = self._read_json_body(max_bytes=MAX_JSON_BODY_BYTES * 4) # bigger: syllabus text if data is None: return raw_text = data.get("text", "") try: course, rubric_by_lesson, description_by_lesson, fallback_used = ( generate_course_from_text(raw_text) ) except Exception: traceback.print_exc() return json_response(self, {"error": "generation failed"}, status=500) conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return if course_id != LEGACY_COURSE_ID and ( not user or user.get("role") not in ("teacher", "admin") ): return json_response(self, {"error": "forbidden"}, status=403) # Critical: if the LLM bailed out and we're about to hand the # teacher the canned sample course, DON'T overwrite a course # they've already built. The teacher would otherwise press the # button once, get a fallback, and lose all their work. if fallback_used: existing_course = get_course(conn, course_id) existing_modules = existing_course.get("course", {}).get("modules", []) if existing_modules: return json_response( self, { "course": existing_course, "practice_rubrics": 0, "practice_descriptions": 0, "llm_backend": LLM_BACKEND, "fallback_used": True, "skipped_save": True, "message": ( "Модель недоступна, а текущий курс уже содержит " "структуру — сохранение пропущено, ваши данные целы." ), }, ) save_course( conn, course, rubric_by_lesson=rubric_by_lesson, description_by_lesson=description_by_lesson, course_id=course_id, ) finally: conn.close() return json_response( self, { "course": course, "practice_rubrics": len(rubric_by_lesson), "practice_descriptions": len(description_by_lesson), "llm_backend": LLM_BACKEND, "fallback_used": fallback_used, }, ) if path == "/api/rubric": data = self._read_json_body() if data is None: return rubric = data.get("rubric") if not rubric: return json_response(self, {"error": "rubric required"}, status=400) # Require an assignment_id so we never clobber other teachers' rubrics # (code-review #5: previously `UPDATE assignment SET rubric_json=?` with no WHERE). assignment_id = data.get("assignment_id") if not assignment_id: return json_response( self, {"error": "assignment_id required; use PATCH /api/assignments/{id}"}, status=400, ) conn = connect_db() try: user = self._current_user(conn) assignment_row = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (int(assignment_id),), ).fetchone() if not assignment_row: return json_response(self, {"error": "assignment not found"}, status=404) assignment_course_id = int(assignment_row[0]) if assignment_course_id != LEGACY_COURSE_ID and not self._assert_course_access( conn, user, assignment_course_id ): return conn.execute( "UPDATE assignment SET rubric_json=? WHERE id=?", (json.dumps(rubric, ensure_ascii=False), int(assignment_id)), ) conn.commit() finally: conn.close() return json_response(self, {"ok": True}) if path == "/api/submissions": return self._handle_create_submission() if path == "/api/feedback/draft": data = self._read_json_body() if data is None: return submission_id = data.get("submission_id") if not submission_id: return json_response(self, {"error": "submission_id required"}, status=400) conn = connect_db() try: user = self._current_user(conn) sub = get_submission(conn, submission_id) if not sub: return json_response(self, {"error": "not found"}, status=404) # Scope through the assignment's course — teachers (owner) only. row = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (sub["assignment_id"],), ).fetchone() sub_course_id = int(row[0]) if row else LEGACY_COURSE_ID if sub_course_id != LEGACY_COURSE_ID: if not user or user.get("role") not in ("teacher", "admin"): return json_response(self, {"error": "teacher only"}, status=403) if not self._assert_course_access(conn, user, sub_course_id): return from prepodov_ai.llm.errors import LLMCircuitOpen try: draft, meta = generate_feedback_draft_chunked( sub["assignment_title"], sub["rubric"], sub["content"], assignment_description=sub.get("assignment_description") or "", domain=( sub.get("domain") if sub.get("domain") and sub.get("domain") != "general" else get_course_subject_area(conn, sub_course_id) ), ) except LLMCircuitOpen as exc: retry_after = max(1, int(exc.retry_after_seconds or 30)) return json_response( self, { "error": "llm_unavailable", "retry_after_seconds": retry_after, "message": ( "Сервис обратной связи временно недоступен. " f"Попробуйте снова через {retry_after} с." ), }, status=503, extra_headers={"Retry-After": retry_after}, ) except Exception: traceback.print_exc() return json_response(self, {"error": "feedback failed"}, status=500) store_feedback_draft(conn, submission_id, draft) conn.commit() finally: conn.close() return json_response(self, {"content": draft, "meta": meta}) if path == "/api/feedback/final": data = self._read_json_body() if data is None: return submission_id = data.get("submission_id") content = data.get("content", "") try: grade = validate_final_grade(data.get("grade")) except ValueError as exc: return json_response(self, {"error": str(exc)}, status=400) if not submission_id or not content.strip(): return json_response( self, {"error": "submission_id and content required"}, status=400 ) conn = connect_db() try: user = self._current_user(conn) sub = get_submission(conn, submission_id) if not sub: return json_response(self, {"error": "not found"}, status=404) row = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (sub["assignment_id"],), ).fetchone() sub_course_id = int(row[0]) if row else LEGACY_COURSE_ID if sub_course_id != LEGACY_COURSE_ID: if not user or user.get("role") not in ("teacher", "admin"): return json_response(self, {"error": "teacher only"}, status=403) if not self._assert_course_access(conn, user, sub_course_id): return store_feedback_final(conn, submission_id, content.strip(), grade) conn.commit() finally: conn.close() return json_response(self, {"ok": True}) if path == "/api/feedback/bulk_draft": conn = connect_db() try: user = self._current_user(conn) course_id = self._resolve_course_scope(conn, user) if course_id is None: return if course_id != LEGACY_COURSE_ID and ( not user or user.get("role") not in ("teacher", "admin") ): return json_response(self, {"error": "teacher only"}, status=403) pending = conn.execute( "SELECT s.id FROM submission s JOIN assignment a ON a.id = s.assignment_id " "WHERE s.status='submitted' AND a.course_id = ?", (course_id,), ).fetchall() generated = 0 failures: list[dict] = [] course_subject = get_course_subject_area(conn, course_id) for (submission_id,) in pending: sub = get_submission(conn, submission_id) if not sub: continue sub_domain = sub.get("domain") resolved_domain = ( sub_domain if sub_domain and sub_domain != "general" else course_subject ) try: draft, _meta = generate_feedback_draft_chunked( sub["assignment_title"], sub["rubric"], sub["content"], assignment_description=sub.get("assignment_description") or "", domain=resolved_domain, ) store_feedback_draft(conn, submission_id, draft) generated += 1 except Exception as exc: failures.append({"submission_id": submission_id, "error": str(exc)[:200]}) conn.commit() finally: conn.close() return json_response( self, {"generated": generated, "total": len(pending), "failures": failures}, ) # ------------------------------------------------------------------ # Auth + multi-course POST endpoints # ------------------------------------------------------------------ if path == "/api/auth/register": return self._handle_register() if path == "/api/auth/login": return self._handle_login() if path == "/api/auth/logout": return self._handle_logout() if path == "/api/my/courses": return self._handle_create_course() if path == "/api/courses/join": return self._handle_join_course() if path.startswith("/api/courses/") and path.endswith("/rotate_code"): course_id_str = path[len("/api/courses/") : -len("/rotate_code")] if course_id_str.isdigit(): return self._handle_rotate_join_code(int(course_id_str)) self.send_error(404) # --- Auth endpoint handlers -------------------------------------------- def _handle_register(self): data = self._read_json_body() if data is None: return conn = connect_db() try: user = create_user( conn, email=data.get("email", ""), password=data.get("password", ""), display_name=data.get("display_name", ""), role=data.get("role", "student"), ) token, expires_at = create_session(conn, user["id"]) conn.commit() except UserAlreadyExistsError as exc: conn.close() return json_response(self, {"error": str(exc)}, status=409) except (ValidationError, PasswordError) as exc: conn.close() return json_response(self, {"error": str(exc)}, status=400) finally: try: conn.close() except Exception: pass self._set_session_cookie(token, expires_at) return json_response(self, {"user": user}, status=201) def _handle_login(self): if not self._check_login_rate_limit(): return json_response( self, {"error": "too many login attempts, try again in 15 minutes"}, status=429, ) data = self._read_json_body() if data is None: return email = data.get("email", "") password = data.get("password", "") conn = connect_db() try: user = find_user_by_email(conn, email) # Run verify_password against a dummy hash when the user doesn't exist, # so response timing doesn't leak whether the email is registered (L5). candidate_hash = user["password_hash"] if user else _dummy_password_hash() if not verify_password(password, candidate_hash) or not user: return json_response(self, {"error": "invalid credentials"}, status=401) # Rotate: invalidate any cookie the client might already have presented # so the new login owns a fresh session token (M1 — session fixation). previous_token = self._current_session_token() if previous_token: delete_session(conn, previous_token) token, expires_at = create_session(conn, user["id"]) conn.commit() public = {k: user[k] for k in ("id", "email", "role", "display_name")} finally: conn.close() self._set_session_cookie(token, expires_at) return json_response(self, {"user": public}) def _handle_logout(self): token = self._current_session_token() if token: conn = connect_db() try: delete_session(conn, token) finally: conn.close() self._clear_session_cookie() return json_response(self, {"ok": True}) # --- Course management -------------------------------------------------- def _handle_create_course(self): user = self._require_user(role="teacher") if not user: return data = self._read_json_body() if data is None: return conn = connect_db() try: course = create_course( conn, teacher_id=user["id"], title=data.get("title", ""), subject_area=data.get("subject_area", "general"), grade_level=data.get("grade_level"), ) finally: conn.close() return json_response(self, {"course": course}, status=201) def _handle_join_course(self): user = self._require_user(role="student") if not user: return data = self._read_json_body() if data is None: return conn = connect_db() try: course = join_course_by_code(conn, student_id=user["id"], code=data.get("code", "")) finally: conn.close() if not course: return json_response(self, {"error": "invalid join code"}, status=404) return json_response(self, {"course": course}) def _handle_rotate_join_code(self, course_id: int): user = self._require_user(role="teacher") if not user: return conn = connect_db() try: if not self._assert_course_owner(conn, user, course_id): return new_code = _generate_unique_join_code(conn, exclude_course_id=course_id) conn.execute("UPDATE course SET join_code=? WHERE id=?", (new_code, course_id)) conn.commit() finally: conn.close() return json_response(self, {"join_code": new_code}) def _handle_create_submission(self): try: return self._handle_create_submission_inner() except Exception: traceback.print_exc() return json_response(self, {"error": "submission failed"}, status=500) def _current_student_identity(self, conn, posted_name: str | None) -> tuple[str, int | None]: """Resolve (student_name, student_id) for a submission. Authenticated students get their own display_name and id — the client cannot impersonate another student by sending a different ``student_name`` (audit L1). Anonymous callers keep the legacy free-text name path. """ user = self._current_user(conn) if user and user.get("role") == "student": return (user["display_name"], user["id"]) return (normalize_student_name(posted_name or "Student"), None) def _assignment_course_id(self, conn, assignment_id: int) -> int | None: row = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (assignment_id,) ).fetchone() return int(row[0]) if row else None def _handle_create_submission_inner(self): content_type = self.headers.get("Content-Type") or "" try: length = int(self.headers.get("Content-Length", "0")) except (TypeError, ValueError): return json_response(self, {"error": "invalid Content-Length"}, status=400) if length < 0 or length > cfg.submission_upload_max_bytes: return json_response( self, { "error": ( f"submission exceeds limit (max {cfg.submission_upload_max_bytes} bytes)" ) }, status=413, ) if "multipart/form-data" in content_type: body = self.rfile.read(length) if length else b"" parts = parse_multipart_formdata(content_type, body) fields: dict[str, str] = {} file_part = None for part in parts: name = part.get("name") if not name: continue if part.get("filename"): file_part = part continue try: fields[str(name)] = (part.get("content") or b"").decode( "utf-8", errors="replace" ) except Exception: fields[str(name)] = "" assignment_id = fields.get("assignment_id") domain = normalize_domain(fields.get("domain")) text_content = fields.get("content", "") attachment_name = None extraction_warnings: list[str] = [] if file_part and file_part.get("filename"): safe_name = _safe_upload_filename(str(file_part.get("filename"))) if safe_name is None: return json_response(self, {"error": "invalid filename"}, status=400) import uuid as _uuid dest = UPLOAD_DIR / f"submission_{_uuid.uuid4().hex}_{safe_name}" try: dest.resolve().relative_to(UPLOAD_DIR.resolve()) except ValueError: return json_response(self, {"error": "invalid filename"}, status=400) dest.write_bytes(file_part.get("content") or b"") extracted = extract_runtime_text(dest) extraction_warnings = list(extracted.warnings) if not text_content.strip(): text_content = extracted.text else: text_content = ( f"{text_content.strip()}\n\n=== Вложение: {safe_name} ===\n\n" f"{extracted.text}" ) attachment_name = safe_name if not assignment_id: return json_response(self, {"error": "assignment_id required"}, status=400) try: assignment_id_int = int(assignment_id) except (TypeError, ValueError): return json_response(self, {"error": "invalid assignment_id"}, status=400) if not text_content.strip(): return json_response( self, {"error": "Нужен либо текст ответа, либо файл с содержимым."}, status=400, ) conn = connect_db() try: if not assignment_exists(conn, assignment_id_int): return json_response(self, {"error": "assignment not found"}, status=404) course_id = self._assignment_course_id(conn, assignment_id_int) or LEGACY_COURSE_ID user = self._current_user(conn) # Authenticated students must be enrolled to submit. if course_id != LEGACY_COURSE_ID: if not user: return json_response(self, {"error": "authentication required"}, status=401) if user.get("role") == "student" and not is_enrolled( conn, course_id, user["id"] ): return json_response(self, {"error": "not enrolled"}, status=403) student_name, student_id = self._current_student_identity( conn, fields.get("student_name") ) conn.execute( "INSERT INTO submission(assignment_id, student_name, content, created_at, " "status, domain, attachment_name, extraction_warnings, student_id) " "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", ( assignment_id_int, student_name, text_content.strip(), now_iso(), "submitted", domain, attachment_name, json.dumps(extraction_warnings, ensure_ascii=False) if extraction_warnings else None, student_id, ), ) conn.commit() finally: conn.close() return json_response( self, { "ok": True, "attachment_name": attachment_name, "extraction_warnings": extraction_warnings, "domain": domain, }, ) # Default: JSON body body = self.rfile.read(length).decode("utf-8") if length else "{}" try: data = json.loads(body) if body else {} except json.JSONDecodeError: return json_response(self, {"error": "invalid JSON"}, status=400) assignment_id = data.get("assignment_id") domain = normalize_domain(data.get("domain")) content = str(data.get("content", "")) if not assignment_id or not content.strip(): return json_response(self, {"error": "assignment_id and content required"}, status=400) try: assignment_id_int = int(assignment_id) except (TypeError, ValueError): return json_response(self, {"error": "invalid assignment_id"}, status=400) conn = connect_db() try: if not assignment_exists(conn, assignment_id_int): return json_response(self, {"error": "assignment not found"}, status=404) course_id = self._assignment_course_id(conn, assignment_id_int) or LEGACY_COURSE_ID user = self._current_user(conn) if course_id != LEGACY_COURSE_ID: if not user: return json_response(self, {"error": "authentication required"}, status=401) if user.get("role") == "student" and not is_enrolled(conn, course_id, user["id"]): return json_response(self, {"error": "not enrolled"}, status=403) student_name, student_id = self._current_student_identity( conn, data.get("student_name") ) conn.execute( "INSERT INTO submission(assignment_id, student_name, content, created_at, " "status, domain, student_id) VALUES(?, ?, ?, ?, ?, ?, ?)", ( assignment_id_int, student_name, content.strip(), now_iso(), "submitted", domain, student_id, ), ) conn.commit() finally: conn.close() return json_response(self, {"ok": True, "domain": domain}) def _do_PATCH(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing parsed = urlparse(self.path) path = parsed.path if path == "/api/auth/me": return self._handle_update_profile() if path.startswith("/api/assignments/"): rest = path[len("/api/assignments/") :] if not rest.isdigit(): return self.send_error(404) assignment_id = int(rest) data = self._read_json_body() if data is None: return conn = connect_db() try: user = self._current_user(conn) row = conn.execute( "SELECT course_id FROM assignment WHERE id=?", (assignment_id,) ).fetchone() if not row: return json_response(self, {"error": "not found"}, status=404) course_id = int(row[0]) if course_id != LEGACY_COURSE_ID: if not user or user.get("role") not in ("teacher", "admin"): return json_response(self, {"error": "teacher only"}, status=403) if not self._assert_course_access(conn, user, course_id): return ok = update_assignment( conn, assignment_id, description=data.get("description"), rubric=data.get("rubric"), title=data.get("title"), ) finally: conn.close() if not ok: return json_response(self, {"error": "not found"}, status=404) return json_response(self, {"ok": True}) if path.startswith("/api/courses/"): rest = path[len("/api/courses/") :] if rest.isdigit(): return self._handle_patch_course(int(rest)) self.send_error(404) def _do_DELETE(self): # noqa: N802 - mirrors BaseHTTPRequestHandler casing parsed = urlparse(self.path) path = parsed.path if path.startswith("/api/submissions/"): rest = path[len("/api/submissions/") :] if not rest.isdigit(): return self.send_error(404) submission_id = int(rest) conn = connect_db() try: user = self._current_user(conn) sub = conn.execute( "SELECT s.id, s.student_id, a.course_id FROM submission s " "JOIN assignment a ON a.id = s.assignment_id WHERE s.id=?", (submission_id,), ).fetchone() if not sub: return json_response(self, {"error": "not found"}, status=404) course_id = int(sub[2]) # Teacher or owning student may delete their submission. if course_id != LEGACY_COURSE_ID: if not user: return json_response(self, {"error": "authentication required"}, status=401) role = user.get("role") if role == "student": if sub[1] != user["id"]: return json_response(self, {"error": "forbidden"}, status=403) else: if not self._assert_course_access(conn, user, course_id): return ok = delete_submission(conn, submission_id) finally: conn.close() if not ok: return json_response(self, {"error": "not found"}, status=404) return json_response(self, {"ok": True}) if path.startswith("/api/courses/"): rest = path[len("/api/courses/") :] if "/" not in rest and rest.isdigit(): return self._handle_delete_course(int(rest)) if rest.endswith("/leave") and rest[: -len("/leave")].isdigit(): return self._handle_leave_course(int(rest[: -len("/leave")])) if "/enrollments/" in rest: course_str, _, student_str = rest.partition("/enrollments/") if course_str.isdigit() and student_str.isdigit(): return self._handle_unenroll(int(course_str), int(student_str)) self.send_error(404) # --- Profile / course management ---------------------------------------- def _handle_update_profile(self): user = self._require_user() if not user: return data = self._read_json_body() if data is None: return from prepodov_ai.webauth.passwords import ( PasswordError, hash_password, ) from prepodov_ai.webauth.passwords import ( verify_password as _verify, ) from prepodov_ai.webauth.repository import _normalize_display_name conn = connect_db() try: updates: list[str] = [] params: list = [] if "display_name" in data: try: display = _normalize_display_name(data.get("display_name")) except Exception as exc: return json_response(self, {"error": str(exc)}, status=400) updates.append("display_name=?") params.append(display) new_password = data.get("new_password") if new_password: current = data.get("current_password", "") row = conn.execute( "SELECT password_hash FROM user WHERE id=?", (user["id"],) ).fetchone() if not row or not _verify(current, row[0]): return json_response(self, {"error": "current password incorrect"}, status=400) try: new_hash = hash_password(new_password) except PasswordError as exc: return json_response(self, {"error": str(exc)}, status=400) updates.append("password_hash=?") params.append(new_hash) # Invalidate all other sessions for this user after password change. current_token = self._current_session_token() conn.execute( "DELETE FROM session WHERE user_id=? AND token<>?", (user["id"], current_token or ""), ) if not updates: return json_response(self, {"user": user}) params.append(user["id"]) conn.execute(f"UPDATE user SET {', '.join(updates)} WHERE id=?", tuple(params)) conn.commit() refreshed = conn.execute( "SELECT id, email, role, display_name FROM user WHERE id=?", (user["id"],), ).fetchone() finally: conn.close() if not refreshed: return json_response(self, {"error": "user missing"}, status=500) return json_response( self, { "user": { "id": int(refreshed[0]), "email": refreshed[1], "role": refreshed[2], "display_name": refreshed[3], } }, ) def _handle_delete_course(self, course_id: int): user = self._require_user(role="teacher") if not user: return conn = connect_db() try: if not self._assert_course_owner(conn, user, course_id): return # Rely on ON DELETE CASCADE for enrollments; assignments + # submissions are tied by course_id but lack a FK — delete # them explicitly in the correct order. conn.execute( "DELETE FROM submission WHERE assignment_id IN " "(SELECT id FROM assignment WHERE course_id=?)", (course_id,), ) conn.execute("DELETE FROM assignment WHERE course_id=?", (course_id,)) conn.execute("DELETE FROM course WHERE id=?", (course_id,)) conn.commit() finally: conn.close() return json_response(self, {"ok": True}) def _handle_patch_course(self, course_id: int): """Rename a course or change its subject_area / grade_level.""" user = self._require_user(role="teacher") if not user: return data = self._read_json_body() if data is None: return conn = connect_db() try: if not self._assert_course_owner(conn, user, course_id): return updates: list[str] = [] params: list = [] if "title" in data: clean = str(data.get("title") or "").strip() if not clean: return json_response(self, {"error": "title cannot be empty"}, status=400) updates.append("title=?") params.append(clean[:80]) if "subject_area" in data: updates.append("subject_area=?") params.append(normalize_domain(data.get("subject_area"))) if "grade_level" in data: grade = str(data.get("grade_level") or "").strip()[:40] or None updates.append("grade_level=?") params.append(grade) if not updates: return json_response(self, {"ok": True, "changed": False}) updates.append("updated_at=?") params.append(now_iso()) params.append(course_id) conn.execute(f"UPDATE course SET {', '.join(updates)} WHERE id=?", tuple(params)) conn.commit() finally: conn.close() return json_response(self, {"ok": True, "changed": True}) def _assert_course_owner(self, conn, user, course_id: int) -> bool: """Shared guard: teacher must own the course (or be admin).""" owner = get_course_owner(conn, course_id) if owner is None: json_response(self, {"error": "course not found"}, status=404) return False if owner != user["id"] and user.get("role") != "admin": json_response(self, {"error": "not your course"}, status=403) return False return True def _handle_leave_course(self, course_id: int): """Student leaves a course — self-service unenroll. Safer to ship as a dedicated endpoint than to reuse the teacher-owned ``DELETE /api/courses/{id}/enrollments/{student_id}`` because the teacher endpoint is authorized through course ownership, not the student's identity. Keeps the authorization clauses distinct. """ user = self._require_user(role="student") if not user: return conn = connect_db() try: if not is_enrolled(conn, course_id, user["id"]): return json_response(self, {"error": "not enrolled"}, status=404) conn.execute( "DELETE FROM enrollment WHERE course_id=? AND student_id=?", (course_id, user["id"]), ) conn.commit() finally: conn.close() return json_response(self, {"ok": True}) def _handle_unenroll(self, course_id: int, student_id: int): user = self._require_user(role="teacher") if not user: return conn = connect_db() try: if not self._assert_course_owner(conn, user, course_id): return conn.execute( "DELETE FROM enrollment WHERE course_id=? AND student_id=?", (course_id, student_id), ) conn.commit() finally: conn.close() return json_response(self, {"ok": True}) def log_message(self, fmt, *args): # Request logs go through the ``http`` logger so the request id, IP and # structured-vs-plain format all come from one place. rid = REQUEST_ID.get() logging.getLogger("prepodov.http").info( fmt % args, extra={"request_id": rid, "client_ip": self._client_ip()}, ) # --------------------------------------------------------------------------- # Request IDs + structured logging # --------------------------------------------------------------------------- REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") class _RequestIdFilter(logging.Filter): """Inject the current request ID into every log record.""" def filter(self, record: logging.LogRecord) -> bool: if not hasattr(record, "request_id"): record.request_id = REQUEST_ID.get() if not hasattr(record, "client_ip"): record.client_ip = "-" return True class _JsonFormatter(logging.Formatter): """One-line JSON per log record — cheap structured logs, no extra deps.""" def format(self, record: logging.LogRecord) -> str: payload = { "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"), "level": record.levelname, "name": record.name, "request_id": getattr(record, "request_id", "-"), "client_ip": getattr(record, "client_ip", "-"), "msg": record.getMessage(), } if record.exc_info: payload["exc"] = self.formatException(record.exc_info) return json.dumps(payload, ensure_ascii=False) def _configure_logging() -> None: """Wire up the root logger exactly once. Plain format for local dev (easy to read); JSON structured format when ``PREPODOV_STRUCTURED_LOGS=true``, which cloud log aggregators parse natively. """ root = logging.getLogger() # Replace any handlers an import may have registered, idempotently. for h in list(root.handlers): root.removeHandler(h) handler = logging.StreamHandler(sys.stderr) handler.addFilter(_RequestIdFilter()) if cfg.structured_logs: handler.setFormatter(_JsonFormatter()) else: handler.setFormatter( logging.Formatter( "%(asctime)s [%(levelname)s] %(client_ip)s req=%(request_id)s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) ) root.addHandler(handler) try: root.setLevel(getattr(logging, str(cfg.log_level).upper(), logging.INFO)) except Exception: root.setLevel(logging.INFO) # --------------------------------------------------------------------------- # Graceful shutdown # --------------------------------------------------------------------------- def _install_shutdown_handlers(server: ThreadingHTTPServer, stop_event) -> None: """Install SIGINT/SIGTERM handlers that stop the server cleanly. Cross-platform: SIGTERM doesn't exist on older Windows Python builds, so we ignore AttributeError on those signals. ``server.shutdown()`` blocks until ``serve_forever`` exits, which deadlocks if called from the main thread inside the signal handler — spin it off a daemon worker. """ import signal as _signal def _handler(signum, _frame): name = {_signal.SIGINT: "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get( signum, str(signum) ) log.info("shutdown signal received: %s", name) stop_event.set() threading.Thread(target=server.shutdown, daemon=True, name="server-shutdown").start() for sig_name in ("SIGINT", "SIGTERM", "SIGBREAK"): sig = getattr(_signal, sig_name, None) if sig is None: continue try: _signal.signal(sig, _handler) except (ValueError, OSError, AttributeError): # Some environments (Windows Python without console, certain # threads) can't register signal handlers — best-effort only. pass log = logging.getLogger("prepodov") def main(): """Process entry point: configure logging, init DB, start threaded server. Startup sequence: 1. Configure logging (plain or JSON; honours PREPODOV_LOG_LEVEL). 2. Ensure data/ and data/uploads/ exist (idempotent). 3. Run schema bootstrap + PRAGMAs on data/app.db. 4. Log effective config (tesseract path, data dir, LLM backend). 5. Bind host:port and serve until SIGINT/SIGTERM arrives. All runtime parameters come from ``cfg`` (see runtime_config.py). To override without touching code: edit ``.env`` or export ``PREPODOV_*`` environment variables before running. """ _configure_logging() DATA_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) init_db() log.info("startup: tesseract=%s", TESSERACT_CMD or "NOT FOUND — OCR disabled") log.info("startup: data_dir=%s db=%s", DATA_DIR, DB_PATH) host = cfg.host port = cfg.port server = ThreadingHTTPServer((host, port), Handler) log.info("startup: listening on http://%s:%s (backend: %s)", host, port, LLM_BACKEND) stop_event = threading.Event() _install_shutdown_handlers(server, stop_event) try: server.serve_forever() except KeyboardInterrupt: stop_event.set() finally: try: server.server_close() except Exception: pass log.info("shutdown: server closed") if __name__ == "__main__": main()