feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:24 +03:30
parent 034c05c713
commit f48f3752af
4 changed files with 403 additions and 22 deletions
+116 -10
View File
@@ -1,17 +1,27 @@
"""Users — profile + role assignment (v2.6, Casdoor-backed).
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
GET /users/me — the caller's profile: role, PM's project, display name.
PATCH /users/{id} — assign role (admin only).
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
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
from app.auth import UserContext, get_current_user
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.schemas.procurement import UserRoleUpdate
from app.utils.errors import NotFoundError
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.utils.serialization import to_iso
router = APIRouter(prefix="/users", tags=["users"])
@@ -38,23 +48,119 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
}
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)
)
@router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await db.scalar(select(Project).where(Project.pm_user_id == ctx.user.id)) if ctx.role == "pm" else None
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")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role. Revoking (or switching away from «pm») unassigns projects."""
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 db.scalar(select(Project).where(Project.pm_user_id == user.id)) if user.role == "pm" else None
project = await _user_project(db, user)
return {"data": user_to_dict(user, project)}
@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)}
+24 -1
View File
@@ -188,8 +188,31 @@ class PMReceiptCreate(APIModel):
quality_note: str | None = None
class UserCreate(APIModel):
"""Define a new user from the Organization panel (admin / procurement manager).
casdoor_id is optional: real Casdoor users auto-provision on their first
login; a manual entry gets a synthetic casdoor_id so the row can exist
before the person ever signs in.
"""
first_name: str = Field(min_length=1)
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserRoleUpdate(APIModel):
role: Literal["pm", "procurement_manager", "admin"]
"""role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserProjectUpdate(APIModel):
"""Assign/unassign the user's single project (project_id=None unassigns)."""
project_id: UUID | None = None
class RejectQuoteIn(APIModel):
+27
View File
@@ -1325,6 +1325,20 @@ const rawInventory = (r: {
interface RawCat { id: string; slug: string; name: string; }
interface RawUnitL { id: number; code: string; name_fa: string | null; }
// Organization panel — one user row (backend GET /users shape).
export interface UserRow {
id: string;
casdoor_id: string;
first_name: string;
last_name: string;
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null;
project_id: string | null;
project_name: string | null;
created_at: string;
}
export const apiV26 = {
lookup: {
categories: async (): Promise<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
@@ -1356,6 +1370,19 @@ export const apiV26 = {
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
};
},
// --- Organization panel (admin / procurement manager) ---
list: async (): Promise<UserRow[]> => {
const data = await getData<UserRow[]>("/users");
return data;
},
create: async (body: {
first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
},
warehouses: {
+236 -11
View File
@@ -1,35 +1,124 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { apiV26 } from "../../lib/api";
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26, type UserRow } from "../../lib/api";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
// سازمان‌دهی (فقط ادمین) — پروژه‌ها، مدیران و انبارهای هر پروژه
type Role = "pm" | "procurement_manager" | "admin" | null;
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
};
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() {
const location = useLocation();
const navigate = useNavigate();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [newWh, setNewWh] = useState<Record<string, string>>({});
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
// کاربر جدید (تعریف مدیر پروژه)
const [newUserFirst, setNewUserFirst] = useState("");
const [newUserLast, setNewUserLast] = useState("");
const [newUserCasdoor, setNewUserCasdoor] = useState("");
const [newUserRole, setNewUserRole] = useState<"pm" | "procurement_manager">("pm");
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین است.</div>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const addUser = async () => {
const first = newUserFirst.trim();
if (!first) return toast.error("نام کاربر الزامی است");
try {
await apiV26.users.create({
first_name: first,
last_name: newUserLast.trim() || "",
casdoor_id: newUserCasdoor.trim() || null,
role: newUserRole,
});
toast.success(`کاربر «${first}» تعریف شد ✓`);
setNewUserFirst("");
setNewUserLast("");
setNewUserCasdoor("");
await loadUsers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تعریف کاربر");
}
};
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
const changeProject = async (u: UserRow, projectId: string) => {
setBusyId(u.id);
try {
const project = data.lookup.projects.find((p) => p.id === projectId);
await apiV26.users.assignProject(u.id, projectId || null);
toast.success(project ? `«${u.first_name} ${u.last_name}» به پروژه «${project.name}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد");
await loadUsers();
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه");
} finally {
setBusyId(null);
}
};
// ---------- actions: warehouses ----------
const addWarehouse = async (projectId: string) => {
const name = (newWh[projectId] || "").trim();
if (!name) return toast.error("نام انبار الزامی است");
@@ -65,18 +154,147 @@ export function OrganizationPage() {
}
};
const pmOf = (projectId: string) => users.find((u) => u.role === "pm" && u.project_id === projectId) || null;
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" sub="ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header
title="سازمان‌دهی"
sub="ادمین / مدیر تدارکات"
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} variant="admin" />}
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ پروژه‌ها از سیستم تدارکات می‌آیند. اینجا مشخص می‌کنید هر پروژه با کدام مدیر و انبار(ها) کار می‌کند؛
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
</div>
{/* ---------- کاربران و مدیران پروژه ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<h3 className="mb-2 text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
{/* تعریف کاربر جدید */}
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-accent/40 bg-accent/5 p-3">
<div className="field mb-0">
<label>نام *</label>
<input
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="مثلاً رضایی"
value={newUserFirst}
onChange={(e) => setNewUserFirst(e.target.value)}
/>
</div>
<div className="field mb-0">
<label>نام خانوادگی</label>
<input
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="(اختیاری)"
value={newUserLast}
onChange={(e) => setNewUserLast(e.target.value)}
/>
</div>
<div className="field mb-0">
<label>نقش</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
value={newUserRole ?? ""}
onChange={(e) => setNewUserRole(e.target.value as "pm" | "procurement_manager")}
>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
</select>
</div>
<div className="field mb-0">
<label>شناسه Casdoor</label>
<input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="(اختیاری — برای کاربر واقعی)"
value={newUserCasdoor}
onChange={(e) => setNewUserCasdoor(e.target.value)}
/>
</div>
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addUser()}>
+ تعریف کاربر
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛
یا از فرم بالا یک مدیر پروژه تعریف کنید.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
<th className="py-2">عملیات</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-1.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
</td>
<td className="py-1.5 pe-3">
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="admin">ادمین</option>
</select>
</td>
<td className="py-1.5 pe-3">
{u.role === "pm" ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.project_id || ""}
disabled={busyId === u.id}
onChange={(e) => void changeProject(u, e.target.value)}
>
<option value="">— بدون پروژه —</option>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<span className="text-[11.5px] text-muted">—</span>
)}
</td>
<td className="py-1.5">
{u.role === "pm" && u.project_id && (
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا می‌شود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابه‌جا می‌کند.
</div>
</div>
)}
</div>
{/* ---------- پروژه‌ها و انبارها ---------- */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.lookup.projects.map((p) => {
const whs = data.warehouses.filter((w) => w.project_id === p.id);
const pm = pmOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
@@ -88,14 +306,14 @@ export function OrganizationPage() {
<div className="mb-3">
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
{p.pm_user_id ? (
{pm ? (
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<span className="font-semibold">{p.pm_name || "—"}</span>
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
</div>
) : (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
</div>
)}
</div>
@@ -112,6 +330,13 @@ export function OrganizationPage() {
<small className="block truncate text-muted">{w.description || ""}</small>
</div>
<div className="flex items-center gap-1.5">
<button
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
title="داشبورد این انبار"
>
📊 داشبورد
</button>
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
value={w.project_id}