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>
344 lines
10 KiB
Python
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
|
|
}
|