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