""" 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