fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
377
backend/klausur/db_models.py
Normal file
377
backend/klausur/db_models.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
SQLAlchemy Database Models for Klausurkorrektur Module.
|
||||
|
||||
Privacy-by-Design: No personal data (student names) is stored in these models.
|
||||
Only pseudonymized doc_tokens are used to reference exam documents.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, DateTime, JSON,
|
||||
Boolean, Text, Enum as SQLEnum, ForeignKey, LargeBinary
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class SessionStatus(str, enum.Enum):
|
||||
"""Status of an exam correction session."""
|
||||
CREATED = "created" # Session created, awaiting uploads
|
||||
UPLOADING = "uploading" # Documents being uploaded
|
||||
PROCESSING = "processing" # OCR and AI correction in progress
|
||||
COMPLETED = "completed" # All documents processed
|
||||
ARCHIVED = "archived" # Session archived (data retention)
|
||||
DELETED = "deleted" # Soft delete
|
||||
|
||||
|
||||
class OnboardingStatus(str, enum.Enum):
|
||||
"""Status of a magic onboarding session."""
|
||||
ANALYZING = "analyzing" # Local LLM extracting headers
|
||||
CONFIRMING = "confirming" # User confirming detected data
|
||||
PROCESSING = "processing" # Cloud LLM correcting exams
|
||||
LINKING = "linking" # Creating module links
|
||||
COMPLETE = "complete" # Onboarding finished
|
||||
|
||||
|
||||
class ModuleLinkType(str, enum.Enum):
|
||||
"""Type of cross-module link."""
|
||||
NOTENBUCH = "notenbuch" # Link to grade book
|
||||
ELTERNABEND = "elternabend" # Link to parent meetings
|
||||
ZEUGNIS = "zeugnis" # Link to certificates
|
||||
CALENDAR = "calendar" # Link to calendar events
|
||||
KLASSENBUCH = "klassenbuch" # Link to class book
|
||||
|
||||
|
||||
class DocumentStatus(str, enum.Enum):
|
||||
"""Status of a single pseudonymized document."""
|
||||
UPLOADED = "uploaded" # Document uploaded, awaiting OCR
|
||||
OCR_PROCESSING = "ocr_processing" # OCR in progress
|
||||
OCR_COMPLETED = "ocr_completed" # OCR done, awaiting AI correction
|
||||
AI_PROCESSING = "ai_processing" # AI correction in progress
|
||||
COMPLETED = "completed" # Fully processed
|
||||
FAILED = "failed" # Processing failed
|
||||
|
||||
|
||||
class ExamSession(Base):
|
||||
"""
|
||||
Exam Correction Session.
|
||||
|
||||
Groups multiple pseudonymized documents for a single exam correction task.
|
||||
No personal data is stored - teacher_id is the only identifying info.
|
||||
"""
|
||||
__tablename__ = 'klausur_sessions'
|
||||
|
||||
# Primary Key
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Teacher isolation (mandatory)
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Session metadata
|
||||
name = Column(String(200), nullable=False) # e.g., "Mathe 10a - Klausur 1"
|
||||
subject = Column(String(100), default="")
|
||||
class_name = Column(String(100), default="") # e.g., "10a"
|
||||
|
||||
# Exam configuration
|
||||
total_points = Column(Integer, default=100)
|
||||
rubric = Column(Text, default="") # Bewertungskriterien
|
||||
questions = Column(JSON, default=list) # [{question, points, rubric}]
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
SQLEnum(SessionStatus),
|
||||
default=SessionStatus.CREATED,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Statistics (anonymized)
|
||||
document_count = Column(Integer, default=0)
|
||||
processed_count = Column(Integer, default=0)
|
||||
|
||||
# Encrypted identity map (only teacher can decrypt)
|
||||
# This is stored encrypted with teacher's password
|
||||
encrypted_identity_map = Column(LargeBinary, nullable=True)
|
||||
identity_map_iv = Column(String(64), nullable=True) # IV for AES decryption
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Data retention: auto-delete after this date
|
||||
retention_until = Column(DateTime, nullable=True)
|
||||
|
||||
# Magic Onboarding: Link to school class (optional)
|
||||
linked_school_class_id = Column(String(36), nullable=True)
|
||||
linked_subject_id = Column(String(36), nullable=True)
|
||||
|
||||
# Relationship to documents
|
||||
documents = relationship(
|
||||
"PseudonymizedDocument",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ExamSession {self.id[:8]}: {self.name} ({self.status.value})>"
|
||||
|
||||
|
||||
class PseudonymizedDocument(Base):
|
||||
"""
|
||||
Pseudonymized Exam Document.
|
||||
|
||||
PRIVACY DESIGN:
|
||||
- doc_token is a 128-bit random UUID, NOT derivable from student identity
|
||||
- No student name or personal info is stored here
|
||||
- Identity mapping is stored encrypted in ExamSession.encrypted_identity_map
|
||||
- The backend CANNOT de-pseudonymize documents
|
||||
|
||||
Only the teacher (with their encryption key) can map doc_token -> student name.
|
||||
"""
|
||||
__tablename__ = 'klausur_documents'
|
||||
|
||||
# Primary Key: The pseudonymization token
|
||||
doc_token = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Session relationship
|
||||
session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=False, index=True)
|
||||
|
||||
# Processing status
|
||||
status = Column(
|
||||
SQLEnum(DocumentStatus),
|
||||
default=DocumentStatus.UPLOADED,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Page info
|
||||
page_number = Column(Integer, default=1)
|
||||
total_pages = Column(Integer, default=1)
|
||||
|
||||
# OCR result (redacted - no header/name visible)
|
||||
ocr_text = Column(Text, default="")
|
||||
ocr_confidence = Column(Integer, default=0) # 0-100
|
||||
|
||||
# AI correction result (pseudonymized)
|
||||
ai_feedback = Column(Text, default="")
|
||||
ai_score = Column(Integer, nullable=True) # Points achieved
|
||||
ai_grade = Column(String(10), nullable=True) # e.g., "2+" or "B"
|
||||
ai_details = Column(JSON, default=dict) # Per-question scores
|
||||
|
||||
# Processing metadata
|
||||
processing_started_at = Column(DateTime, nullable=True)
|
||||
processing_completed_at = Column(DateTime, nullable=True)
|
||||
processing_error = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
session = relationship("ExamSession", back_populates="documents")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PseudonymizedDocument {self.doc_token[:8]} ({self.status.value})>"
|
||||
|
||||
|
||||
class QRBatchJob(Base):
|
||||
"""
|
||||
QR Code Generation Batch Job.
|
||||
|
||||
Tracks generation of QR overlay sheets for printing.
|
||||
The generated PDF contains QR codes with doc_tokens.
|
||||
"""
|
||||
__tablename__ = 'klausur_qr_batches'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Session relationship
|
||||
session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=False, index=True)
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Batch info
|
||||
student_count = Column(Integer, nullable=False)
|
||||
generated_tokens = Column(JSON, default=list) # List of generated doc_tokens
|
||||
|
||||
# Generated PDF (stored as path reference, not in DB)
|
||||
pdf_path = Column(String(500), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
downloaded_at = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<QRBatchJob {self.id[:8]}: {self.student_count} students>"
|
||||
|
||||
|
||||
class OnboardingSession(Base):
|
||||
"""
|
||||
Magic Onboarding Session.
|
||||
|
||||
Tracks the automatic class/student detection and setup process.
|
||||
Temporary data structure - merged into ExamSession after confirmation.
|
||||
"""
|
||||
__tablename__ = 'klausur_onboarding_sessions'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Links
|
||||
klausur_session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=True)
|
||||
teacher_id = Column(String(100), nullable=False, index=True)
|
||||
|
||||
# Detected metadata (from local LLM)
|
||||
detected_class = Column(String(100), nullable=True)
|
||||
detected_subject = Column(String(100), nullable=True)
|
||||
detected_date = Column(DateTime, nullable=True)
|
||||
detected_student_count = Column(Integer, default=0)
|
||||
detection_confidence = Column(Integer, default=0) # 0-100
|
||||
|
||||
# Confirmed data (after user review)
|
||||
confirmed_class = Column(String(100), nullable=True)
|
||||
confirmed_subject = Column(String(100), nullable=True)
|
||||
|
||||
# Linked school entities (after confirmation)
|
||||
linked_school_id = Column(String(36), nullable=True)
|
||||
linked_class_id = Column(String(36), nullable=True)
|
||||
|
||||
# School context
|
||||
bundesland = Column(String(50), nullable=True)
|
||||
schulform = Column(String(50), nullable=True)
|
||||
school_name = Column(String(200), nullable=True)
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
SQLEnum(OnboardingStatus),
|
||||
default=OnboardingStatus.ANALYZING,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Progress tracking
|
||||
analysis_completed_at = Column(DateTime, nullable=True)
|
||||
confirmation_completed_at = Column(DateTime, nullable=True)
|
||||
processing_started_at = Column(DateTime, nullable=True)
|
||||
processing_completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
detected_students = relationship(
|
||||
"DetectedStudent",
|
||||
back_populates="onboarding_session",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OnboardingSession {self.id[:8]}: {self.detected_class} ({self.status.value})>"
|
||||
|
||||
|
||||
class DetectedStudent(Base):
|
||||
"""
|
||||
Student detected during Magic Onboarding.
|
||||
|
||||
Temporary storage for detected student data before confirmation.
|
||||
After confirmation, students are created in the School Service.
|
||||
"""
|
||||
__tablename__ = 'klausur_detected_students'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Onboarding session
|
||||
onboarding_session_id = Column(
|
||||
String(36),
|
||||
ForeignKey('klausur_onboarding_sessions.id'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Detected data (from exam header)
|
||||
detected_first_name = Column(String(100), nullable=True)
|
||||
detected_last_name_hint = Column(String(100), nullable=True) # Partial, e.g. "M."
|
||||
|
||||
# Confirmed data (after roster matching)
|
||||
confirmed_first_name = Column(String(100), nullable=True)
|
||||
confirmed_last_name = Column(String(100), nullable=True)
|
||||
|
||||
# Matched to School Service student
|
||||
matched_student_id = Column(String(36), nullable=True)
|
||||
|
||||
# Parent contact (extracted from roster)
|
||||
parent_email = Column(String(200), nullable=True)
|
||||
parent_phone = Column(String(50), nullable=True)
|
||||
|
||||
# Link to pseudonymized document
|
||||
doc_token = Column(String(36), nullable=True)
|
||||
|
||||
# Confidence
|
||||
confidence = Column(Integer, default=0) # 0-100
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
onboarding_session = relationship("OnboardingSession", back_populates="detected_students")
|
||||
|
||||
def __repr__(self):
|
||||
name = self.confirmed_first_name or self.detected_first_name or "?"
|
||||
return f"<DetectedStudent {self.id[:8]}: {name}>"
|
||||
|
||||
|
||||
class ModuleLink(Base):
|
||||
"""
|
||||
Cross-module link from Klausur to other BreakPilot modules.
|
||||
|
||||
Tracks connections to: Notenbuch, Elternabend, Zeugnis, Calendar
|
||||
"""
|
||||
__tablename__ = 'klausur_module_links'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Source
|
||||
klausur_session_id = Column(
|
||||
String(36),
|
||||
ForeignKey('klausur_sessions.id'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Link type
|
||||
link_type = Column(
|
||||
SQLEnum(ModuleLinkType),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Target
|
||||
target_module = Column(String(50), nullable=False) # school, calendar, etc.
|
||||
target_entity_id = Column(String(36), nullable=True)
|
||||
target_url = Column(String(500), nullable=True)
|
||||
|
||||
# Link metadata
|
||||
link_metadata = Column(JSON, default=dict)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleLink {self.id[:8]}: {self.link_type.value} -> {self.target_module}>"
|
||||
|
||||
|
||||
# Export all models
|
||||
__all__ = [
|
||||
"SessionStatus",
|
||||
"DocumentStatus",
|
||||
"OnboardingStatus",
|
||||
"ModuleLinkType",
|
||||
"ExamSession",
|
||||
"PseudonymizedDocument",
|
||||
"QRBatchJob",
|
||||
"OnboardingSession",
|
||||
"DetectedStudent",
|
||||
"ModuleLink",
|
||||
]
|
||||
Reference in New Issue
Block a user