feat: deal attributes with select and options

This commit is contained in:
2025-10-29 19:37:27 +04:00
parent 0e8c9077c9
commit 82fcd6e8cb
14 changed files with 206 additions and 23 deletions

View File

@ -1,6 +1,10 @@
from sqlalchemy.orm import configure_mappers from sqlalchemy.orm import configure_mappers
from modules.fulfillment_base.models import * # noqa: F401 from modules.fulfillment_base.models import * # noqa: F401
from .attr_select import (
AttributeOption as AttributeOption,
AttributeSelect as AttributeSelect,
)
from .attribute import ( from .attribute import (
AttributeType as AttributeType, AttributeType as AttributeType,
Attribute as Attribute, Attribute as Attribute,

46
models/attr_select.py Normal file
View File

@ -0,0 +1,46 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
if TYPE_CHECKING:
from models import Attribute
class AttributeSelect(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attribute_selects"
label: Mapped[str] = mapped_column()
is_built_in: Mapped[bool] = mapped_column(
default=False,
comment="Если встроенный select, то запрещено редактировать пользователю",
)
options: Mapped[list["AttributeOption"]] = relationship(
back_populates="select",
lazy="noload",
)
attributes: Mapped[list["Attribute"]] = relationship(
back_populates="select",
lazy="noload",
)
class AttributeOption(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attribute_options"
value: Mapped[dict[str, any]] = mapped_column(JSONB)
label: Mapped[str] = mapped_column()
select_id: Mapped[int] = mapped_column(ForeignKey("attribute_selects.id"))
select: Mapped[AttributeSelect] = relationship(
back_populates="options",
lazy="noload",
)
__table_args__ = (UniqueConstraint("value", "select_id", name="_value_select_uc"),)

View File

@ -8,8 +8,7 @@ from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin from models.mixins import IdMixin, SoftDeleteMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from models import Module, Deal from models import Module, Deal, AttributeSelect
module_attribute = Table( module_attribute = Table(
"module_attribute", "module_attribute",
@ -51,6 +50,12 @@ class Attribute(BaseModel, IdMixin, SoftDeleteMixin):
lazy="joined", lazy="joined",
) )
select_id: Mapped[Optional[int]] = mapped_column(ForeignKey("attribute_selects.id"))
select: Mapped[Optional["AttributeSelect"]] = relationship(
back_populates="attributes",
lazy="joined",
)
modules: Mapped[list["Module"]] = relationship( modules: Mapped[list["Module"]] = relationship(
secondary=module_attribute, secondary=module_attribute,
back_populates="attributes", back_populates="attributes",

View File

@ -1,3 +1,4 @@
from .attr_select import AttrSelectRepository as AttrSelectRepository
from .attribute import AttributeRepository as AttributeRepository from .attribute import AttributeRepository as AttributeRepository
from .board import BoardRepository as BoardRepository from .board import BoardRepository as BoardRepository
from .deal import DealRepository as DealRepository from .deal import DealRepository as DealRepository

View File

@ -0,0 +1,19 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeSelect, AttributeOption
from repositories.base import BaseRepository
from repositories.mixins import RepGetAllMixin
class AttrSelectRepository(BaseRepository, RepGetAllMixin[AttributeSelect]):
session: AsyncSession
entity_class = AttributeSelect
async def get_options(self, select_id: int) -> list[AttributeOption]:
stmt = select(AttributeOption).where(
AttributeOption.select_id == select_id,
AttributeOption.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@ -31,29 +31,24 @@ class AttributeRepository(
return ( return (
stmt.options(joinedload(Attribute.type)) stmt.options(joinedload(Attribute.type))
.where(Attribute.is_deleted.is_(False)) .where(Attribute.is_deleted.is_(False))
.order_by(Attribute.id) .order_by(Attribute.is_built_in.desc(), Attribute.id)
) )
def _process_get_by_id_stmt(self, stmt: Select) -> Select: def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(joinedload(Attribute.type)) return stmt.options(joinedload(Attribute.type))
async def _get_attribute_type_by_id(self, type_id: int) -> AttributeType:
stmt = select(AttributeType).where(AttributeType.id == type_id)
result = (await self.session.execute(stmt)).one_or_none()
if result is None:
raise ObjectNotFoundException("Тип аттрибута не найден")
return result[0]
async def update(self, attr: Attribute, data: UpdateAttributeSchema) -> Attribute: async def update(self, attr: Attribute, data: UpdateAttributeSchema) -> Attribute:
if data.type: return await self._apply_update_data_to_model(
data.type = await self._get_attribute_type_by_id(data.type.id) attr, data, with_commit=True, set_if_value_is_not_none=False
return await self._apply_update_data_to_model(attr, data, True) )
async def _before_delete(self, attribute: Attribute) -> None: async def _before_delete(self, attribute: Attribute) -> None:
if attribute.is_built_in: if attribute.is_built_in:
raise ForbiddenException("Нельзя менять встроенный атрибут") raise ForbiddenException("Нельзя менять встроенный атрибут")
async def _get_all_attributes_for_deal(self, project_id) -> list[tuple[Attribute, int]]: async def _get_all_attributes_for_deal(
self, project_id
) -> list[tuple[Attribute, int]]:
stmt = ( stmt = (
select(Attribute, Module.id) select(Attribute, Module.id)
.join(Attribute.modules) .join(Attribute.modules)
@ -148,9 +143,8 @@ class AttributeRepository(
AttributeLabel.module_id == module_id, AttributeLabel.module_id == module_id,
), ),
) )
.where( .where(Attribute.is_deleted.is_(False))
Attribute.is_deleted.is_(False), .options(joinedload(Attribute.select))
)
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.all()) return list(result.all())

View File

@ -65,13 +65,14 @@ class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityT
data: UpdateSchemaType, data: UpdateSchemaType,
with_commit: Optional[bool] = False, with_commit: Optional[bool] = False,
fields: Optional[list[str]] = None, fields: Optional[list[str]] = None,
set_if_value_is_not_none: Optional[bool] = True,
) -> EntityType: ) -> EntityType:
if fields is None: if fields is None:
fields = data.model_dump().keys() fields = data.model_dump().keys()
for field in fields: for field in fields:
value = getattr(data, field) value = getattr(data, field)
if value is not None: if not set_if_value_is_not_none or value is not None:
setattr(model, field, value) setattr(model, field, value)
if with_commit: if with_commit:

View File

@ -0,0 +1,30 @@
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.attr_select import *
from services import AttrSelectService
router = APIRouter(tags=["attr_select"])
@router.get(
"/",
response_model=GetAllAttrSelectsResponse,
operation_id="get_attr_selects",
)
async def get_attr_selects(
session: SessionDependency,
):
return await AttrSelectService(session).get_all()
@router.get(
"/{selectId}",
response_model=GetAllAttrSelectOptionsResponse,
operation_id="get_attr_select_options",
)
async def get_attr_select_options(
session: SessionDependency,
select_id: int = Path(alias="selectId"),
):
return await AttrSelectService(session).get_options(select_id)

43
schemas/attr_select.py Normal file
View File

@ -0,0 +1,43 @@
from typing import Any
from schemas.base import BaseSchema
# region Entity
class AttrSelectSchema(BaseSchema):
id: int
label: str
is_built_in: bool
class AttrOptionSchema(BaseSchema):
id: int
label: str
value: Any
class AttrSelectWithOptionsSchema(AttrSelectSchema):
options: list[AttrOptionSchema]
# endregion
# region Request
# endregion
# region Response
class GetAllAttrSelectsResponse(BaseSchema):
items: list[AttrSelectSchema]
class GetAllAttrSelectOptionsResponse(BaseSchema):
items: list[AttrOptionSchema]
# endregion

View File

@ -12,6 +12,11 @@ class AttributeTypeSchema(BaseSchema):
name: str name: str
class AttributeSelectSchema(BaseSchema):
id: int
label: str
class CreateAttributeSchema(BaseSchema): class CreateAttributeSchema(BaseSchema):
label: str label: str
is_applicable_to_group: bool is_applicable_to_group: bool
@ -19,12 +24,14 @@ class CreateAttributeSchema(BaseSchema):
default_value: Optional[Any] default_value: Optional[Any]
description: str description: str
type_id: int type_id: int
select_id: Optional[int]
class AttributeSchema(CreateAttributeSchema): class AttributeSchema(CreateAttributeSchema):
id: int id: int
is_built_in: bool is_built_in: bool
type: AttributeTypeSchema type: AttributeTypeSchema
select: Optional[AttributeSelectSchema]
class UpdateAttributeSchema(BaseSchema): class UpdateAttributeSchema(BaseSchema):
@ -33,7 +40,6 @@ class UpdateAttributeSchema(BaseSchema):
is_nullable: Optional[bool] = None is_nullable: Optional[bool] = None
default_value: Optional[Any] = None default_value: Optional[Any] = None
description: Optional[str] = None description: Optional[str] = None
type: Optional[AttributeTypeSchema] = None
class ModuleAttributeSchema(AttributeSchema): class ModuleAttributeSchema(AttributeSchema):
@ -46,6 +52,7 @@ class DealModuleAttributeSchema(BaseSchema):
original_label: str original_label: str
value: Optional[Any] value: Optional[Any]
type: AttributeTypeSchema type: AttributeTypeSchema
select: Optional[AttributeSelectSchema]
default_value: Any default_value: Any
description: str description: str
is_applicable_to_group: bool is_applicable_to_group: bool

View File

@ -1,7 +1,8 @@
from .attr_select import AttrSelectService as AttrSelectService
from .attribute import AttributeService as AttributeService
from .board import BoardService as BoardService from .board import BoardService as BoardService
from .deal import DealService as DealService from .deal import DealService as DealService
from .project import ProjectService as ProjectService
from .status import StatusService as StatusService
from .deal_group import DealGroupService as DealGroupService from .deal_group import DealGroupService as DealGroupService
from .deal_tag import DealTagService as DealTagService from .deal_tag import DealTagService as DealTagService
from .attribute import AttributeService as AttributeService from .project import ProjectService as ProjectService
from .status import StatusService as StatusService

24
services/attr_select.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeSelect
from repositories import AttrSelectRepository
from schemas.attr_select import (
AttrSelectSchema,
GetAllAttrSelectOptionsResponse,
AttrOptionSchema,
)
from services.mixins import ServiceGetAllMixin
class AttrSelectService(ServiceGetAllMixin[AttributeSelect, AttrSelectSchema]):
schema_class = AttrSelectSchema
def __init__(self, session: AsyncSession):
self.repository = AttrSelectRepository(session)
async def get_options(self, select_id: int) -> GetAllAttrSelectOptionsResponse:
options = await self.repository.get_options(select_id)
return GetAllAttrSelectOptionsResponse(
items=[AttrOptionSchema.model_validate(option) for option in options]
)

View File

@ -39,12 +39,19 @@ class AttributeService(
attributes = [] attributes = []
for attr, attr_value, attr_label in deal_attributes: for attr, attr_value, attr_label in deal_attributes:
select_schema = (
AttributeSelectSchema.model_validate(attr.select)
if attr.select
else None
)
attribute = DealModuleAttributeSchema( attribute = DealModuleAttributeSchema(
attribute_id=attr.id, attribute_id=attr.id,
label=attr_label.label if attr_label else attr.label, label=attr_label.label if attr_label else attr.label,
original_label=attr.label, original_label=attr.label,
value=attr_value.value if attr_value else None, value=attr_value.value if attr_value else None,
type=AttributeTypeSchema.model_validate(attr.type), type=AttributeTypeSchema.model_validate(attr.type),
select=select_schema,
default_value=attr.default_value, default_value=attr.default_value,
description=attr.description, description=attr.description,
is_applicable_to_group=attr.is_applicable_to_group, is_applicable_to_group=attr.is_applicable_to_group,

View File

@ -31,8 +31,9 @@ class ModuleService(
raise ObjectNotFoundException(f"Модуль с ID {pk} не найден") raise ObjectNotFoundException(f"Модуль с ID {pk} не найден")
return GetByIdWithAttributesResponse(entity=module) return GetByIdWithAttributesResponse(entity=module)
@staticmethod
def _build_modules_with_attributes( def _build_modules_with_attributes(
self, result: list[tuple] result: list[tuple],
) -> dict[int, ModuleWithAttributesSchema]: ) -> dict[int, ModuleWithAttributesSchema]:
module_attrs_dict: dict[int, ModuleWithAttributesSchema] = {} module_attrs_dict: dict[int, ModuleWithAttributesSchema] = {}