This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/klausur/repository.py
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

378 lines
12 KiB
Python

"""
Repository for Klausurkorrektur Module.
All queries are filtered by teacher_id to ensure complete namespace isolation.
No cross-teacher data access is possible.
"""
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from .db_models import (
ExamSession, PseudonymizedDocument, QRBatchJob,
SessionStatus, DocumentStatus
)
class KlausurRepository:
"""
Repository for exam correction data.
PRIVACY DESIGN:
- All queries MUST include teacher_id filter
- No method allows access to other teachers' data
- Bulk operations are scoped to teacher namespace
"""
def __init__(self, db: Session):
self.db = db
# ==================== Session Operations ====================
def create_session(
self,
teacher_id: str,
name: str,
subject: str = "",
class_name: str = "",
total_points: int = 100,
rubric: str = "",
questions: Optional[List[dict]] = None,
retention_days: int = 30
) -> ExamSession:
"""Create a new exam correction session."""
session = ExamSession(
teacher_id=teacher_id,
name=name,
subject=subject,
class_name=class_name,
total_points=total_points,
rubric=rubric,
questions=questions or [],
status=SessionStatus.CREATED,
retention_until=datetime.utcnow() + timedelta(days=retention_days)
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def get_session(
self,
session_id: str,
teacher_id: str
) -> Optional[ExamSession]:
"""Get a session by ID (teacher-scoped)."""
return self.db.query(ExamSession).filter(
and_(
ExamSession.id == session_id,
ExamSession.teacher_id == teacher_id,
ExamSession.status != SessionStatus.DELETED
)
).first()
def list_sessions(
self,
teacher_id: str,
include_archived: bool = False,
limit: int = 50,
offset: int = 0
) -> List[ExamSession]:
"""List all sessions for a teacher."""
query = self.db.query(ExamSession).filter(
and_(
ExamSession.teacher_id == teacher_id,
ExamSession.status != SessionStatus.DELETED
)
)
if not include_archived:
query = query.filter(ExamSession.status != SessionStatus.ARCHIVED)
return query.order_by(ExamSession.created_at.desc()).offset(offset).limit(limit).all()
def update_session_status(
self,
session_id: str,
teacher_id: str,
status: SessionStatus
) -> Optional[ExamSession]:
"""Update session status."""
session = self.get_session(session_id, teacher_id)
if session:
session.status = status
if status == SessionStatus.COMPLETED:
session.completed_at = datetime.utcnow()
self.db.commit()
self.db.refresh(session)
return session
def update_session_identity_map(
self,
session_id: str,
teacher_id: str,
encrypted_map: bytes,
iv: str
) -> Optional[ExamSession]:
"""Store encrypted identity map (teacher-scoped)."""
session = self.get_session(session_id, teacher_id)
if session:
session.encrypted_identity_map = encrypted_map
session.identity_map_iv = iv
self.db.commit()
self.db.refresh(session)
return session
def delete_session(
self,
session_id: str,
teacher_id: str,
hard_delete: bool = False
) -> bool:
"""Delete a session (soft or hard delete)."""
session = self.get_session(session_id, teacher_id)
if not session:
return False
if hard_delete:
self.db.delete(session)
else:
session.status = SessionStatus.DELETED
self.db.commit()
return True
# ==================== Document Operations ====================
def create_document(
self,
session_id: str,
teacher_id: str,
doc_token: Optional[str] = None,
page_number: int = 1,
total_pages: int = 1
) -> Optional[PseudonymizedDocument]:
"""Create a new pseudonymized document."""
# Verify session belongs to teacher
session = self.get_session(session_id, teacher_id)
if not session:
return None
doc = PseudonymizedDocument(
session_id=session_id,
page_number=page_number,
total_pages=total_pages,
status=DocumentStatus.UPLOADED
)
if doc_token:
doc.doc_token = doc_token
self.db.add(doc)
# Update session document count
session.document_count += 1
self.db.commit()
self.db.refresh(doc)
return doc
def get_document(
self,
doc_token: str,
teacher_id: str
) -> Optional[PseudonymizedDocument]:
"""Get a document by token (teacher-scoped via session)."""
return self.db.query(PseudonymizedDocument).join(
ExamSession
).filter(
and_(
PseudonymizedDocument.doc_token == doc_token,
ExamSession.teacher_id == teacher_id,
ExamSession.status != SessionStatus.DELETED
)
).first()
def list_documents(
self,
session_id: str,
teacher_id: str
) -> List[PseudonymizedDocument]:
"""List all documents in a session (teacher-scoped)."""
# Verify session belongs to teacher
session = self.get_session(session_id, teacher_id)
if not session:
return []
return self.db.query(PseudonymizedDocument).filter(
PseudonymizedDocument.session_id == session_id
).order_by(PseudonymizedDocument.created_at).all()
def update_document_ocr(
self,
doc_token: str,
teacher_id: str,
ocr_text: str,
confidence: int = 0
) -> Optional[PseudonymizedDocument]:
"""Update document with OCR results."""
doc = self.get_document(doc_token, teacher_id)
if doc:
doc.ocr_text = ocr_text
doc.ocr_confidence = confidence
doc.status = DocumentStatus.OCR_COMPLETED
self.db.commit()
self.db.refresh(doc)
return doc
def update_document_ai_result(
self,
doc_token: str,
teacher_id: str,
feedback: str,
score: Optional[int] = None,
grade: Optional[str] = None,
details: Optional[dict] = None
) -> Optional[PseudonymizedDocument]:
"""Update document with AI correction results."""
doc = self.get_document(doc_token, teacher_id)
if doc:
doc.ai_feedback = feedback
doc.ai_score = score
doc.ai_grade = grade
doc.ai_details = details or {}
doc.status = DocumentStatus.COMPLETED
doc.processing_completed_at = datetime.utcnow()
# Update session processed count
session = doc.session
session.processed_count += 1
# Check if all documents are processed
if session.processed_count >= session.document_count:
session.status = SessionStatus.COMPLETED
session.completed_at = datetime.utcnow()
self.db.commit()
self.db.refresh(doc)
return doc
def update_document_status(
self,
doc_token: str,
teacher_id: str,
status: DocumentStatus,
error: Optional[str] = None
) -> Optional[PseudonymizedDocument]:
"""Update document processing status."""
doc = self.get_document(doc_token, teacher_id)
if doc:
doc.status = status
if error:
doc.processing_error = error
if status in [DocumentStatus.OCR_PROCESSING, DocumentStatus.AI_PROCESSING]:
doc.processing_started_at = datetime.utcnow()
self.db.commit()
self.db.refresh(doc)
return doc
# ==================== QR Batch Operations ====================
def create_qr_batch(
self,
session_id: str,
teacher_id: str,
student_count: int,
generated_tokens: List[str]
) -> Optional[QRBatchJob]:
"""Create a QR code batch job."""
# Verify session belongs to teacher
session = self.get_session(session_id, teacher_id)
if not session:
return None
batch = QRBatchJob(
session_id=session_id,
teacher_id=teacher_id,
student_count=student_count,
generated_tokens=generated_tokens
)
self.db.add(batch)
self.db.commit()
self.db.refresh(batch)
return batch
def get_qr_batch(
self,
batch_id: str,
teacher_id: str
) -> Optional[QRBatchJob]:
"""Get a QR batch by ID (teacher-scoped)."""
return self.db.query(QRBatchJob).filter(
and_(
QRBatchJob.id == batch_id,
QRBatchJob.teacher_id == teacher_id
)
).first()
# ==================== Statistics (Anonymized) ====================
def get_session_stats(
self,
session_id: str,
teacher_id: str
) -> dict:
"""Get anonymized statistics for a session."""
session = self.get_session(session_id, teacher_id)
if not session:
return {}
# Count documents by status
status_counts = self.db.query(
PseudonymizedDocument.status,
func.count(PseudonymizedDocument.doc_token)
).filter(
PseudonymizedDocument.session_id == session_id
).group_by(PseudonymizedDocument.status).all()
# Score statistics (anonymized)
score_stats = self.db.query(
func.avg(PseudonymizedDocument.ai_score),
func.min(PseudonymizedDocument.ai_score),
func.max(PseudonymizedDocument.ai_score)
).filter(
and_(
PseudonymizedDocument.session_id == session_id,
PseudonymizedDocument.ai_score.isnot(None)
)
).first()
return {
"session_id": session_id,
"total_documents": session.document_count,
"processed_documents": session.processed_count,
"status_breakdown": {s.value: c for s, c in status_counts},
"score_average": float(score_stats[0]) if score_stats[0] else None,
"score_min": score_stats[1],
"score_max": score_stats[2]
}
# ==================== Data Retention ====================
def cleanup_expired_sessions(self) -> int:
"""Delete sessions past their retention date. Returns count deleted."""
now = datetime.utcnow()
expired = self.db.query(ExamSession).filter(
and_(
ExamSession.retention_until < now,
ExamSession.status != SessionStatus.DELETED
)
).all()
count = len(expired)
for session in expired:
session.status = SessionStatus.DELETED
# Clear sensitive data
session.encrypted_identity_map = None
session.identity_map_iv = None
self.db.commit()
return count