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