[split-required] [guardrail-change] Enforce 500 LOC budget across all services

Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 00:09:30 +02:00
parent 5ef039a6bc
commit 92c86ec6ba
162 changed files with 23853 additions and 23034 deletions

View File

@@ -1,11 +1,14 @@
"""
RBAC API - Teacher and Role Management Endpoints
RBAC API - Role and Assignment Management Endpoints
Provides API endpoints for:
- Listing all teachers
- Listing all available roles
- Assigning/revoking roles to teachers
- Viewing role assignments per teacher
- Listing all available roles (built-in + custom)
- Assigning/revoking roles to users
- Role summary with assignment counts
- Custom role CRUD
Shared infrastructure (DB pool, Pydantic models, role definitions)
used by rbac_teachers_api.py as well.
Architecture:
- Authentication: Keycloak (when configured) or local JWT
@@ -230,163 +233,6 @@ async def list_available_roles() -> List[RoleInfo]:
]
@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"""
@@ -519,178 +365,6 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) ->
}
# ==========================================
# 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
# ==========================================