feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon

This commit is contained in:
2026-09-27 18:42:05 +03:30
parent 387edfb1ab
commit c7d860f431
9 changed files with 138 additions and 73 deletions
+39 -18
View File
@@ -71,16 +71,17 @@ class ArrivalScope:
user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"),
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"),
db: AsyncSession = Depends(get_db),
) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to the requested project, which must be one they manage
PM / supervisor → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter).
"""
@@ -89,19 +90,19 @@ async def arrivals_scope(
if ctx is not None:
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm":
if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM.
@@ -110,8 +111,10 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id")
@@ -189,10 +192,17 @@ async def pm_on_the_way(
.outerjoin(Project, Project.id == ItemDecision.project_id)
.where(ItemDecision.status == "chosen")
)
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -243,8 +253,12 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if decision.project_id not in allowed:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id)
@@ -451,10 +465,17 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id)
)
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows]