Files
ali_mohaghegh 83f4fb0747 fix(backend): arrival-confirmation 500, receipt state, RBAC, Casdoor user sync
- pm.py/inventory.py: remove db.begin() on already-begun sessions — the
  scope dependencies read from the session first, so opening a new
  transaction raised InvalidRequestError -> 500 on every receipt/transaction
  (JWT path); get_db already commits/rolls back atomically
- pm.py: receipt response returns the real derived state (was hardcoded
  'arrived'); dev-user creation is race-free (insert on conflict)
- expand_service: aggregate 1:N receipts per line item (received_qty sum +
  count) so the ledger can show partial-receipt totals
- users.py: role changes are admin-only (RBAC); new POST /users/sync mirrors
  Casdoor org users into the app users table (identity only, roles untouched)
- config/compose: CASDOOR_ADMIN_USER/PASSWORD for the sync endpoint
- seed_casdoor: redirect URI updated for the Traefik edge
2026-09-01 17:28:06 +03:30

247 lines
9.1 KiB
Python

"""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 project.
- 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 their project.
- POST /users/{id}/project — assign/unassign the user's single project
(project_id=None unassigns; one PM per project).
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
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
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": "ادمین",
}
def user_to_dict(user: User, project: Project | None = None) -> dict:
return {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"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(project.id) if project else None,
"project_name": project.name if project else None,
"created_at": to_iso(user.created_at),
}
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management."""
if ctx.role not in ("admin", "procurement_manager"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
async def _user_project(db: AsyncSession, user: User) -> Project | None:
if user.role != "pm":
return None
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
async def _unassign_projects(db: AsyncSession, user_id) -> None:
"""Clear the user's pm assignment on every project (one project per PM)."""
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)):
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
return {"data": user_to_dict(ctx.user, project)}
@router.get("")
async def list_users(
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Every user (with role + assigned project) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
"meta": {"total": len(users)},
}
@router.post("", status_code=201)
async def create_user(
payload: UserCreate,
_: 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()
return {"data": user_to_dict(user)}
@router.patch("/{user_id}")
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: 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("کاربر یافت نشد")
if user.role == "pm" and payload.role != "pm":
await _unassign_projects(db, user.id)
user.role = payload.role
await db.flush()
project = await _user_project(db, user)
return {"data": user_to_dict(user, project)}
@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:
db.add(User(
casdoor_id=cid,
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
await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
return {
"data": [user_to_dict(u, projects.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 (project_id=None unassigns).
Rules: the user must hold the «pm» role; a PM manages ONE project at a
time (assignment on other projects is cleared); assigning a new PM to a
project replaces the previous PM there.
"""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
await _unassign_projects(db, user.id)
if payload.project_id is None:
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
await db.flush()
return {"data": user_to_dict(user, project)}