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 modules.fulfillment_base.models import * # noqa: F401
from .attr_select import (
AttributeOption as AttributeOption,
AttributeSelect as AttributeSelect,
)
from .attribute import (
AttributeType as AttributeType,
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
if TYPE_CHECKING:
from models import Module, Deal
from models import Module, Deal, AttributeSelect
module_attribute = Table(
"module_attribute",
@ -51,6 +50,12 @@ class Attribute(BaseModel, IdMixin, SoftDeleteMixin):
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(
secondary=module_attribute,
back_populates="attributes",

View File

@ -1,3 +1,4 @@
from .attr_select import AttrSelectRepository as AttrSelectRepository
from .attribute import AttributeRepository as AttributeRepository
from .board import BoardRepository as BoardRepository
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 (
stmt.options(joinedload(Attribute.type))
.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:
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:
if data.type:
data.type = await self._get_attribute_type_by_id(data.type.id)
return await self._apply_update_data_to_model(attr, data, True)
return await self._apply_update_data_to_model(
attr, data, with_commit=True, set_if_value_is_not_none=False
)
async def _before_delete(self, attribute: Attribute) -> None:
if attribute.is_built_in:
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 = (
select(Attribute, Module.id)
.join(Attribute.modules)
@ -148,9 +143,8 @@ class AttributeRepository(
AttributeLabel.module_id == module_id,
),
)
.where(
Attribute.is_deleted.is_(False),
)
.where(Attribute.is_deleted.is_(False))
.options(joinedload(Attribute.select))
)
result = await self.session.execute(stmt)
return list(result.all())

View File

@ -65,13 +65,14 @@ class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityT
data: UpdateSchemaType,
with_commit: Optional[bool] = False,
fields: Optional[list[str]] = None,
set_if_value_is_not_none: Optional[bool] = True,
) -> EntityType:
if fields is None:
fields = data.model_dump().keys()
for field in fields:
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)
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
class AttributeSelectSchema(BaseSchema):
id: int
label: str
class CreateAttributeSchema(BaseSchema):
label: str
is_applicable_to_group: bool
@ -19,12 +24,14 @@ class CreateAttributeSchema(BaseSchema):
default_value: Optional[Any]
description: str
type_id: int
select_id: Optional[int]
class AttributeSchema(CreateAttributeSchema):
id: int
is_built_in: bool
type: AttributeTypeSchema
select: Optional[AttributeSelectSchema]
class UpdateAttributeSchema(BaseSchema):
@ -33,7 +40,6 @@ class UpdateAttributeSchema(BaseSchema):
is_nullable: Optional[bool] = None
default_value: Optional[Any] = None
description: Optional[str] = None
type: Optional[AttributeTypeSchema] = None
class ModuleAttributeSchema(AttributeSchema):
@ -46,6 +52,7 @@ class DealModuleAttributeSchema(BaseSchema):
original_label: str
value: Optional[Any]
type: AttributeTypeSchema
select: Optional[AttributeSelectSchema]
default_value: Any
description: str
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 .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_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 = []
for attr, attr_value, attr_label in deal_attributes:
select_schema = (
AttributeSelectSchema.model_validate(attr.select)
if attr.select
else None
)
attribute = DealModuleAttributeSchema(
attribute_id=attr.id,
label=attr_label.label if attr_label else attr.label,
original_label=attr.label,
value=attr_value.value if attr_value else None,
type=AttributeTypeSchema.model_validate(attr.type),
select=select_schema,
default_value=attr.default_value,
description=attr.description,
is_applicable_to_group=attr.is_applicable_to_group,

View File

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