"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed). Organization panel endpoints (admin / procurement manager — same person): - GET /users — list every user with role + assigned projects. - POST /users — define a new user (manual identity, casdoor_id optional). - PATCH /users/{id} — set/clear role; clearing (or switching away from «pm») also unassigns the user from every project. - POST /users/{id}/project — assign/unassign ONE project (multi-project: other assignments are kept; project_id=None clears all; one PM per project). - GET /users/me/projects — every project assigned to the caller (PM). Role management is open to BOTH «admin» and «procurement_manager» (they are the same person in this system), unlike the plain require_role("admin"). """ import uuid import httpx from fastapi import APIRouter, Depends from sqlalchemy import select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role from app.utils.audit import record as audit_record from app.config import settings from app.db.engine import get_db from app.models.procurement import Project, User from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate from app.utils.errors import ( ConflictError, ForbiddenError, NotFoundError, ServiceUnavailableError, ValidationError, ) from app.utils.serialization import to_iso router = APIRouter(prefix="/users", tags=["users"]) USER_ROLE_LABELS = { "pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین", # کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد "supervisor": "سرپرست پروژه", } def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: """Serialize a user. `projects` = every project the PM manages (1:N); project_id/project_name stay as the first (oldest) one for compatibility.""" projects = projects or [] first = projects[0] if projects else None return { "id": str(user.id), "casdoor_id": user.casdoor_id, "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, "role": user.role, "role_label": USER_ROLE_LABELS.get(user.role) if user.role else None, "project_id": str(first.id) if first else None, "project_name": first.name if first else None, "projects": [{"id": str(p.id), "name": p.name} for p in projects], "created_at": to_iso(user.created_at), } async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: """Admin, procurement manager, or project manager — organization management.""" if ctx.role not in ("admin", "procurement_manager", "pm"): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx async def _user_projects(db: AsyncSession, user: User) -> list[Project]: """Every project assigned to the user (supervisor or PM) (oldest first).""" if user.role not in ("supervisor", "pm"): return [] rows = ( await db.execute( select(Project) .where(Project.pm_user_id == user.id) .order_by(Project.created_at, Project.id) ) ).scalars().all() return list(rows) async def _user_project(db: AsyncSession, user: User) -> Project | None: """First (oldest) project of a PM — legacy single-project view.""" projects = await _user_projects(db, user) return projects[0] if projects else None async def _unassign_projects(db: AsyncSession, user_id) -> None: """Clear the user's pm assignment on every project (role revocation).""" await db.execute( update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None) ) async def _casdoor_users() -> list[dict]: """Fetch the users of the Casdoor organization (org inventory) via the Casdoor management API, using the console admin session.""" async with httpx.AsyncClient(timeout=15) as client: login = await client.post( f"{settings.CASDOOR_ENDPOINT}/api/login", json={ "type": "login", "organization": "built-in", "username": settings.CASDOOR_ADMIN_USER, "password": settings.CASDOOR_ADMIN_PASSWORD, "application": "app-built-in", }, ) if login.status_code != 200 or (login.json() or {}).get("status") != "ok": raise ServiceUnavailableError("ورود به کنسول Casdoor برای همگام‌سازی ممکن نشد") resp = await client.get( f"{settings.CASDOOR_ENDPOINT}/api/get-users?owner={settings.CASDOOR_ORGANIZATION}" ) resp.raise_for_status() body = resp.json() if body.get("status") != "ok": raise ServiceUnavailableError("دریافت کاربران Casdoor ناموفق بود") return body.get("data") or [] @router.get("/me") async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): projects = await _user_projects(db, ctx.user) return {"data": user_to_dict(ctx.user, projects)} @router.get("/me/projects") async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): """Every project assigned to the caller (empty list for non-PM roles). Drives the PM project switcher: the frontend validates its persisted active project against this list and falls back gracefully when a project is revoked.""" projects = await _user_projects(db, ctx.user) return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} @router.get("") async def list_users( _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """Every user (with role + assigned projects) — Organization panel.""" users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} if supervisor_ids: projs = ( await db.execute( select(Project) .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "meta": {"total": len(users)}, } @router.post("", status_code=201) async def create_user( payload: UserCreate, ctx: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """Define a new user (manual identity — casdoor_id optional).""" casdoor_id = (payload.casdoor_id or "").strip() or f"manual-{uuid.uuid4()}" duplicate = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) if duplicate is not None: raise ConflictError("کاربری با این شناسه قبلاً ثبت شده است", field="casdoor_id") user = User( casdoor_id=casdoor_id, first_name=payload.first_name, last_name=payload.last_name or "", phone=payload.phone, role=payload.role, ) db.add(user) await db.flush() await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) return {"data": user_to_dict(user)} @router.patch("/{user_id}") async def update_user_role( user_id, payload: UserRoleUpdate, ctx: UserContext = Depends(require_role("admin")), db: AsyncSession = Depends(get_db), ): """Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not assign or elevate roles. Revoking (or switching away from «pm») also unassigns the user from their project.""" user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") previous_role = user.role if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"): await _unassign_projects(db, user.id) user.role = payload.role await db.flush() await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) projects = await _user_projects(db, user) return {"data": user_to_dict(user, projects)} @router.post("/sync") async def sync_casdoor_users( _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """Mirror the Casdoor organization users into the app's users table. Identity-only: roles are NOT touched here (role assignment stays in the app — PATCH /users/{id}, admin-only). Returns the merged user list. """ cas_users = await _casdoor_users() created = 0 for cu in cas_users: cid = str(cu.get("id") or "").strip() if not cid: continue existing = await db.scalar(select(User).where(User.casdoor_id == cid)) if existing is None: uname = str(cu.get("name") or cu.get("preferred_username") or "") or None if uname: by_uname = await db.scalar(select(User).where(User.username == uname)) if by_uname is not None: by_uname.casdoor_id = cid continue db.add(User( casdoor_id=cid, username=uname, first_name=str( cu.get("firstName") or cu.get("displayName") or cu.get("name") or "کاربر" ), last_name=str(cu.get("lastName") or ""), phone=str(cu.get("phone") or "") or None, )) created += 1 elif not existing.username: uname = str(cu.get("name") or cu.get("preferred_username") or "") or None if uname: existing.username = uname await db.flush() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} if supervisor_ids: projs = ( await db.execute( select(Project) .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "meta": {"total": len(users), "created": created}, } @router.post("/{user_id}/project") async def assign_user_project( user_id, payload: UserProjectUpdate, _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): """Assign the user as PM of ONE project (multi-project aware). - project_id given: that project gets this PM (a previous PM there is replaced); the user's OTHER project assignments are kept. - project_id=None: clears ALL of the user's project assignments. Rules: the user must hold the «pm» role; a project has at most one PM. """ user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") if user.role not in ("supervisor", "pm"): raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role") if payload.project_id is None: await _unassign_projects(db, user.id) if user.username: await db.execute( text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"), {"uname": user.username} ) await db.flush() return {"data": user_to_dict(user)} project = await db.get(Project, payload.project_id) if project is None: raise NotFoundError("پروژه یافت نشد", field="project_id") project.pm_user_id = user.id if user.username: await db.execute( text(""" INSERT INTO project_assignments (id, user_id, project_id) SELECT gen_random_uuid(), pu.id, :pid FROM pm_users pu WHERE pu.username = :uname AND NOT EXISTS ( SELECT 1 FROM project_assignments pa WHERE pa.user_id = pu.id AND pa.project_id = :pid ) """), {"pid": project.id, "uname": user.username} ) await db.flush() return {"data": user_to_dict(user, await _user_projects(db, user))}