Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
820 lines
28 KiB
Python
820 lines
28 KiB
Python
"""
|
|
RBAC API - Teacher and Role Management Endpoints
|
|
|
|
Provides API endpoints for:
|
|
- Listing all teachers
|
|
- Listing all available roles
|
|
- Assigning/revoking roles to teachers
|
|
- Viewing role assignments per teacher
|
|
|
|
Architecture:
|
|
- Authentication: Keycloak (when configured) or local JWT
|
|
- Authorization: Custom rbac.py for fine-grained permissions
|
|
"""
|
|
|
|
import os
|
|
import asyncpg
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Dict, Any
|
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
|
from pydantic import BaseModel
|
|
|
|
# Import hybrid auth module
|
|
try:
|
|
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
|
except ImportError:
|
|
# Fallback for standalone testing
|
|
from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError
|
|
|
|
# Configuration from environment - NO DEFAULT SECRETS
|
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
|
|
|
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
|
|
|
# Connection pool
|
|
_pool: Optional[asyncpg.Pool] = None
|
|
|
|
|
|
def _get_database_url() -> str:
|
|
"""Get DATABASE_URL from environment, raising error if not set."""
|
|
url = os.environ.get("DATABASE_URL")
|
|
if not url:
|
|
raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
|
|
return url
|
|
|
|
|
|
async def get_pool() -> asyncpg.Pool:
|
|
"""Get or create database connection pool"""
|
|
global _pool
|
|
if _pool is None:
|
|
database_url = _get_database_url()
|
|
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
|
return _pool
|
|
|
|
|
|
async def close_pool():
|
|
"""Close database connection pool"""
|
|
global _pool
|
|
if _pool:
|
|
await _pool.close()
|
|
_pool = None
|
|
|
|
|
|
# Pydantic Models
|
|
class RoleAssignmentCreate(BaseModel):
|
|
user_id: str
|
|
role: str
|
|
resource_type: str = "tenant"
|
|
resource_id: str
|
|
valid_to: Optional[str] = None
|
|
|
|
|
|
class RoleAssignmentRevoke(BaseModel):
|
|
assignment_id: str
|
|
|
|
|
|
class TeacherCreate(BaseModel):
|
|
email: str
|
|
first_name: str
|
|
last_name: str
|
|
teacher_code: Optional[str] = None
|
|
title: Optional[str] = None
|
|
roles: List[str] = []
|
|
|
|
|
|
class TeacherUpdate(BaseModel):
|
|
email: Optional[str] = None
|
|
first_name: Optional[str] = None
|
|
last_name: Optional[str] = None
|
|
teacher_code: Optional[str] = None
|
|
title: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class CustomRoleCreate(BaseModel):
|
|
role_key: str
|
|
display_name: str
|
|
description: str
|
|
category: str
|
|
|
|
|
|
class CustomRoleUpdate(BaseModel):
|
|
display_name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
|
|
|
|
class TeacherResponse(BaseModel):
|
|
id: str
|
|
user_id: str
|
|
email: str
|
|
name: str
|
|
teacher_code: Optional[str]
|
|
title: Optional[str]
|
|
first_name: str
|
|
last_name: str
|
|
is_active: bool
|
|
roles: List[str]
|
|
|
|
|
|
class RoleInfo(BaseModel):
|
|
role: str
|
|
display_name: str
|
|
description: str
|
|
category: str
|
|
|
|
|
|
class RoleAssignmentResponse(BaseModel):
|
|
id: str
|
|
user_id: str
|
|
role: str
|
|
resource_type: str
|
|
resource_id: str
|
|
valid_from: str
|
|
valid_to: Optional[str]
|
|
granted_at: str
|
|
is_active: bool
|
|
|
|
|
|
# Role definitions with German display names
|
|
AVAILABLE_ROLES = {
|
|
# Klausur-Korrekturkette
|
|
"erstkorrektor": {
|
|
"display_name": "Erstkorrektor",
|
|
"description": "Fuehrt die erste Korrektur der Klausur durch",
|
|
"category": "klausur"
|
|
},
|
|
"zweitkorrektor": {
|
|
"display_name": "Zweitkorrektor",
|
|
"description": "Fuehrt die zweite Korrektur der Klausur durch",
|
|
"category": "klausur"
|
|
},
|
|
"drittkorrektor": {
|
|
"display_name": "Drittkorrektor",
|
|
"description": "Fuehrt die dritte Korrektur bei Notenabweichung durch",
|
|
"category": "klausur"
|
|
},
|
|
# Zeugnis-Workflow
|
|
"klassenlehrer": {
|
|
"display_name": "Klassenlehrer/in",
|
|
"description": "Erstellt Zeugnisse, traegt Kopfnoten und Bemerkungen ein",
|
|
"category": "zeugnis"
|
|
},
|
|
"fachlehrer": {
|
|
"display_name": "Fachlehrer/in",
|
|
"description": "Traegt Fachnoten ein",
|
|
"category": "zeugnis"
|
|
},
|
|
"zeugnisbeauftragter": {
|
|
"display_name": "Zeugnisbeauftragte/r",
|
|
"description": "Qualitaetskontrolle und Freigabe von Zeugnissen",
|
|
"category": "zeugnis"
|
|
},
|
|
"sekretariat": {
|
|
"display_name": "Sekretariat",
|
|
"description": "Druck, Versand und Archivierung von Dokumenten",
|
|
"category": "verwaltung"
|
|
},
|
|
# Leitung
|
|
"fachvorsitz": {
|
|
"display_name": "Fachvorsitz",
|
|
"description": "Fachpruefungsleitung und Qualitaetssicherung",
|
|
"category": "leitung"
|
|
},
|
|
"pruefungsvorsitz": {
|
|
"display_name": "Pruefungsvorsitz",
|
|
"description": "Pruefungsleitung und finale Freigabe",
|
|
"category": "leitung"
|
|
},
|
|
"schulleitung": {
|
|
"display_name": "Schulleitung",
|
|
"description": "Finale Freigabe und Unterschrift",
|
|
"category": "leitung"
|
|
},
|
|
"stufenleitung": {
|
|
"display_name": "Stufenleitung",
|
|
"description": "Koordination einer Jahrgangsstufe",
|
|
"category": "leitung"
|
|
},
|
|
# Administration
|
|
"schul_admin": {
|
|
"display_name": "Schul-Administrator",
|
|
"description": "Technische Administration der Schule",
|
|
"category": "admin"
|
|
},
|
|
"teacher_assistant": {
|
|
"display_name": "Referendar/in",
|
|
"description": "Lehrkraft in Ausbildung mit eingeschraenkten Rechten",
|
|
"category": "other"
|
|
},
|
|
}
|
|
|
|
|
|
# Note: get_user_from_token is replaced by the imported get_current_user dependency
|
|
# from auth module which supports both Keycloak and local JWT authentication
|
|
|
|
|
|
# API Endpoints
|
|
|
|
@router.get("/roles")
|
|
async def list_available_roles() -> List[RoleInfo]:
|
|
"""List all available roles with their descriptions"""
|
|
return [
|
|
RoleInfo(
|
|
role=role_key,
|
|
display_name=role_data["display_name"],
|
|
description=role_data["description"],
|
|
category=role_data["category"]
|
|
)
|
|
for role_key, role_data in AVAILABLE_ROLES.items()
|
|
]
|
|
|
|
|
|
@router.get("/teachers")
|
|
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
"""List all teachers with their current roles"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
# Get all teachers with their user info
|
|
teachers = await conn.fetch("""
|
|
SELECT
|
|
t.id, t.user_id, t.teacher_code, t.title,
|
|
t.first_name, t.last_name, t.is_active,
|
|
u.email, u.name
|
|
FROM teachers t
|
|
JOIN users u ON t.user_id = u.id
|
|
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
ORDER BY t.last_name, t.first_name
|
|
""")
|
|
|
|
# Get role assignments for all teachers
|
|
role_assignments = await conn.fetch("""
|
|
SELECT user_id, role
|
|
FROM role_assignments
|
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND revoked_at IS NULL
|
|
AND (valid_to IS NULL OR valid_to > NOW())
|
|
""")
|
|
|
|
# Build role lookup
|
|
role_lookup: Dict[str, List[str]] = {}
|
|
for ra in role_assignments:
|
|
uid = str(ra["user_id"])
|
|
if uid not in role_lookup:
|
|
role_lookup[uid] = []
|
|
role_lookup[uid].append(ra["role"])
|
|
|
|
# Build response
|
|
result = []
|
|
for t in teachers:
|
|
uid = str(t["user_id"])
|
|
result.append(TeacherResponse(
|
|
id=str(t["id"]),
|
|
user_id=uid,
|
|
email=t["email"],
|
|
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
teacher_code=t["teacher_code"],
|
|
title=t["title"],
|
|
first_name=t["first_name"],
|
|
last_name=t["last_name"],
|
|
is_active=t["is_active"],
|
|
roles=role_lookup.get(uid, [])
|
|
))
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/teachers/{teacher_id}/roles")
|
|
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
|
"""Get all role assignments for a specific teacher"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
# Get teacher's user_id
|
|
teacher = await conn.fetchrow(
|
|
"SELECT user_id FROM teachers WHERE id = $1",
|
|
teacher_id
|
|
)
|
|
if not teacher:
|
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
|
|
# Get role assignments
|
|
assignments = await conn.fetch("""
|
|
SELECT id, user_id, role, resource_type, resource_id,
|
|
valid_from, valid_to, granted_at, revoked_at
|
|
FROM role_assignments
|
|
WHERE user_id = $1
|
|
ORDER BY granted_at DESC
|
|
""", teacher["user_id"])
|
|
|
|
return [
|
|
RoleAssignmentResponse(
|
|
id=str(a["id"]),
|
|
user_id=str(a["user_id"]),
|
|
role=a["role"],
|
|
resource_type=a["resource_type"],
|
|
resource_id=str(a["resource_id"]),
|
|
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
|
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
|
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
|
is_active=a["revoked_at"] is None and (
|
|
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
|
)
|
|
)
|
|
for a in assignments
|
|
]
|
|
|
|
|
|
@router.get("/roles/{role}/teachers")
|
|
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
"""Get all teachers with a specific role"""
|
|
if role not in AVAILABLE_ROLES:
|
|
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
|
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
teachers = await conn.fetch("""
|
|
SELECT DISTINCT
|
|
t.id, t.user_id, t.teacher_code, t.title,
|
|
t.first_name, t.last_name, t.is_active,
|
|
u.email, u.name
|
|
FROM teachers t
|
|
JOIN users u ON t.user_id = u.id
|
|
JOIN role_assignments ra ON t.user_id = ra.user_id
|
|
WHERE ra.role = $1
|
|
AND ra.revoked_at IS NULL
|
|
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
|
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
ORDER BY t.last_name, t.first_name
|
|
""", role)
|
|
|
|
# Get all roles for these teachers
|
|
if teachers:
|
|
user_ids = [t["user_id"] for t in teachers]
|
|
role_assignments = await conn.fetch("""
|
|
SELECT user_id, role
|
|
FROM role_assignments
|
|
WHERE user_id = ANY($1)
|
|
AND revoked_at IS NULL
|
|
AND (valid_to IS NULL OR valid_to > NOW())
|
|
""", user_ids)
|
|
|
|
role_lookup: Dict[str, List[str]] = {}
|
|
for ra in role_assignments:
|
|
uid = str(ra["user_id"])
|
|
if uid not in role_lookup:
|
|
role_lookup[uid] = []
|
|
role_lookup[uid].append(ra["role"])
|
|
else:
|
|
role_lookup = {}
|
|
|
|
return [
|
|
TeacherResponse(
|
|
id=str(t["id"]),
|
|
user_id=str(t["user_id"]),
|
|
email=t["email"],
|
|
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
teacher_code=t["teacher_code"],
|
|
title=t["title"],
|
|
first_name=t["first_name"],
|
|
last_name=t["last_name"],
|
|
is_active=t["is_active"],
|
|
roles=role_lookup.get(str(t["user_id"]), [])
|
|
)
|
|
for t in teachers
|
|
]
|
|
|
|
|
|
@router.post("/assignments")
|
|
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
|
"""Assign a role to a user"""
|
|
if assignment.role not in AVAILABLE_ROLES:
|
|
raise HTTPException(status_code=400, detail=f"Unknown role: {assignment.role}")
|
|
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
# Check if assignment already exists
|
|
existing = await conn.fetchrow("""
|
|
SELECT id FROM role_assignments
|
|
WHERE user_id = $1 AND role = $2 AND resource_id = $3
|
|
AND revoked_at IS NULL
|
|
""", assignment.user_id, assignment.role, assignment.resource_id)
|
|
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Role assignment already exists"
|
|
)
|
|
|
|
# Parse valid_to if provided
|
|
valid_to = None
|
|
if assignment.valid_to:
|
|
valid_to = datetime.fromisoformat(assignment.valid_to)
|
|
|
|
# Create assignment
|
|
result = await conn.fetchrow("""
|
|
INSERT INTO role_assignments
|
|
(user_id, role, resource_type, resource_id, tenant_id, valid_to, granted_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, user_id, role, resource_type, resource_id, valid_from, valid_to, granted_at
|
|
""",
|
|
assignment.user_id,
|
|
assignment.role,
|
|
assignment.resource_type,
|
|
assignment.resource_id,
|
|
assignment.resource_id, # tenant_id same as resource_id for tenant-level roles
|
|
valid_to,
|
|
user.get("user_id")
|
|
)
|
|
|
|
return RoleAssignmentResponse(
|
|
id=str(result["id"]),
|
|
user_id=str(result["user_id"]),
|
|
role=result["role"],
|
|
resource_type=result["resource_type"],
|
|
resource_id=str(result["resource_id"]),
|
|
valid_from=result["valid_from"].isoformat(),
|
|
valid_to=result["valid_to"].isoformat() if result["valid_to"] else None,
|
|
granted_at=result["granted_at"].isoformat(),
|
|
is_active=True
|
|
)
|
|
|
|
|
|
@router.delete("/assignments/{assignment_id}")
|
|
async def revoke_role(assignment_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
|
"""Revoke a role assignment"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
result = await conn.execute("""
|
|
UPDATE role_assignments
|
|
SET revoked_at = NOW()
|
|
WHERE id = $1 AND revoked_at IS NULL
|
|
""", assignment_id)
|
|
|
|
if result == "UPDATE 0":
|
|
raise HTTPException(status_code=404, detail="Assignment not found or already revoked")
|
|
|
|
return {"status": "revoked", "assignment_id": assignment_id}
|
|
|
|
|
|
@router.get("/summary")
|
|
async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
|
"""Get a summary of roles and their assignment counts"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
counts = await conn.fetch("""
|
|
SELECT role, COUNT(*) as count
|
|
FROM role_assignments
|
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND revoked_at IS NULL
|
|
AND (valid_to IS NULL OR valid_to > NOW())
|
|
GROUP BY role
|
|
ORDER BY role
|
|
""")
|
|
|
|
total_teachers = await conn.fetchval("""
|
|
SELECT COUNT(*) FROM teachers
|
|
WHERE school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND is_active = true
|
|
""")
|
|
|
|
role_counts = {c["role"]: c["count"] for c in counts}
|
|
|
|
# Also include custom roles from database
|
|
custom_roles = await conn.fetch("""
|
|
SELECT role_key, display_name, category
|
|
FROM custom_roles
|
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND is_active = true
|
|
""")
|
|
|
|
all_roles = [
|
|
{
|
|
"role": role_key,
|
|
"display_name": role_data["display_name"],
|
|
"category": role_data["category"],
|
|
"count": role_counts.get(role_key, 0),
|
|
"is_custom": False
|
|
}
|
|
for role_key, role_data in AVAILABLE_ROLES.items()
|
|
]
|
|
|
|
for cr in custom_roles:
|
|
all_roles.append({
|
|
"role": cr["role_key"],
|
|
"display_name": cr["display_name"],
|
|
"category": cr["category"],
|
|
"count": role_counts.get(cr["role_key"], 0),
|
|
"is_custom": True
|
|
})
|
|
|
|
return {
|
|
"total_teachers": total_teachers,
|
|
"roles": all_roles
|
|
}
|
|
|
|
|
|
# ==========================================
|
|
# TEACHER MANAGEMENT ENDPOINTS
|
|
# ==========================================
|
|
|
|
@router.post("/teachers")
|
|
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
"""Create a new teacher with optional initial roles"""
|
|
pool = await get_pool()
|
|
|
|
import uuid
|
|
|
|
async with pool.acquire() as conn:
|
|
# Check if email already exists
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM users WHERE email = $1",
|
|
teacher.email
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Email already exists")
|
|
|
|
# Generate UUIDs
|
|
user_id = str(uuid.uuid4())
|
|
teacher_id = str(uuid.uuid4())
|
|
|
|
# Create user first
|
|
await conn.execute("""
|
|
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
|
VALUES ($1, $2, $3, '', 'teacher', true)
|
|
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
|
|
|
# Create teacher record
|
|
await conn.execute("""
|
|
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
|
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
|
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
|
teacher.teacher_code, teacher.title)
|
|
|
|
# Assign initial roles if provided
|
|
assigned_roles = []
|
|
for role in teacher.roles:
|
|
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
|
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
|
):
|
|
await conn.execute("""
|
|
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
|
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
|
'a0000000-0000-0000-0000-000000000001', $3)
|
|
""", user_id, role, user.get("user_id"))
|
|
assigned_roles.append(role)
|
|
|
|
return TeacherResponse(
|
|
id=teacher_id,
|
|
user_id=user_id,
|
|
email=teacher.email,
|
|
name=f"{teacher.first_name} {teacher.last_name}",
|
|
teacher_code=teacher.teacher_code,
|
|
title=teacher.title,
|
|
first_name=teacher.first_name,
|
|
last_name=teacher.last_name,
|
|
is_active=True,
|
|
roles=assigned_roles
|
|
)
|
|
|
|
|
|
@router.put("/teachers/{teacher_id}")
|
|
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
"""Update teacher information"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
# Get current teacher data
|
|
teacher = await conn.fetchrow("""
|
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
u.email, u.name
|
|
FROM teachers t
|
|
JOIN users u ON t.user_id = u.id
|
|
WHERE t.id = $1
|
|
""", teacher_id)
|
|
|
|
if not teacher:
|
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
|
|
# Build update queries
|
|
if updates.email:
|
|
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
|
updates.email, teacher["user_id"])
|
|
|
|
teacher_updates = []
|
|
teacher_values = []
|
|
idx = 1
|
|
|
|
if updates.first_name:
|
|
teacher_updates.append(f"first_name = ${idx}")
|
|
teacher_values.append(updates.first_name)
|
|
idx += 1
|
|
if updates.last_name:
|
|
teacher_updates.append(f"last_name = ${idx}")
|
|
teacher_values.append(updates.last_name)
|
|
idx += 1
|
|
if updates.teacher_code is not None:
|
|
teacher_updates.append(f"teacher_code = ${idx}")
|
|
teacher_values.append(updates.teacher_code)
|
|
idx += 1
|
|
if updates.title is not None:
|
|
teacher_updates.append(f"title = ${idx}")
|
|
teacher_values.append(updates.title)
|
|
idx += 1
|
|
if updates.is_active is not None:
|
|
teacher_updates.append(f"is_active = ${idx}")
|
|
teacher_values.append(updates.is_active)
|
|
idx += 1
|
|
|
|
if teacher_updates:
|
|
teacher_values.append(teacher_id)
|
|
await conn.execute(
|
|
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
|
*teacher_values
|
|
)
|
|
|
|
# Update user name if first/last name changed
|
|
if updates.first_name or updates.last_name:
|
|
new_first = updates.first_name or teacher["first_name"]
|
|
new_last = updates.last_name or teacher["last_name"]
|
|
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
|
f"{new_first} {new_last}", teacher["user_id"])
|
|
|
|
# Fetch updated data
|
|
updated = await conn.fetchrow("""
|
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
u.email, u.name
|
|
FROM teachers t
|
|
JOIN users u ON t.user_id = u.id
|
|
WHERE t.id = $1
|
|
""", teacher_id)
|
|
|
|
# Get roles
|
|
roles = await conn.fetch("""
|
|
SELECT role FROM role_assignments
|
|
WHERE user_id = $1 AND revoked_at IS NULL
|
|
AND (valid_to IS NULL OR valid_to > NOW())
|
|
""", updated["user_id"])
|
|
|
|
return TeacherResponse(
|
|
id=str(updated["id"]),
|
|
user_id=str(updated["user_id"]),
|
|
email=updated["email"],
|
|
name=updated["name"],
|
|
teacher_code=updated["teacher_code"],
|
|
title=updated["title"],
|
|
first_name=updated["first_name"],
|
|
last_name=updated["last_name"],
|
|
is_active=updated["is_active"],
|
|
roles=[r["role"] for r in roles]
|
|
)
|
|
|
|
|
|
@router.delete("/teachers/{teacher_id}")
|
|
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
|
"""Deactivate a teacher (soft delete)"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
result = await conn.execute("""
|
|
UPDATE teachers SET is_active = false WHERE id = $1
|
|
""", teacher_id)
|
|
|
|
if result == "UPDATE 0":
|
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
|
|
return {"status": "deactivated", "teacher_id": teacher_id}
|
|
|
|
|
|
# ==========================================
|
|
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
|
# ==========================================
|
|
|
|
@router.get("/custom-roles")
|
|
async def list_custom_roles(user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleInfo]:
|
|
"""List all custom roles"""
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
roles = await conn.fetch("""
|
|
SELECT role_key, display_name, description, category
|
|
FROM custom_roles
|
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND is_active = true
|
|
ORDER BY category, display_name
|
|
""")
|
|
|
|
return [
|
|
RoleInfo(
|
|
role=r["role_key"],
|
|
display_name=r["display_name"],
|
|
description=r["description"],
|
|
category=r["category"]
|
|
)
|
|
for r in roles
|
|
]
|
|
|
|
|
|
@router.post("/custom-roles")
|
|
async def create_custom_role(role: CustomRoleCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
|
|
"""Create a new custom role"""
|
|
pool = await get_pool()
|
|
|
|
# Check if role_key conflicts with built-in roles
|
|
if role.role_key in AVAILABLE_ROLES:
|
|
raise HTTPException(status_code=409, detail="Role key conflicts with built-in role")
|
|
|
|
async with pool.acquire() as conn:
|
|
# Check if custom role already exists
|
|
existing = await conn.fetchrow("""
|
|
SELECT id FROM custom_roles
|
|
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
""", role.role_key)
|
|
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Custom role already exists")
|
|
|
|
await conn.execute("""
|
|
INSERT INTO custom_roles (role_key, display_name, description, category, tenant_id, created_by)
|
|
VALUES ($1, $2, $3, $4, 'a0000000-0000-0000-0000-000000000001', $5)
|
|
""", role.role_key, role.display_name, role.description, role.category, user.get("user_id"))
|
|
|
|
return RoleInfo(
|
|
role=role.role_key,
|
|
display_name=role.display_name,
|
|
description=role.description,
|
|
category=role.category
|
|
)
|
|
|
|
|
|
@router.put("/custom-roles/{role_key}")
|
|
async def update_custom_role(role_key: str, updates: CustomRoleUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
|
|
"""Update a custom role"""
|
|
|
|
if role_key in AVAILABLE_ROLES:
|
|
raise HTTPException(status_code=400, detail="Cannot modify built-in roles")
|
|
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
current = await conn.fetchrow("""
|
|
SELECT role_key, display_name, description, category
|
|
FROM custom_roles
|
|
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND is_active = true
|
|
""", role_key)
|
|
|
|
if not current:
|
|
raise HTTPException(status_code=404, detail="Custom role not found")
|
|
|
|
new_display = updates.display_name or current["display_name"]
|
|
new_desc = updates.description or current["description"]
|
|
new_cat = updates.category or current["category"]
|
|
|
|
await conn.execute("""
|
|
UPDATE custom_roles
|
|
SET display_name = $1, description = $2, category = $3
|
|
WHERE role_key = $4 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
""", new_display, new_desc, new_cat, role_key)
|
|
|
|
return RoleInfo(
|
|
role=role_key,
|
|
display_name=new_display,
|
|
description=new_desc,
|
|
category=new_cat
|
|
)
|
|
|
|
|
|
@router.delete("/custom-roles/{role_key}")
|
|
async def delete_custom_role(role_key: str, user: Dict[str, Any] = Depends(get_current_user)):
|
|
"""Delete a custom role (soft delete)"""
|
|
|
|
if role_key in AVAILABLE_ROLES:
|
|
raise HTTPException(status_code=400, detail="Cannot delete built-in roles")
|
|
|
|
pool = await get_pool()
|
|
|
|
async with pool.acquire() as conn:
|
|
# Soft delete the role
|
|
result = await conn.execute("""
|
|
UPDATE custom_roles SET is_active = false
|
|
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
""", role_key)
|
|
|
|
if result == "UPDATE 0":
|
|
raise HTTPException(status_code=404, detail="Custom role not found")
|
|
|
|
# Also revoke all assignments with this role
|
|
await conn.execute("""
|
|
UPDATE role_assignments SET revoked_at = NOW()
|
|
WHERE role = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
AND revoked_at IS NULL
|
|
""", role_key)
|
|
|
|
return {"status": "deleted", "role_key": role_key}
|