Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on top of the restored RBAC base. Conflicts against newer integration work resolved by combining: - App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside the supervisor gating (no-project block screen, panel scoping) - LoginPage: returnUrl navigation kept, supervisor routing added - migration.sql: bot_drafts table kept, unified-projects ALTERs restored
This commit is contained in:
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||
from app.models.procurement import LineItemSpec
|
||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_attribute_definition(
|
||||
payload: AttributeDefinitionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
||||
|
||||
@router.patch("/{definition_id}")
|
||||
async def update_attribute_definition(
|
||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
||||
definition_id,
|
||||
payload: AttributeDefinitionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
||||
|
||||
|
||||
@router.delete("/{definition_id}")
|
||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_attribute_definition(
|
||||
definition_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.routers.users import _user_projects, user_to_dict
|
||||
from app.services import analytics
|
||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
||||
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||
from app.utils.ratelimit import limiter
|
||||
|
||||
logger = logging.getLogger("procurement.auth")
|
||||
@@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
claims = await _verify_token(access_token)
|
||||
ctx = await context_from_claims(claims, db)
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
if ctx.role == "supervisor" and not projects:
|
||||
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||
await analytics.track(
|
||||
"login_success",
|
||||
{"username": payload.username, "role": ctx.role},
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial, Material
|
||||
from app.models.procurement import LineItem
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_brand_material(
|
||||
payload: BrandMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
if await db.get(Brand, payload.brand_id) is None:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
if await db.get(Material, payload.material_id) is None:
|
||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
||||
|
||||
|
||||
@router.patch("/{bm_id}")
|
||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_brand_material(
|
||||
bm_id,
|
||||
payload: BrandMaterialUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
||||
|
||||
|
||||
@router.delete("/{bm_id}")
|
||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_brand_material(
|
||||
bm_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial
|
||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def brand_to_dict(brand: Brand) -> dict:
|
||||
@@ -42,7 +44,11 @@ async def list_brands(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_brand(
|
||||
payload: BrandCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||
if any(b.country == payload.country for b in rows):
|
||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{brand_id}")
|
||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_brand(
|
||||
brand_id,
|
||||
payload: BrandUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
||||
|
||||
|
||||
@router.delete("/{brand_id}")
|
||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_brand(
|
||||
brand_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
|
||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
||||
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||
# برای هر کاربر واردشده آزاد است.
|
||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||
_attachment_writer = require_role("procurement_manager", "admin")
|
||||
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
||||
# Proforma — no status restriction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
|
||||
@@ -77,23 +77,21 @@ async def inventory_scope(
|
||||
if user is not None:
|
||||
if user.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
||||
if user.role == "procurement_manager":
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
if user.role not in ("pm", "admin"):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
||||
if user.role == "pm":
|
||||
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||
if user.role in ("procurement_manager", "pm"):
|
||||
return Scope(role="manager", project_id=project_id, actor=user.display_name)
|
||||
if user.role == "supervisor":
|
||||
allowed = list(user.project_ids or [])
|
||||
if not allowed:
|
||||
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
||||
if not allowed:
|
||||
raise ValidationError(
|
||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
||||
)
|
||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
return Scope(role="supervisor", project_id=project_id, actor=user.display_name)
|
||||
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name)
|
||||
if user.role == "admin":
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
raise ValidationError("نقش نامعتبر است", field="role")
|
||||
|
||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||
@@ -128,9 +126,9 @@ async def inventory_scope(
|
||||
return Scope(role=role, project_id=project_id, actor=actor)
|
||||
|
||||
|
||||
def _require_pm(scope: Scope) -> None:
|
||||
if scope.role != ROLE_PM:
|
||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
||||
def _require_inventory_modifier(scope: Scope) -> None:
|
||||
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -216,8 +214,8 @@ async def list_warehouses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Warehouse)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
stmt = stmt.order_by(Warehouse.name)
|
||||
@@ -324,7 +322,7 @@ async def list_inventory(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(InventoryItem)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == scope.project_id
|
||||
)
|
||||
@@ -389,7 +387,7 @@ async def list_transactions(
|
||||
stmt = select(InventoryTransaction).join(
|
||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
@@ -455,7 +453,7 @@ async def inventory_history(
|
||||
if item is None:
|
||||
raise NotFoundError("موجودی یافت نشد")
|
||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||
txs = (await db.execute(
|
||||
select(InventoryTransaction)
|
||||
@@ -475,17 +473,17 @@ async def create_transaction(
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_require_pm(scope)
|
||||
_require_inventory_modifier(scope)
|
||||
# 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:
|
||||
if scope.project_id is not None and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.dest_warehouse_id is not None:
|
||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||
if dest.project_id != scope.project_id:
|
||||
if scope.project_id is not None and dest.project_id != scope.project_id:
|
||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||
await audit_record(
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||
tags=["item-receipts"],
|
||||
)
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_receipt(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ReceiptCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await _decision_or_404(db, line_item_id)
|
||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
||||
|
||||
|
||||
@router.patch("")
|
||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_receipt(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ReceiptUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||
tags=["item-termsheets"],
|
||||
)
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_item_termsheet(
|
||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ItemTermsheetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
||||
|
||||
@router.patch("/{item_termsheet_id}")
|
||||
async def update_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
item_termsheet_id,
|
||||
payload: ItemTermsheetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
||||
|
||||
@router.delete("/{item_termsheet_id}")
|
||||
async def delete_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
item_termsheet_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
||||
async def put_line_item_specs(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: SpecsPutIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, Unit
|
||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_line_item(
|
||||
quote_id,
|
||||
payload: LineItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
||||
|
||||
@router.patch("/{line_item_id}")
|
||||
async def update_line_item(
|
||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: LineItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
||||
|
||||
|
||||
@router.delete("/{line_item_id}")
|
||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_line_item(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Supplier
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def category_to_dict(category: MaterialCategory) -> dict:
|
||||
@@ -46,7 +48,11 @@ async def list_categories(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_category(
|
||||
payload: MaterialCategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{category_id}")
|
||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_category(
|
||||
category_id,
|
||||
payload: MaterialCategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_category(
|
||||
category_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import (
|
||||
AttributeDefinition,
|
||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def _coerce_filter_value(data_type: str, raw: str):
|
||||
@@ -125,7 +127,10 @@ async def list_materials(
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_material(
|
||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
payload: MaterialCreate,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await material_service.create_material(db, payload)
|
||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
||||
|
||||
|
||||
@router.patch("/{material_id}")
|
||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_material(
|
||||
material_id,
|
||||
payload: MaterialUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@router.delete("/{material_id}")
|
||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_material(
|
||||
material_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -248,7 +262,10 @@ async def list_material_units(
|
||||
|
||||
@router.post("/{material_id}/units", status_code=201)
|
||||
async def create_material_unit(
|
||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
payload: MaterialUnitCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
await _material_or_404(db, material_id)
|
||||
@@ -287,7 +304,11 @@ async def create_material_unit(
|
||||
|
||||
@router.patch("/{material_id}/units/{mu_id}")
|
||||
async def update_material_unit(
|
||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
mu_id,
|
||||
payload: MaterialUnitUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
mu = await db.scalar(
|
||||
@@ -319,7 +340,12 @@ async def update_material_unit(
|
||||
|
||||
|
||||
@router.delete("/{material_id}/units/{mu_id}")
|
||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_material_unit(
|
||||
material_id,
|
||||
mu_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
mu = await db.scalar(
|
||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||
)
|
||||
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
|
||||
|
||||
@router.put("/{material_id}/attribute-values")
|
||||
async def put_material_attribute_values(
|
||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
payload: MaterialAttributeValuesPut,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||
async with db.begin():
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.auth import UserContext, get_optional_user, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, Quote, User
|
||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_date, to_iso
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
||||
d = {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"location": project.location or "",
|
||||
"project_manager": project.project_manager or "",
|
||||
"description": project.description or "",
|
||||
"start_date": to_date(project.start_date),
|
||||
"status": project.status or "ACTIVE",
|
||||
"progress": float(project.progress) if project.progress is not None else 0.0,
|
||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||
"created_at": to_iso(project.created_at),
|
||||
}
|
||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
||||
pm = await db.get(User, project.pm_user_id)
|
||||
if pm:
|
||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||
elif project.project_manager:
|
||||
d["pm_name"] = project.project_manager
|
||||
return d
|
||||
|
||||
|
||||
@@ -55,7 +62,14 @@ async def list_projects(
|
||||
):
|
||||
stmt = select(Project)
|
||||
if q:
|
||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Project.name.ilike(f"%{q}%"),
|
||||
Project.description.ilike(f"%{q}%"),
|
||||
Project.location.ilike(f"%{q}%"),
|
||||
Project.project_manager.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
stmt = sort_query(stmt, Project, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
pm_map = await _pm_map(db, items)
|
||||
@@ -64,24 +78,38 @@ async def list_projects(
|
||||
d = {
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"location": p.location or "",
|
||||
"project_manager": p.project_manager or "",
|
||||
"description": p.description or "",
|
||||
"start_date": to_date(p.start_date),
|
||||
"status": p.status or "ACTIVE",
|
||||
"progress": float(p.progress) if p.progress is not None else 0.0,
|
||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||
"created_at": to_iso(p.created_at),
|
||||
}
|
||||
pm = pm_map.get(p.pm_user_id)
|
||||
if pm:
|
||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||
elif p.project_manager:
|
||||
d["pm_name"] = p.project_manager
|
||||
data.append(d)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_project(
|
||||
payload: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = Project(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
name=payload.name.strip(),
|
||||
location=payload.location.strip() if payload.location else "",
|
||||
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||
description=payload.description.strip() if payload.description else "",
|
||||
start_date=payload.start_date,
|
||||
planned_start=payload.start_date,
|
||||
status="ACTIVE",
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -103,37 +131,51 @@ async def update_project(
|
||||
request: Request,
|
||||
ctx: UserContext | None = Depends(_optional_ctx),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
project.name = payload.name.strip()
|
||||
if payload.location is not None:
|
||||
project.location = payload.location.strip()
|
||||
if payload.project_manager is not None:
|
||||
project.project_manager = payload.project_manager.strip()
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
project.description = payload.description.strip()
|
||||
if payload.start_date is not None:
|
||||
project.start_date = payload.start_date
|
||||
project.planned_start = payload.start_date
|
||||
if payload.status is not None:
|
||||
project.status = payload.status
|
||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||
# Admin / procurement manager only (same rule as the Organization panel).
|
||||
if "pm_user_id" in payload.model_fields_set:
|
||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role not in ("admin", "procurement_manager"):
|
||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
||||
if role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||
if payload.pm_user_id is None:
|
||||
project.pm_user_id = None
|
||||
else:
|
||||
pm_user = await db.get(User, payload.pm_user_id)
|
||||
if pm_user is None:
|
||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||
if pm_user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
||||
if pm_user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
|
||||
project.pm_user_id = pm_user.id
|
||||
if not project.project_manager:
|
||||
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
|
||||
await db.flush()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_project(
|
||||
project_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote, QuoteEvent
|
||||
from app.schemas.procurement import NoteEventIn
|
||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -34,7 +36,12 @@ async def list_events(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
||||
async def create_event(
|
||||
quote_id,
|
||||
payload: NoteEventIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material
|
||||
from app.models.procurement import (
|
||||
@@ -42,6 +43,9 @@ from app.utils.security import get_actor
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
|
||||
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
# --- list filter helpers ---------------------------------------------------
|
||||
|
||||
@@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def create_quote(
|
||||
payload: QuoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.create_quote(db, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
@@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
||||
|
||||
@router.patch("/{quote_id}")
|
||||
async def update_quote(
|
||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: QuoteUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||
return {"data": await serialize_quote(db, quote, set())}
|
||||
@@ -175,7 +188,11 @@ async def update_quote(
|
||||
|
||||
@router.patch("/{quote_id}/children")
|
||||
async def replace_quote_children(
|
||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: QuoteChildrenReplaceIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||
@@ -188,7 +205,11 @@ async def replace_quote_children(
|
||||
|
||||
@router.patch("/{quote_id}/accept")
|
||||
async def accept_quote(
|
||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: AcceptQuoteIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||
return {"data": await _accept_response(db, quote)}
|
||||
@@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/arrive")
|
||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def arrive_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||
return {
|
||||
"data": {
|
||||
@@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
|
||||
@router.patch("/{quote_id}/arrive-items")
|
||||
async def arrive_items(
|
||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: ArriveItemsIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||
return {
|
||||
@@ -259,14 +289,23 @@ async def arrive_items(
|
||||
|
||||
@router.patch("/{quote_id}/reject")
|
||||
async def reject_quote(
|
||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: RejectQuoteIn | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/expire")
|
||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def expire_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
@@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{quote_id}")
|
||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
result = await quote_service.delete_quote(db, quote_id)
|
||||
return {"data": result}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||
tags=["schedule-steps"],
|
||||
)
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_schedule_step(
|
||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: ScheduleStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
step_no = payload.step_no
|
||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
||||
|
||||
@router.patch("/{step_id}")
|
||||
async def update_schedule_step(
|
||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
step_id,
|
||||
payload: ScheduleStepUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
||||
|
||||
|
||||
@router.delete("/{step_id}")
|
||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_schedule_step(
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
step_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import StandardConversion
|
||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_standard_conversion(
|
||||
payload: StandardConversionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(
|
||||
select(StandardConversion.id).where(
|
||||
StandardConversion.from_unit == payload.from_unit,
|
||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{sc_id}")
|
||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_standard_conversion(
|
||||
sc_id,
|
||||
payload: StandardConversionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
||||
|
||||
|
||||
@router.delete("/{sc_id}")
|
||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_standard_conversion(
|
||||
sc_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.procurement import Supplier, SupplierMaterial
|
||||
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(tags=["supplier-materials"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||
@@ -63,7 +65,10 @@ async def list_supplier_materials(
|
||||
|
||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||
async def create_supplier_material(
|
||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
||||
supplier_id,
|
||||
payload: SupplierMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||
@@ -72,7 +77,12 @@ async def create_supplier_material(
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_supplier_material(
|
||||
supplier_id,
|
||||
material_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Quote, Supplier
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_supplier(
|
||||
payload: SupplierCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await _category_or_404(db, payload.category_id)
|
||||
supplier = Supplier(
|
||||
name=payload.name,
|
||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{supplier_id}")
|
||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_supplier(
|
||||
supplier_id,
|
||||
payload: SupplierUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_supplier(
|
||||
supplier_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_termsheet(
|
||||
quote_id,
|
||||
payload: TermsheetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}")
|
||||
async def update_termsheet(
|
||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: TermsheetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
if ts.status != "offered":
|
||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}/status")
|
||||
async def update_termsheet_status(
|
||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: TermsheetStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
||||
|
||||
|
||||
@router.delete("/{termsheet_id}")
|
||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_termsheet(
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec
|
||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/units", tags=["units"])
|
||||
_procurement_writer = require_role("admin", "procurement_manager")
|
||||
|
||||
|
||||
def unit_full_to_dict(unit: Unit) -> dict:
|
||||
@@ -45,7 +47,11 @@ async def list_units(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_unit(
|
||||
payload: UnitCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{unit_id}")
|
||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_unit(
|
||||
unit_id: int,
|
||||
payload: UnitUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
||||
|
||||
|
||||
@router.delete("/{unit_id}")
|
||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_unit(
|
||||
unit_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
|
||||
@@ -17,7 +17,7 @@ import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_current_user, require_role
|
||||
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
|
||||
"pm": "مدیر پروژه",
|
||||
"procurement_manager": "مدیر تدارکات",
|
||||
"admin": "ادمین",
|
||||
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||
"supervisor": "سرپرست پروژه",
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
@@ -65,15 +68,15 @@ def user_to_dict(user: User, projects: list[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"):
|
||||
"""Admin, procurement manager, or project manager — organization management."""
|
||||
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||
return ctx
|
||||
|
||||
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
||||
if user.role != "pm":
|
||||
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(
|
||||
@@ -148,13 +151,13 @@ async def list_users(
|
||||
):
|
||||
"""Every user (with role + assigned projects) — 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"}
|
||||
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -204,7 +207,7 @@ async def update_user_role(
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
previous_role = user.role
|
||||
if user.role == "pm" and payload.role != "pm":
|
||||
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||
await _unassign_projects(db, user.id)
|
||||
user.role = payload.role
|
||||
await db.flush()
|
||||
@@ -231,8 +234,15 @@ async def sync_casdoor_users(
|
||||
continue
|
||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||
if existing is None:
|
||||
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||
if uname:
|
||||
by_uname = await db.scalar(select(User).where(User.username == uname))
|
||||
if by_uname is not None:
|
||||
by_uname.casdoor_id = cid
|
||||
continue
|
||||
db.add(User(
|
||||
casdoor_id=cid,
|
||||
username=uname,
|
||||
first_name=str(
|
||||
cu.get("firstName")
|
||||
or cu.get("displayName")
|
||||
@@ -243,16 +253,20 @@ async def sync_casdoor_users(
|
||||
phone=str(cu.get("phone") or "") or None,
|
||||
))
|
||||
created += 1
|
||||
elif not existing.username:
|
||||
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||
if uname:
|
||||
existing.username = uname
|
||||
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"}
|
||||
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -282,11 +296,16 @@ async def assign_user_project(
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
if user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||
|
||||
if payload.project_id is None:
|
||||
await _unassign_projects(db, user.id)
|
||||
if user.username:
|
||||
await db.execute(
|
||||
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
|
||||
{"uname": user.username}
|
||||
)
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user)}
|
||||
|
||||
@@ -294,5 +313,19 @@ async def assign_user_project(
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
project.pm_user_id = user.id
|
||||
if user.username:
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO project_assignments (id, user_id, project_id)
|
||||
SELECT gen_random_uuid(), pu.id, :pid
|
||||
FROM pm_users pu
|
||||
WHERE pu.username = :uname
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM project_assignments pa
|
||||
WHERE pa.user_id = pu.id AND pa.project_id = :pid
|
||||
)
|
||||
"""),
|
||||
{"pid": project.id, "uname": user.username}
|
||||
)
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||
|
||||
Reference in New Issue
Block a user