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>
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""
|
|
RBAC Teachers API - Teacher Management Endpoints
|
|
|
|
Provides API endpoints for:
|
|
- Listing all teachers with roles
|
|
- Getting teacher roles
|
|
- Getting teachers by role
|
|
- Creating, updating, deactivating teachers
|
|
|
|
Split from rbac_api.py for file-size compliance.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, Any, List
|
|
from fastapi import APIRouter, HTTPException, Depends
|
|
|
|
from rbac_api import (
|
|
get_pool,
|
|
get_current_user,
|
|
TeacherCreate,
|
|
TeacherUpdate,
|
|
TeacherResponse,
|
|
RoleAssignmentResponse,
|
|
AVAILABLE_ROLES,
|
|
)
|
|
|
|
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
|
|
|
|
|
def _build_teacher_response(teacher_row, roles: List[str]) -> TeacherResponse:
|
|
"""Build a TeacherResponse from a DB row and a list of role strings."""
|
|
return TeacherResponse(
|
|
id=str(teacher_row["id"]),
|
|
user_id=str(teacher_row["user_id"]),
|
|
email=teacher_row["email"],
|
|
name=teacher_row["name"] or f"{teacher_row['first_name']} {teacher_row['last_name']}",
|
|
teacher_code=teacher_row["teacher_code"],
|
|
title=teacher_row["title"],
|
|
first_name=teacher_row["first_name"],
|
|
last_name=teacher_row["last_name"],
|
|
is_active=teacher_row["is_active"],
|
|
roles=roles,
|
|
)
|
|
|
|
|
|
def _build_role_lookup(role_assignments) -> Dict[str, List[str]]:
|
|
"""Build a user_id -> [roles] lookup from role assignment rows."""
|
|
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"])
|
|
return role_lookup
|
|
|
|
|
|
# ==========================================
|
|
# TEACHER LISTING / QUERY ENDPOINTS
|
|
# ==========================================
|
|
|
|
|
|
@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:
|
|
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
|
|
""")
|
|
|
|
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())
|
|
""")
|
|
|
|
role_lookup = _build_role_lookup(role_assignments)
|
|
|
|
return [
|
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
|
for t in teachers
|
|
]
|
|
|
|
|
|
@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:
|
|
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")
|
|
|
|
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)
|
|
|
|
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 = _build_role_lookup(role_assignments)
|
|
else:
|
|
role_lookup = {}
|
|
|
|
return [
|
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
|
for t in teachers
|
|
]
|
|
|
|
|
|
# ==========================================
|
|
# TEACHER CRUD 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()
|
|
|
|
async with pool.acquire() as conn:
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM users WHERE email = $1",
|
|
teacher.email,
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail="Email already exists")
|
|
|
|
user_id = str(uuid.uuid4())
|
|
teacher_id = str(uuid.uuid4())
|
|
|
|
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}")
|
|
|
|
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)
|
|
|
|
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:
|
|
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")
|
|
|
|
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,
|
|
)
|
|
|
|
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"],
|
|
)
|
|
|
|
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)
|
|
|
|
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}
|