[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:
343
klausur-service/backend/routes/eh_invitations.py
Normal file
343
klausur-service/backend/routes/eh_invitations.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user