[split-required] Split final batch of monoliths >1000 LOC

Python (6 files in klausur-service):
- rbac.py (1,132 → 4), admin_api.py (1,012 → 4)
- routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5)

Python (2 files in backend-lehrer):
- unit_api.py (1,226 → 6), game_api.py (1,129 → 5)

Website (6 page files):
- 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components
  in website/components/klausur-korrektur/ (17 shared files)
- companion (1,057 → 10), magic-help (1,017 → 8)

All re-export barrels preserve backward compatibility.
Zero import errors verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:17:30 +02:00
parent b2a0126f14
commit 6811264756
67 changed files with 12270 additions and 13651 deletions

View File

@@ -0,0 +1,347 @@
"""
BYOEH Key Sharing and Klausur Linking Routes
Endpoints for sharing EH access with other examiners
and linking EH to Klausuren.
Extracted from routes/eh.py for file-size compliance.
"""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from models.eh import EHKeyShare, EHKlausurLink
from models.requests import EHShareRequest, EHLinkKlausurRequest
from services.auth_service import get_current_user
from services.eh_service import log_eh_audit
import storage
router = APIRouter()
# =============================================
# BYOEH KEY SHARING
# =============================================
@router.post("/api/v1/eh/{eh_id}/share")
async def share_erwartungshorizont(
eh_id: str,
share_request: EHShareRequest,
request: Request
):
"""
Share an Erwartungshorizont with another examiner.
The first examiner shares their EH by providing an encrypted passphrase
that the recipient can use.
"""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
# Check EH exists and belongs to user
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
if eh.teacher_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Only the owner can share this EH")
# Validate role
valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head']
if share_request.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}")
# Create key share entry
share_id = str(uuid.uuid4())
key_share = EHKeyShare(
id=share_id,
eh_id=eh_id,
user_id=share_request.user_id,
encrypted_passphrase=share_request.encrypted_passphrase,
passphrase_hint=share_request.passphrase_hint or "",
granted_by=user["user_id"],
granted_at=datetime.now(timezone.utc),
role=share_request.role,
klausur_id=share_request.klausur_id,
active=True
)
# Store in memory
if eh_id not in storage.eh_key_shares_db:
storage.eh_key_shares_db[eh_id] = []
storage.eh_key_shares_db[eh_id].append(key_share)
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="share",
eh_id=eh_id,
details={
"shared_with": share_request.user_id,
"role": share_request.role,
"klausur_id": share_request.klausur_id
}
)
return {
"status": "shared",
"share_id": share_id,
"eh_id": eh_id,
"shared_with": share_request.user_id,
"role": share_request.role
}
@router.get("/api/v1/eh/{eh_id}/shares")
async def list_eh_shares(eh_id: str, request: Request):
"""List all users who have access to an EH."""
user = get_current_user(request)
# Check EH exists and belongs to user
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
if eh.teacher_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Only the owner can view shares")
shares = storage.eh_key_shares_db.get(eh_id, [])
return [share.to_dict() for share in shares if share.active]
@router.delete("/api/v1/eh/{eh_id}/shares/{share_id}")
async def revoke_eh_share(eh_id: str, share_id: str, request: Request):
"""Revoke a shared EH access."""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
# Check EH exists and belongs to user
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
if eh.teacher_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Only the owner can revoke shares")
# Find and deactivate share
shares = storage.eh_key_shares_db.get(eh_id, [])
for share in shares:
if share.id == share_id:
share.active = False
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="revoke_share",
eh_id=eh_id,
details={"revoked_user": share.user_id, "share_id": share_id}
)
return {"status": "revoked", "share_id": share_id}
raise HTTPException(status_code=404, detail="Share not found")
# =============================================
# KLAUSUR LINKING
# =============================================
@router.post("/api/v1/eh/{eh_id}/link-klausur")
async def link_eh_to_klausur(
eh_id: str,
link_request: EHLinkKlausurRequest,
request: Request
):
"""
Link an Erwartungshorizont to a Klausur.
This creates an association between the EH and a specific Klausur.
"""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
# Check EH exists and user has access
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
user_has_access = (
eh.teacher_id == user["user_id"] or
any(
share.user_id == user["user_id"] and share.active
for share in storage.eh_key_shares_db.get(eh_id, [])
)
)
if not user_has_access:
raise HTTPException(status_code=403, detail="No access to this EH")
# Check Klausur exists
klausur_id = link_request.klausur_id
if klausur_id not in storage.klausuren_db:
raise HTTPException(status_code=404, detail="Klausur not found")
# Create link
link_id = str(uuid.uuid4())
link = EHKlausurLink(
id=link_id,
eh_id=eh_id,
klausur_id=klausur_id,
linked_by=user["user_id"],
linked_at=datetime.now(timezone.utc)
)
if klausur_id not in storage.eh_klausur_links_db:
storage.eh_klausur_links_db[klausur_id] = []
storage.eh_klausur_links_db[klausur_id].append(link)
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="link_klausur",
eh_id=eh_id,
details={"klausur_id": klausur_id}
)
return {
"status": "linked",
"link_id": link_id,
"eh_id": eh_id,
"klausur_id": klausur_id
}
@router.get("/api/v1/klausuren/{klausur_id}/linked-eh")
async def get_linked_eh(klausur_id: str, request: Request):
"""Get all EH linked to a specific Klausur."""
user = get_current_user(request)
user_id = user["user_id"]
# Check Klausur exists
if klausur_id not in storage.klausuren_db:
raise HTTPException(status_code=404, detail="Klausur not found")
# Get all links for this Klausur
links = storage.eh_klausur_links_db.get(klausur_id, [])
linked_ehs = []
for link in links:
if link.eh_id in storage.eh_db:
eh = storage.eh_db[link.eh_id]
# Check if user has access to this EH
is_owner = eh.teacher_id == user_id
is_shared = any(
share.user_id == user_id and share.active
for share in storage.eh_key_shares_db.get(link.eh_id, [])
)
if is_owner or is_shared:
# Find user's share info if shared
share_info = None
if is_shared:
for share in storage.eh_key_shares_db.get(link.eh_id, []):
if share.user_id == user_id and share.active:
share_info = share.to_dict()
break
linked_ehs.append({
"eh": eh.to_dict(),
"link": link.to_dict(),
"is_owner": is_owner,
"share": share_info
})
return linked_ehs
@router.delete("/api/v1/eh/{eh_id}/link-klausur/{klausur_id}")
async def unlink_eh_from_klausur(eh_id: str, klausur_id: str, request: Request):
"""Remove the link between an EH and a Klausur."""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
# Check EH exists and user has access
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
if eh.teacher_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Only the owner can unlink")
# Find and remove link
links = storage.eh_klausur_links_db.get(klausur_id, [])
for i, link in enumerate(links):
if link.eh_id == eh_id:
del links[i]
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="unlink_klausur",
eh_id=eh_id,
details={"klausur_id": klausur_id}
)
return {"status": "unlinked", "eh_id": eh_id, "klausur_id": klausur_id}
raise HTTPException(status_code=404, detail="Link not found")
@router.get("/api/v1/eh/{eh_id}/access-chain")
async def get_eh_access_chain(eh_id: str, request: Request):
"""
Get the complete access chain for an EH.
Shows the correction chain: EK -> ZK -> DK -> FVL
with their current access status.
"""
user = get_current_user(request)
# Check EH exists
if eh_id not in storage.eh_db:
raise HTTPException(status_code=404, detail="Erwartungshorizont not found")
eh = storage.eh_db[eh_id]
# Check access - owner or shared user
is_owner = eh.teacher_id == user["user_id"]
is_shared = any(
share.user_id == user["user_id"] and share.active
for share in storage.eh_key_shares_db.get(eh_id, [])
)
if not is_owner and not is_shared:
raise HTTPException(status_code=403, detail="No access to this EH")
# Build access chain
chain = {
"eh_id": eh_id,
"eh_title": eh.title,
"owner": {
"user_id": eh.teacher_id,
"role": "erstkorrektor"
},
"active_shares": [],
"pending_invitations": [],
"revoked_shares": []
}
# Active shares
for share in storage.eh_key_shares_db.get(eh_id, []):
share_dict = share.to_dict()
if share.active:
chain["active_shares"].append(share_dict)
else:
chain["revoked_shares"].append(share_dict)
# Pending invitations (only for owner)
if is_owner:
for inv in storage.eh_invitations_db.values():
if inv.eh_id == eh_id and inv.status == 'pending':
chain["pending_invitations"].append(inv.to_dict())
return chain