- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
stock change); 'limited' -> supervisor scope (own memberships, stock change
allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
procurement_manager, or procurement 'full') across all 18 write routers;
quote CREATE additionally allows 'limited' users restricted to their member
projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
limited -> minimal RequesterHome), wizard project options restricted for
limited users, change-inventory button/route gated to inventory 'limited',
organization panel gains the panels & access-levels management section
440 lines
17 KiB
Python
440 lines
17 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 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 delete, 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, UserPanel, UserProjectMembership
|
|
from app.schemas.procurement import (
|
|
UserCreate,
|
|
UserPanelsUpdate,
|
|
UserPanelProjectsUpdate,
|
|
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": "سرپرست پروژه",
|
|
}
|
|
|
|
|
|
PANEL_LEVELS = ("full", "limited")
|
|
PANELS = ("procurement", "inventory", "pm")
|
|
|
|
|
|
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
|
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
|
rows = (
|
|
await db.execute(
|
|
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
|
)
|
|
).scalars().all()
|
|
return {row.panel: row.level for row in rows}
|
|
|
|
|
|
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
|
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
|
rows = (
|
|
await db.execute(
|
|
select(UserProjectMembership.project_id)
|
|
.where(UserProjectMembership.user_id == user_id)
|
|
.order_by(UserProjectMembership.created_at)
|
|
)
|
|
).scalars().all()
|
|
return [str(pid) for pid in rows]
|
|
|
|
|
|
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | 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.
|
|
با db، پنلهای تخصیصی و عضویت پروژهای هم برمیگردند."""
|
|
projects = projects or []
|
|
first = projects[0] if projects else None
|
|
out = {
|
|
"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),
|
|
}
|
|
if db is not None:
|
|
out["panels"] = await _panels_of(db, user.id)
|
|
out["member_project_ids"] = await _member_project_ids_of(db, user.id)
|
|
return out
|
|
|
|
|
|
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": await user_to_dict(ctx.user, projects, db)}
|
|
|
|
|
|
@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)
|
|
# عضویتهای M:N (لایه پنلها) هم به فهرست اضافه میشود
|
|
member_ids = set(ctx.member_project_ids or [])
|
|
have = {p.id for p in projects}
|
|
if member_ids:
|
|
extra = (
|
|
await db.execute(
|
|
select(Project)
|
|
.where(Project.id.in_(member_ids))
|
|
.order_by(Project.created_at, Project.id)
|
|
)
|
|
).scalars().all()
|
|
projects = list(projects) + [p for p in extra if p.id not in have]
|
|
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": [await user_to_dict(u, by_user.get(u.id, []), db) 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": await 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": await 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": [await user_to_dict(u, by_user.get(u.id, []), db) 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": await 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": await user_to_dict(user, await _user_projects(db, user))}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.put("/{user_id}/panels")
|
|
async def set_user_panels(
|
|
user_id,
|
|
payload: UserPanelsUpdate,
|
|
_: UserContext = Depends(require_role("admin")),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
|
|
user = await db.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("کاربر یافت نشد")
|
|
for panel, level in payload.panels.items():
|
|
if panel not in PANELS:
|
|
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
|
if level not in PANEL_LEVELS:
|
|
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
|
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
|
for panel, level in payload.panels.items():
|
|
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
|
await db.flush()
|
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
|
|
|
|
|
@router.put("/{user_id}/panel-projects")
|
|
async def set_user_panel_projects(
|
|
user_id,
|
|
payload: UserPanelProjectsUpdate,
|
|
_: UserContext = Depends(require_role("admin")),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
|
user = await db.get(User, user_id)
|
|
if user is None:
|
|
raise NotFoundError("کاربر یافت نشد")
|
|
wanted = list(dict.fromkeys(payload.project_ids))
|
|
if wanted:
|
|
found = (
|
|
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
|
).scalars().all()
|
|
found_ids = {p.id for p in found}
|
|
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
|
if unknown:
|
|
raise ValidationError(
|
|
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
|
)
|
|
await db.execute(
|
|
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
|
)
|
|
for pid in wanted:
|
|
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
|
await db.flush()
|
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|