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
172 lines
6.6 KiB
Python
172 lines
6.6 KiB
Python
"""Attribute definitions (global attribute dictionary) CRUD — P2 set."""
|
|
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
|
|
from app.schemas.catalog import AttributeDefinitionCreate, AttributeDefinitionUpdate
|
|
from app.services.expand_service import unit_to_dict
|
|
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|
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:
|
|
d = {
|
|
"id": str(ad.id),
|
|
"attribute_key": ad.attribute_key,
|
|
"label": ad.label,
|
|
"data_type": ad.data_type,
|
|
"unit_id": ad.unit_id,
|
|
"enum_values": ad.enum_values,
|
|
"created_at": to_iso(ad.created_at),
|
|
}
|
|
if unit is not None:
|
|
d["unit"] = unit_to_dict(unit)
|
|
return d
|
|
|
|
|
|
@router.get("")
|
|
async def list_attribute_definitions(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
q: str | None = None,
|
|
data_type: str | None = None,
|
|
unit_id: int | None = None,
|
|
sort: str | None = None,
|
|
expand: str | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
stmt = select(AttributeDefinition)
|
|
if q:
|
|
stmt = stmt.where(
|
|
or_(
|
|
AttributeDefinition.attribute_key.ilike(f"%{q}%"),
|
|
AttributeDefinition.label.ilike(f"%{q}%"),
|
|
)
|
|
)
|
|
if data_type:
|
|
stmt = stmt.where(AttributeDefinition.data_type == data_type)
|
|
if unit_id is not None:
|
|
stmt = stmt.where(AttributeDefinition.unit_id == unit_id)
|
|
stmt = sort_query(stmt, AttributeDefinition, sort)
|
|
items, meta = await paginate(stmt, page, page_size, db)
|
|
|
|
units = {}
|
|
if expand and "unit" in {t.strip() for t in expand.split(",")}:
|
|
unit_ids = {ad.unit_id for ad in items if ad.unit_id is not None}
|
|
if unit_ids:
|
|
rows = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
|
|
units = {u.id: u for u in rows}
|
|
return {
|
|
"data": [attr_def_to_dict(ad, units.get(ad.unit_id)) for ad in items],
|
|
"meta": meta,
|
|
}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
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")
|
|
if payload.data_type == "enum" and not payload.enum_values:
|
|
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
|
|
ad = AttributeDefinition(
|
|
attribute_key=payload.attribute_key,
|
|
label=payload.label,
|
|
data_type=payload.data_type,
|
|
unit_id=payload.unit_id,
|
|
enum_values=payload.enum_values,
|
|
)
|
|
db.add(ad)
|
|
await db.flush()
|
|
return {"data": attr_def_to_dict(ad)}
|
|
|
|
|
|
@router.get("/{definition_id}")
|
|
async def get_attribute_definition(
|
|
definition_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
|
):
|
|
ad = await db.get(AttributeDefinition, definition_id)
|
|
if ad is None:
|
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
|
unit = None
|
|
if expand and "unit" in {t.strip() for t in expand.split(",")} and ad.unit_id is not None:
|
|
unit = await db.get(Unit, ad.unit_id)
|
|
return {"data": attr_def_to_dict(ad, unit)}
|
|
|
|
|
|
@router.patch("/{definition_id}")
|
|
async def update_attribute_definition(
|
|
definition_id,
|
|
payload: AttributeDefinitionUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_auth=Depends(_procurement_writer),
|
|
):
|
|
ad = await db.get(AttributeDefinition, definition_id)
|
|
if ad is None:
|
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
|
|
|
if payload.data_type is not None and payload.data_type != ad.data_type:
|
|
# Changing data_type with existing values is a hard block (P2-9).
|
|
values = await db.scalar(
|
|
select(func.count()).select_from(MaterialAttributeValue).where(
|
|
MaterialAttributeValue.attribute_definition_id == definition_id
|
|
)
|
|
)
|
|
specs = await db.scalar(
|
|
select(func.count()).select_from(LineItemSpec).where(
|
|
LineItemSpec.attribute_definition_id == definition_id
|
|
)
|
|
)
|
|
if values or specs:
|
|
raise ConflictError("به دلیل وجود مقادیر ثبتشده، تغییر data_type امکانپذیر نیست", field="data_type")
|
|
ad.data_type = payload.data_type
|
|
if payload.data_type == "enum" and not payload.enum_values:
|
|
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
|
|
if payload.label is not None:
|
|
ad.label = payload.label
|
|
if payload.unit_id is not None:
|
|
ad.unit_id = payload.unit_id
|
|
if payload.enum_values is not None:
|
|
ad.enum_values = payload.enum_values
|
|
await db.flush()
|
|
return {"data": attr_def_to_dict(ad)}
|
|
|
|
|
|
@router.delete("/{definition_id}")
|
|
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("تعریف ویژگی یافت نشد")
|
|
# ON DELETE RESTRICT from material_attribute_values / line_item_specs — block if values exist.
|
|
values = await db.scalar(
|
|
select(func.count()).select_from(MaterialAttributeValue).where(
|
|
MaterialAttributeValue.attribute_definition_id == definition_id
|
|
)
|
|
)
|
|
specs = await db.scalar(
|
|
select(func.count()).select_from(LineItemSpec).where(
|
|
LineItemSpec.attribute_definition_id == definition_id
|
|
)
|
|
)
|
|
if values or specs:
|
|
raise ConflictError("این ویژگی دارای مقادیر ثبتشده است و قابل حذف نیست")
|
|
await db.delete(ad) # category_attributes cascade via DDL
|
|
await db.flush()
|
|
return {"data": {"id": str(definition_id), "deleted": True}}
|