feat(rbac): enforce procurement write restrictions and support supervisor role in SSO

This commit is contained in:
2026-09-26 12:32:01 +03:30
parent 730d0bd496
commit 5a9b543040
31 changed files with 480 additions and 139 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""",
"""
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"""
+1 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum(
"pm", "procurement_manager", "admin",
"pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True,
)
quote_status_enum = Enum(
+16 -3
View File
@@ -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("تعریف ویژگی یافت نشد")
+18 -3
View File
@@ -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("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -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("برند یافت نشد")
+4 -3
View File
@@ -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)
+16 -2
View File
@@ -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:
+18 -3
View File
@@ -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)
+9 -1
View File
@@ -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)
+19 -3
View File
@@ -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":
+18 -3
View File
@@ -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("دسته‌بندی یافت نشد")
+36 -7
View File
@@ -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():
+13 -3
View File
@@ -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:
@@ -77,7 +78,11 @@ async def list_projects(
@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,
@@ -103,6 +108,7 @@ 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:
@@ -133,7 +139,11 @@ async def update_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("پروژه یافت نشد")
+8 -1
View File
@@ -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("پیشنهاد (استعلام) یافت نشد")
+52 -9
View File
@@ -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}
+20 -3
View File
@@ -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(
+18 -3
View File
@@ -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("تبدیل استاندارد یافت نشد")
+12 -2
View File
@@ -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}}
+18 -3
View File
@@ -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("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -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":
+18 -3
View File
@@ -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("واحد یافت نشد")
+1 -1
View File
@@ -203,7 +203,7 @@ class UserCreate(APIModel):
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserRoleUpdate(APIModel):