Files
ERP/backend/app/routers/attribute_definitions.py
T
MMD 0ea0322967 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
2026-10-01 15:24:56 +03:30

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}}