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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user