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/repository.py
Normal file
377
backend/klausur/repository.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user