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:
@@ -49,6 +49,11 @@ class Settings(BaseSettings):
|
|||||||
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
||||||
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
|
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 dev fallback (only when no Authorization header is sent).
|
||||||
INVENTORY_DEFAULT_ROLE: str = "pm"
|
INVENTORY_DEFAULT_ROLE: str = "pm"
|
||||||
INVENTORY_DEFAULT_PROJECT_ID: str = ""
|
INVENTORY_DEFAULT_PROJECT_ID: str = ""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Authorization (v2.6):
|
|||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -465,7 +466,10 @@ async def create_transaction(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_require_pm(scope)
|
_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)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if wh.project_id != scope.project_id:
|
if wh.project_id != scope.project_id:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ inventory router.
|
|||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
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
|
from app.models.procurement import User
|
||||||
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
|
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
|
||||||
if dev is None:
|
if dev is None:
|
||||||
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
|
# Insert-or-ignore: concurrent dev-mode requests must not race on the
|
||||||
db.add(dev)
|
# unique casdoor_id ("dev") and 500 with a unique violation.
|
||||||
await db.flush()
|
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 "کاربر آزمایشی")
|
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),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
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 must be chosen ---
|
||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
@@ -400,9 +411,10 @@ async def pm_confirm_receipt(
|
|||||||
quote.status = "arrived"
|
quote.status = "arrived"
|
||||||
quote.arrived_date = payload.received_at
|
quote.arrived_date = payload.received_at
|
||||||
|
|
||||||
|
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
|
||||||
return {"data": {
|
return {"data": {
|
||||||
"receipt_id": str(receipt.id),
|
"receipt_id": str(receipt.id),
|
||||||
"state": "arrived",
|
"state": item_state,
|
||||||
"posted_transactions": [str(tx.id) for tx in posted],
|
"posted_transactions": [str(tx.id) for tx in posted],
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ the same person in this system), unlike the plain require_role("admin").
|
|||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User
|
||||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
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
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
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")
|
@router.get("/me")
|
||||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
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
|
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(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
_: UserContext = Depends(_org_manager),
|
_: UserContext = Depends(require_role("admin")),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
@@ -134,6 +170,50 @@ async def update_user_role(
|
|||||||
return {"data": user_to_dict(user, project)}
|
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")
|
@router.post("/{user_id}/project")
|
||||||
async def assign_user_project(
|
async def assign_user_project(
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ queries per related table, then assembly from in-memory maps.
|
|||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -305,7 +306,7 @@ class _LineItemData:
|
|||||||
self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {}
|
self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {}
|
||||||
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
|
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
|
||||||
self.decisions: dict[uuid.UUID, ItemDecision] = {}
|
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_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
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]
|
dec_ids = [d.id for d in decisions]
|
||||||
if dec_ids:
|
if dec_ids:
|
||||||
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
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:
|
if unit_ids:
|
||||||
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
|
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:
|
if chosen_it is not None:
|
||||||
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
|
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
|
||||||
if "receipt" in expand:
|
if "receipt" in expand:
|
||||||
r = data.receipts_by_decision.get(decision.id) if decision is not None else None
|
# 1:N partial deliveries — emit the aggregate (sum of received qty,
|
||||||
d["receipt"] = receipt_brief(r) if r is not None else None
|
# 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:
|
if include_termsheet_ids:
|
||||||
d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])]
|
d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])]
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ ORG = "inventory"
|
|||||||
APP = "inventory-app"
|
APP = "inventory-app"
|
||||||
CLIENT_ID = "inventory-app-client"
|
CLIENT_ID = "inventory-app-client"
|
||||||
CLIENT_SECRET = "inventory-app-secret-2026"
|
CLIENT_SECRET = "inventory-app-secret-2026"
|
||||||
REDIRECT_URI = "http://localhost:3000/login"
|
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
|
||||||
|
|
||||||
USERS = [
|
USERS = [
|
||||||
# (name, first_name, last_name, email, password)
|
# (name, first_name, last_name, email, password)
|
||||||
|
|||||||
Reference in New Issue
Block a user