Files
breakpilot-lehrer/klausur-service/backend/routes/eh_invitations.py
Benjamin Admin 6811264756 [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>
2026-04-24 23:17:30 +02:00

344 lines
10 KiB
Python

"""
BYOEH Invitation Flow Routes
Endpoints for inviting users, listing/accepting/declining/revoking
invitations to access Erwartungshorizonte.
Extracted from routes/eh.py for file-size compliance.
"""
import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Request
from models.eh import EHKeyShare, EHShareInvitation
from models.requests import EHInviteRequest, EHAcceptInviteRequest
from services.auth_service import get_current_user
from services.eh_service import log_eh_audit
import storage
router = APIRouter()
# =============================================
# INVITATION FLOW
# =============================================
@router.post("/api/v1/eh/{eh_id}/invite")
async def invite_to_eh(
eh_id: str,
invite_request: EHInviteRequest,
request: Request
):
"""
Invite another user to access an Erwartungshorizont.
This creates a pending invitation that the recipient must accept.
"""
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 invite others")
# Validate role
valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head', 'fachvorsitz']
if invite_request.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}")
# Check for existing pending invitation to same user
for inv in storage.eh_invitations_db.values():
if (inv.eh_id == eh_id and
inv.invitee_email == invite_request.invitee_email and
inv.status == 'pending'):
raise HTTPException(
status_code=409,
detail="Pending invitation already exists for this user"
)
# Create invitation
invitation_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
expires_at = now + timedelta(days=invite_request.expires_in_days)
invitation = EHShareInvitation(
id=invitation_id,
eh_id=eh_id,
inviter_id=user["user_id"],
invitee_id=invite_request.invitee_id or "",
invitee_email=invite_request.invitee_email,
role=invite_request.role,
klausur_id=invite_request.klausur_id,
message=invite_request.message,
status='pending',
expires_at=expires_at,
created_at=now,
accepted_at=None,
declined_at=None
)
storage.eh_invitations_db[invitation_id] = invitation
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user["user_id"],
action="invite",
eh_id=eh_id,
details={
"invitation_id": invitation_id,
"invitee_email": invite_request.invitee_email,
"role": invite_request.role,
"expires_at": expires_at.isoformat()
}
)
return {
"status": "invited",
"invitation_id": invitation_id,
"eh_id": eh_id,
"invitee_email": invite_request.invitee_email,
"role": invite_request.role,
"expires_at": expires_at.isoformat(),
"eh_title": eh.title
}
@router.get("/api/v1/eh/invitations/pending")
async def list_pending_invitations(request: Request):
"""List all pending invitations for the current user."""
user = get_current_user(request)
user_email = user.get("email", "")
user_id = user["user_id"]
now = datetime.now(timezone.utc)
pending = []
for inv in storage.eh_invitations_db.values():
# Match by email or user_id
if (inv.invitee_email == user_email or inv.invitee_id == user_id):
if inv.status == 'pending' and inv.expires_at > now:
# Get EH info
eh_info = None
if inv.eh_id in storage.eh_db:
eh = storage.eh_db[inv.eh_id]
eh_info = {
"id": eh.id,
"title": eh.title,
"subject": eh.subject,
"niveau": eh.niveau,
"year": eh.year
}
pending.append({
"invitation": inv.to_dict(),
"eh": eh_info
})
return pending
@router.get("/api/v1/eh/invitations/sent")
async def list_sent_invitations(request: Request):
"""List all invitations sent by the current user."""
user = get_current_user(request)
user_id = user["user_id"]
sent = []
for inv in storage.eh_invitations_db.values():
if inv.inviter_id == user_id:
# Get EH info
eh_info = None
if inv.eh_id in storage.eh_db:
eh = storage.eh_db[inv.eh_id]
eh_info = {
"id": eh.id,
"title": eh.title,
"subject": eh.subject
}
sent.append({
"invitation": inv.to_dict(),
"eh": eh_info
})
return sent
@router.post("/api/v1/eh/invitations/{invitation_id}/accept")
async def accept_eh_invitation(
invitation_id: str,
accept_request: EHAcceptInviteRequest,
request: Request
):
"""Accept an invitation and receive access to the EH."""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
user_email = user.get("email", "")
user_id = user["user_id"]
now = datetime.now(timezone.utc)
# Find invitation
if invitation_id not in storage.eh_invitations_db:
raise HTTPException(status_code=404, detail="Invitation not found")
invitation = storage.eh_invitations_db[invitation_id]
# Verify recipient
if invitation.invitee_email != user_email and invitation.invitee_id != user_id:
raise HTTPException(status_code=403, detail="This invitation is not for you")
# Check status
if invitation.status != 'pending':
raise HTTPException(
status_code=400,
detail=f"Invitation is {invitation.status}, cannot accept"
)
# Check expiration
if invitation.expires_at < now:
invitation.status = 'expired'
raise HTTPException(status_code=400, detail="Invitation has expired")
# Create key share
share_id = str(uuid.uuid4())
key_share = EHKeyShare(
id=share_id,
eh_id=invitation.eh_id,
user_id=user_id,
encrypted_passphrase=accept_request.encrypted_passphrase,
passphrase_hint="",
granted_by=invitation.inviter_id,
granted_at=now,
role=invitation.role,
klausur_id=invitation.klausur_id,
active=True
)
# Store key share
if invitation.eh_id not in storage.eh_key_shares_db:
storage.eh_key_shares_db[invitation.eh_id] = []
storage.eh_key_shares_db[invitation.eh_id].append(key_share)
# Update invitation status
invitation.status = 'accepted'
invitation.accepted_at = now
invitation.invitee_id = user_id # Update with actual user ID
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user_id,
action="accept_invite",
eh_id=invitation.eh_id,
details={
"invitation_id": invitation_id,
"share_id": share_id,
"role": invitation.role
}
)
return {
"status": "accepted",
"share_id": share_id,
"eh_id": invitation.eh_id,
"role": invitation.role,
"klausur_id": invitation.klausur_id
}
@router.post("/api/v1/eh/invitations/{invitation_id}/decline")
async def decline_eh_invitation(invitation_id: str, request: Request):
"""Decline an invitation."""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
user_email = user.get("email", "")
user_id = user["user_id"]
now = datetime.now(timezone.utc)
# Find invitation
if invitation_id not in storage.eh_invitations_db:
raise HTTPException(status_code=404, detail="Invitation not found")
invitation = storage.eh_invitations_db[invitation_id]
# Verify recipient
if invitation.invitee_email != user_email and invitation.invitee_id != user_id:
raise HTTPException(status_code=403, detail="This invitation is not for you")
# Check status
if invitation.status != 'pending':
raise HTTPException(
status_code=400,
detail=f"Invitation is {invitation.status}, cannot decline"
)
# Update status
invitation.status = 'declined'
invitation.declined_at = now
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user_id,
action="decline_invite",
eh_id=invitation.eh_id,
details={"invitation_id": invitation_id}
)
return {
"status": "declined",
"invitation_id": invitation_id,
"eh_id": invitation.eh_id
}
@router.delete("/api/v1/eh/invitations/{invitation_id}")
async def revoke_eh_invitation(invitation_id: str, request: Request):
"""Revoke a pending invitation (by the inviter)."""
user = get_current_user(request)
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
user_id = user["user_id"]
# Find invitation
if invitation_id not in storage.eh_invitations_db:
raise HTTPException(status_code=404, detail="Invitation not found")
invitation = storage.eh_invitations_db[invitation_id]
# Verify inviter
if invitation.inviter_id != user_id:
raise HTTPException(status_code=403, detail="Only the inviter can revoke")
# Check status
if invitation.status != 'pending':
raise HTTPException(
status_code=400,
detail=f"Invitation is {invitation.status}, cannot revoke"
)
# Update status
invitation.status = 'revoked'
# Audit log
log_eh_audit(
tenant_id=tenant_id,
user_id=user_id,
action="revoke_invite",
eh_id=invitation.eh_id,
details={
"invitation_id": invitation_id,
"invitee_email": invitation.invitee_email
}
)
return {
"status": "revoked",
"invitation_id": invitation_id,
"eh_id": invitation.eh_id
}