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
This commit is contained in:
ali_mohaghegh
2026-09-01 17:28:06 +03:30
parent c59f5ad1a3
commit 83f4fb0747
6 changed files with 131 additions and 15 deletions
+5
View File
@@ -49,6 +49,11 @@ class Settings(BaseSettings):
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
# Casdoor console admin — used by POST /users/sync to mirror Casdoor
# identities into the app's users table (dev defaults: admin / 123).
CASDOOR_ADMIN_USER: str = "admin"
CASDOOR_ADMIN_PASSWORD: str = "123"
# Inventory dev fallback (only when no Authorization header is sent).
INVENTORY_DEFAULT_ROLE: str = "pm"
INVENTORY_DEFAULT_PROJECT_ID: str = ""
+5 -1
View File
@@ -10,6 +10,7 @@ Authorization (v2.6):
"""
import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from decimal import Decimal
@@ -465,7 +466,10 @@ async def create_transaction(
db: AsyncSession = Depends(get_db),
):
_require_pm(scope)
async with db.begin():
# No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure.
with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
+17 -5
View File
@@ -25,11 +25,13 @@ inventory router.
"""
import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
@@ -113,9 +115,14 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
from app.models.procurement import User
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
if dev is None:
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
db.add(dev)
await db.flush()
# Insert-or-ignore: concurrent dev-mode requests must not race on the
# unique casdoor_id ("dev") and 500 with a unique violation.
await db.execute(
pg_insert(User)
.values(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
.on_conflict_do_nothing(index_elements=["casdoor_id"])
)
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
return ArrivalScope(role=role, user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
@@ -222,7 +229,11 @@ async def pm_confirm_receipt(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
async with db.begin():
# No explicit db.begin(): the session is already inside an implicit
# transaction (arrivals_scope read from it above). get_db commits on
# success and rolls back on failure, so atomicity is preserved — opening
# another transaction here would raise "A transaction is already begun".
with nullcontext():
# --- decision must be chosen ---
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
@@ -400,9 +411,10 @@ async def pm_confirm_receipt(
quote.status = "arrived"
quote.arrived_date = payload.received_at
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
return {"data": {
"receipt_id": str(receipt.id),
"state": "arrived",
"state": item_state,
"posted_transactions": [str(tx.id) for tx in posted],
}}
+84 -4
View File
@@ -13,15 +13,23 @@ 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
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, ValidationError
from app.utils.errors import (
ConflictError,
ForbiddenError,
NotFoundError,
ServiceUnavailableError,
ValidationError,
)
from app.utils.serialization import to_iso
router = APIRouter(prefix="/users", tags=["users"])
@@ -68,6 +76,32 @@ async def _unassign_projects(db: AsyncSession, 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
@@ -119,10 +153,12 @@ async def create_user(
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: UserContext = Depends(_org_manager),
_: UserContext = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role. Revoking (or switching away from «pm») unassigns projects."""
"""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("کاربر یافت نشد")
@@ -134,6 +170,50 @@ async def update_user_role(
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,
+19 -4
View File
@@ -8,6 +8,7 @@ queries per related table, then assembly from in-memory maps.
"""
import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -305,7 +306,7 @@ class _LineItemData:
self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {}
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
self.decisions: dict[uuid.UUID, ItemDecision] = {}
self.receipts_by_decision: dict[uuid.UUID, ItemReceipt] = {}
self.receipts_by_decision: dict[uuid.UUID, list[ItemReceipt]] = {}
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
@@ -374,7 +375,10 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
dec_ids = [d.id for d in decisions]
if dec_ids:
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
data.receipts_by_decision = {r.item_decision_id: r for r in receipts}
by_dec: dict[uuid.UUID, list[ItemReceipt]] = {}
for r in receipts:
by_dec.setdefault(r.item_decision_id, []).append(r)
data.receipts_by_decision = by_dec
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
@@ -451,8 +455,19 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
if chosen_it is not None:
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
if "receipt" in expand:
r = data.receipts_by_decision.get(decision.id) if decision is not None else None
d["receipt"] = receipt_brief(r) if r is not None else None
# 1:N partial deliveries — emit the aggregate (sum of received qty,
# latest receipt kept for the received_at contract).
rs = data.receipts_by_decision.get(decision.id) if decision is not None else None
if rs:
latest = rs[-1]
total = sum((r.received_qty for r in rs), Decimal("0"))
d["receipt"] = {
**receipt_brief(latest),
"received_qty": float(total),
"receipt_count": len(rs),
}
else:
d["receipt"] = None
if include_termsheet_ids:
d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])]
return d