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>
348 lines
11 KiB
Python
348 lines
11 KiB
Python
"""
|
|
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
|