Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1112 lines
34 KiB
Python
1112 lines
34 KiB
Python
"""
|
|
Klausur-Service BYOEH Routes
|
|
|
|
Endpoints for Bring-Your-Own-Expectation-Horizon (BYOEH).
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
import json
|
|
from datetime import datetime, timezone, timedelta
|
|
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,
|
|
EHKeyShare,
|
|
EHKlausurLink,
|
|
EHShareInvitation,
|
|
)
|
|
from models.requests import (
|
|
EHMetadata,
|
|
EHUploadMetadata,
|
|
EHRAGQuery,
|
|
EHIndexRequest,
|
|
EHShareRequest,
|
|
EHLinkKlausurRequest,
|
|
EHInviteRequest,
|
|
EHAcceptInviteRequest,
|
|
)
|
|
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)}")
|
|
|
|
|
|
# =============================================
|
|
# 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")
|
|
|
|
|
|
# =============================================
|
|
# 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
|
|
}
|
|
|
|
|
|
@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
|