diff --git a/models/__init__.py b/models/__init__.py index fdf3fa4..849bc4f 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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, diff --git a/models/attr_select.py b/models/attr_select.py new file mode 100644 index 0000000..8c1e39f --- /dev/null +++ b/models/attr_select.py @@ -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"),) diff --git a/models/attribute.py b/models/attribute.py index d518698..d26fd4b 100644 --- a/models/attribute.py +++ b/models/attribute.py @@ -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", diff --git a/repositories/__init__.py b/repositories/__init__.py index 8a46d9e..6091de1 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -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 diff --git a/repositories/attr_select.py b/repositories/attr_select.py new file mode 100644 index 0000000..5707359 --- /dev/null +++ b/repositories/attr_select.py @@ -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()) diff --git a/repositories/attribute.py b/repositories/attribute.py index c8f9b18..f0b0e16 100644 --- a/repositories/attribute.py +++ b/repositories/attribute.py @@ -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()) diff --git a/repositories/mixins.py b/repositories/mixins.py index d748fa2..cf5a24a 100644 --- a/repositories/mixins.py +++ b/repositories/mixins.py @@ -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: diff --git a/routers/crm/v1/attr_select.py b/routers/crm/v1/attr_select.py new file mode 100644 index 0000000..3040023 --- /dev/null +++ b/routers/crm/v1/attr_select.py @@ -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) diff --git a/schemas/attr_select.py b/schemas/attr_select.py new file mode 100644 index 0000000..6a2d90c --- /dev/null +++ b/schemas/attr_select.py @@ -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 diff --git a/schemas/attribute.py b/schemas/attribute.py index c28cb34..876e0fe 100644 --- a/schemas/attribute.py +++ b/schemas/attribute.py @@ -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 diff --git a/services/__init__.py b/services/__init__.py index bc2b513..0412db8 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -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 diff --git a/services/attr_select.py b/services/attr_select.py new file mode 100644 index 0000000..88913e2 --- /dev/null +++ b/services/attr_select.py @@ -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] + ) diff --git a/services/attribute.py b/services/attribute.py index 76b82fd..97becdd 100644 --- a/services/attribute.py +++ b/services/attribute.py @@ -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, diff --git a/services/module.py b/services/module.py index 3a93aee..14c4e98 100644 --- a/services/module.py +++ b/services/module.py @@ -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] = {}