""" 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}