[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:
File diff suppressed because it is too large
Load Diff
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
|
||||
}
|
||||
347
klausur-service/backend/routes/eh_sharing.py
Normal file
347
klausur-service/backend/routes/eh_sharing.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
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
|
||||
455
klausur-service/backend/routes/eh_upload.py
Normal file
455
klausur-service/backend/routes/eh_upload.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
BYOEH Upload, List, and Core CRUD Routes
|
||||
|
||||
Endpoints for uploading, listing, getting, deleting,
|
||||
indexing, and RAG-querying Erwartungshorizonte.
|
||||
Extracted from routes/eh.py for file-size compliance.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
|
||||
|
||||
from models.enums import EHStatus
|
||||
from models.eh import (
|
||||
Erwartungshorizont,
|
||||
EHRightsConfirmation,
|
||||
)
|
||||
from models.requests import (
|
||||
EHUploadMetadata,
|
||||
EHRAGQuery,
|
||||
EHIndexRequest,
|
||||
)
|
||||
from services.auth_service import get_current_user
|
||||
from services.eh_service import log_eh_audit
|
||||
from config import EH_UPLOAD_DIR, OPENAI_API_KEY, ENVIRONMENT, RIGHTS_CONFIRMATION_TEXT
|
||||
import storage
|
||||
|
||||
# BYOEH imports
|
||||
from qdrant_service import (
|
||||
get_collection_info, delete_eh_vectors, search_eh, index_eh_chunks
|
||||
)
|
||||
from eh_pipeline import (
|
||||
decrypt_text, verify_key_hash, process_eh_for_indexing,
|
||||
generate_single_embedding, EncryptionError, EmbeddingError
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================
|
||||
# EH UPLOAD & LIST
|
||||
# =============================================
|
||||
|
||||
@router.post("/api/v1/eh/upload")
|
||||
async def upload_erwartungshorizont(
|
||||
file: UploadFile = File(...),
|
||||
metadata_json: str = Form(...),
|
||||
request: Request = None,
|
||||
background_tasks: BackgroundTasks = None
|
||||
):
|
||||
"""
|
||||
Upload an encrypted Erwartungshorizont.
|
||||
|
||||
The file MUST be client-side encrypted.
|
||||
Server stores only the encrypted blob + key hash (never the passphrase).
|
||||
"""
|
||||
user = get_current_user(request)
|
||||
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
|
||||
|
||||
try:
|
||||
data = EHUploadMetadata(**json.loads(metadata_json))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid metadata: {str(e)}")
|
||||
|
||||
if not data.rights_confirmed:
|
||||
raise HTTPException(status_code=400, detail="Rights confirmation required")
|
||||
|
||||
eh_id = str(uuid.uuid4())
|
||||
|
||||
# Create tenant-isolated directory
|
||||
upload_dir = f"{EH_UPLOAD_DIR}/{tenant_id}/{eh_id}"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Save encrypted file
|
||||
encrypted_path = f"{upload_dir}/encrypted.bin"
|
||||
content = await file.read()
|
||||
with open(encrypted_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Save salt separately
|
||||
with open(f"{upload_dir}/salt.txt", "w") as f:
|
||||
f.write(data.salt)
|
||||
|
||||
# Create EH record
|
||||
eh = Erwartungshorizont(
|
||||
id=eh_id,
|
||||
tenant_id=tenant_id,
|
||||
teacher_id=user["user_id"],
|
||||
title=data.metadata.title,
|
||||
subject=data.metadata.subject,
|
||||
niveau=data.metadata.niveau,
|
||||
year=data.metadata.year,
|
||||
aufgaben_nummer=data.metadata.aufgaben_nummer,
|
||||
encryption_key_hash=data.encryption_key_hash,
|
||||
salt=data.salt,
|
||||
encrypted_file_path=encrypted_path,
|
||||
file_size_bytes=len(content),
|
||||
original_filename=data.original_filename,
|
||||
rights_confirmed=True,
|
||||
rights_confirmed_at=datetime.now(timezone.utc),
|
||||
status=EHStatus.PENDING_RIGHTS,
|
||||
chunk_count=0,
|
||||
indexed_at=None,
|
||||
error_message=None,
|
||||
training_allowed=False, # ALWAYS FALSE - critical for compliance
|
||||
created_at=datetime.now(timezone.utc),
|
||||
deleted_at=None
|
||||
)
|
||||
|
||||
storage.eh_db[eh_id] = eh
|
||||
|
||||
# Store rights confirmation
|
||||
rights_confirmation = EHRightsConfirmation(
|
||||
id=str(uuid.uuid4()),
|
||||
eh_id=eh_id,
|
||||
teacher_id=user["user_id"],
|
||||
confirmation_type="upload",
|
||||
confirmation_text=RIGHTS_CONFIRMATION_TEXT,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
confirmed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
storage.eh_rights_db[rights_confirmation.id] = rights_confirmation
|
||||
|
||||
# Audit log
|
||||
log_eh_audit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user["user_id"],
|
||||
action="upload",
|
||||
eh_id=eh_id,
|
||||
details={
|
||||
"subject": data.metadata.subject,
|
||||
"year": data.metadata.year,
|
||||
"file_size": len(content)
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
|
||||
return eh.to_dict()
|
||||
|
||||
|
||||
@router.get("/api/v1/eh")
|
||||
async def list_erwartungshorizonte(
|
||||
request: Request,
|
||||
subject: Optional[str] = None,
|
||||
year: Optional[int] = None
|
||||
):
|
||||
"""List all Erwartungshorizonte for the current teacher."""
|
||||
user = get_current_user(request)
|
||||
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
|
||||
|
||||
results = []
|
||||
for eh in storage.eh_db.values():
|
||||
if eh.tenant_id == tenant_id and eh.deleted_at is None:
|
||||
if subject and eh.subject != subject:
|
||||
continue
|
||||
if year and eh.year != year:
|
||||
continue
|
||||
results.append(eh.to_dict())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================
|
||||
# SPECIFIC EH ROUTES (must come before {eh_id} catch-all)
|
||||
# =============================================
|
||||
|
||||
@router.get("/api/v1/eh/audit-log")
|
||||
async def get_eh_audit_log(
|
||||
request: Request,
|
||||
eh_id: Optional[str] = None,
|
||||
limit: int = 100
|
||||
):
|
||||
"""Get BYOEH audit log entries."""
|
||||
user = get_current_user(request)
|
||||
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
|
||||
|
||||
# Filter by tenant
|
||||
entries = [e for e in storage.eh_audit_db if e.tenant_id == tenant_id]
|
||||
|
||||
# Filter by EH if specified
|
||||
if eh_id:
|
||||
entries = [e for e in entries if e.eh_id == eh_id]
|
||||
|
||||
# Sort and limit
|
||||
entries = sorted(entries, key=lambda e: e.created_at, reverse=True)[:limit]
|
||||
|
||||
return [e.to_dict() for e in entries]
|
||||
|
||||
|
||||
@router.get("/api/v1/eh/rights-text")
|
||||
async def get_rights_confirmation_text():
|
||||
"""Get the rights confirmation text for display in UI."""
|
||||
return {
|
||||
"text": RIGHTS_CONFIRMATION_TEXT,
|
||||
"version": "v1.0"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/eh/qdrant-status")
|
||||
async def get_qdrant_status(request: Request):
|
||||
"""Get Qdrant collection status (admin only)."""
|
||||
user = get_current_user(request)
|
||||
if user.get("role") != "admin" and ENVIRONMENT != "development":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
return await get_collection_info()
|
||||
|
||||
|
||||
@router.get("/api/v1/eh/shared-with-me")
|
||||
async def list_shared_eh(request: Request):
|
||||
"""List all EH shared with the current user."""
|
||||
user = get_current_user(request)
|
||||
user_id = user["user_id"]
|
||||
|
||||
shared_ehs = []
|
||||
for eh_id, shares in storage.eh_key_shares_db.items():
|
||||
for share in shares:
|
||||
if share.user_id == user_id and share.active:
|
||||
if eh_id in storage.eh_db:
|
||||
eh = storage.eh_db[eh_id]
|
||||
shared_ehs.append({
|
||||
"eh": eh.to_dict(),
|
||||
"share": share.to_dict()
|
||||
})
|
||||
|
||||
return shared_ehs
|
||||
|
||||
|
||||
# =============================================
|
||||
# GENERIC EH ROUTES
|
||||
# =============================================
|
||||
|
||||
@router.get("/api/v1/eh/{eh_id}")
|
||||
async def get_erwartungshorizont(eh_id: str, request: Request):
|
||||
"""Get a specific Erwartungshorizont by ID."""
|
||||
user = get_current_user(request)
|
||||
|
||||
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"] and user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if eh.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="Erwartungshorizont was deleted")
|
||||
|
||||
return eh.to_dict()
|
||||
|
||||
|
||||
@router.delete("/api/v1/eh/{eh_id}")
|
||||
async def delete_erwartungshorizont(eh_id: str, request: Request):
|
||||
"""Soft-delete an Erwartungshorizont and remove vectors from Qdrant."""
|
||||
user = get_current_user(request)
|
||||
|
||||
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"] and user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Soft delete
|
||||
eh.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
# Delete vectors from Qdrant
|
||||
try:
|
||||
deleted_count = await delete_eh_vectors(eh_id)
|
||||
print(f"Deleted {deleted_count} vectors for EH {eh_id}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete vectors: {e}")
|
||||
|
||||
# Audit log
|
||||
log_eh_audit(
|
||||
tenant_id=eh.tenant_id,
|
||||
user_id=user["user_id"],
|
||||
action="delete",
|
||||
eh_id=eh_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
|
||||
return {"status": "deleted", "id": eh_id}
|
||||
|
||||
|
||||
@router.post("/api/v1/eh/{eh_id}/index")
|
||||
async def index_erwartungshorizont(
|
||||
eh_id: str,
|
||||
data: EHIndexRequest,
|
||||
request: Request
|
||||
):
|
||||
"""
|
||||
Index an Erwartungshorizont for RAG queries.
|
||||
|
||||
Requires the passphrase to decrypt, chunk, embed, and re-encrypt chunks.
|
||||
The passphrase is only used transiently and never stored.
|
||||
"""
|
||||
user = get_current_user(request)
|
||||
|
||||
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"] and user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Verify passphrase matches key hash
|
||||
if not verify_key_hash(data.passphrase, eh.salt, eh.encryption_key_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid passphrase")
|
||||
|
||||
eh.status = EHStatus.PROCESSING
|
||||
|
||||
try:
|
||||
# Read encrypted file
|
||||
with open(eh.encrypted_file_path, "rb") as f:
|
||||
encrypted_content = f.read()
|
||||
|
||||
# Decrypt the file
|
||||
decrypted_text = decrypt_text(
|
||||
encrypted_content.decode('utf-8'),
|
||||
data.passphrase,
|
||||
eh.salt
|
||||
)
|
||||
|
||||
# Process for indexing
|
||||
chunk_count, chunks_data = await process_eh_for_indexing(
|
||||
eh_id=eh_id,
|
||||
tenant_id=eh.tenant_id,
|
||||
subject=eh.subject,
|
||||
text_content=decrypted_text,
|
||||
passphrase=data.passphrase,
|
||||
salt_hex=eh.salt
|
||||
)
|
||||
|
||||
# Index in Qdrant
|
||||
await index_eh_chunks(
|
||||
eh_id=eh_id,
|
||||
tenant_id=eh.tenant_id,
|
||||
subject=eh.subject,
|
||||
chunks=chunks_data
|
||||
)
|
||||
|
||||
# Update EH record
|
||||
eh.status = EHStatus.INDEXED
|
||||
eh.chunk_count = chunk_count
|
||||
eh.indexed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Audit log
|
||||
log_eh_audit(
|
||||
tenant_id=eh.tenant_id,
|
||||
user_id=user["user_id"],
|
||||
action="indexed",
|
||||
eh_id=eh_id,
|
||||
details={"chunk_count": chunk_count}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "indexed",
|
||||
"id": eh_id,
|
||||
"chunk_count": chunk_count
|
||||
}
|
||||
|
||||
except EncryptionError as e:
|
||||
eh.status = EHStatus.ERROR
|
||||
eh.error_message = str(e)
|
||||
raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}")
|
||||
except EmbeddingError as e:
|
||||
eh.status = EHStatus.ERROR
|
||||
eh.error_message = str(e)
|
||||
raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
|
||||
except Exception as e:
|
||||
eh.status = EHStatus.ERROR
|
||||
eh.error_message = str(e)
|
||||
raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/api/v1/eh/rag-query")
|
||||
async def rag_query_eh(data: EHRAGQuery, request: Request):
|
||||
"""
|
||||
RAG query against teacher's Erwartungshorizonte.
|
||||
|
||||
1. Semantic search in Qdrant (tenant-isolated)
|
||||
2. Decrypt relevant chunks on-the-fly
|
||||
3. Return context for LLM usage
|
||||
"""
|
||||
user = get_current_user(request)
|
||||
tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"]
|
||||
|
||||
if not OPENAI_API_KEY:
|
||||
raise HTTPException(status_code=500, detail="OpenAI API key not configured")
|
||||
|
||||
try:
|
||||
# Generate embedding for query
|
||||
query_embedding = await generate_single_embedding(data.query_text)
|
||||
|
||||
# Search in Qdrant (tenant-isolated)
|
||||
results = await search_eh(
|
||||
query_embedding=query_embedding,
|
||||
tenant_id=tenant_id,
|
||||
subject=data.subject,
|
||||
limit=data.limit
|
||||
)
|
||||
|
||||
# Decrypt matching chunks
|
||||
decrypted_chunks = []
|
||||
for r in results:
|
||||
eh = storage.eh_db.get(r["eh_id"])
|
||||
if eh and r.get("encrypted_content"):
|
||||
try:
|
||||
decrypted = decrypt_text(
|
||||
r["encrypted_content"],
|
||||
data.passphrase,
|
||||
eh.salt
|
||||
)
|
||||
decrypted_chunks.append({
|
||||
"text": decrypted,
|
||||
"eh_id": r["eh_id"],
|
||||
"eh_title": eh.title,
|
||||
"chunk_index": r["chunk_index"],
|
||||
"score": r["score"]
|
||||
})
|
||||
except EncryptionError:
|
||||
# Skip chunks that can't be decrypted (wrong passphrase for different EH)
|
||||
pass
|
||||
|
||||
# Audit log
|
||||
log_eh_audit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user["user_id"],
|
||||
action="rag_query",
|
||||
details={
|
||||
"query_length": len(data.query_text),
|
||||
"results_count": len(results),
|
||||
"decrypted_count": len(decrypted_chunks)
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
|
||||
return {
|
||||
"context": "\n\n---\n\n".join([c["text"] for c in decrypted_chunks]),
|
||||
"sources": decrypted_chunks,
|
||||
"query": data.query_text
|
||||
}
|
||||
|
||||
except EmbeddingError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Query embedding failed: {str(e)}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"RAG query failed: {str(e)}")
|
||||
Reference in New Issue
Block a user