From 281600c72d524dd81c38783029286f1e28d96ad0 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Sat, 25 Oct 2025 12:11:48 +0400 Subject: [PATCH] feat: modules and module-editor pages --- main.py | 9 ++- models/__init__.py | 17 +++-- models/attribute.py | 139 ++++++++++++++++++++++++++++++++++++ models/deal.py | 13 +++- models/module.py | 14 +++- repositories/__init__.py | 3 +- repositories/attribute.py | 73 +++++++++++++++++++ repositories/module.py | 59 +++++++++++++-- routers/crm/v1/attribute.py | 78 ++++++++++++++++++++ routers/crm/v1/module.py | 79 +++++++++++++++++++- schemas/attribute.py | 95 ++++++++++++++++++++++++ schemas/module.py | 62 +++++++++++++++- services/__init__.py | 1 + services/attribute.py | 31 ++++++++ services/module.py | 93 ++++++++++++++++++++++-- utils/exceptions.py | 10 ++- 16 files changed, 751 insertions(+), 25 deletions(-) create mode 100644 models/attribute.py create mode 100644 repositories/attribute.py create mode 100644 routers/crm/v1/attribute.py create mode 100644 schemas/attribute.py create mode 100644 services/attribute.py diff --git a/main.py b/main.py index eae841c..c8ec926 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from starlette.responses import JSONResponse import routers from utils.auto_include_routers import auto_include_routers -from utils.exceptions import ObjectNotFoundException +from utils.exceptions import * origins = ["http://localhost:3000"] @@ -31,10 +31,15 @@ app.add_middleware( @app.exception_handler(ObjectNotFoundException) -async def unicorn_exception_handler(request: Request, exc: ObjectNotFoundException): +async def not_found_exception_handler(request: Request, exc: ObjectNotFoundException): return JSONResponse(status_code=404, content={"detail": exc.name}) +@app.exception_handler(ForbiddenException) +async def forbidden_exception_handler(request: Request, exc: ForbiddenException): + return JSONResponse(status_code=403, content={"detail": exc.name}) + + auto_include_routers(app, routers, True) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/models/__init__.py b/models/__init__.py index 4e7026b..09baca7 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,13 +1,15 @@ from sqlalchemy.orm import configure_mappers from modules.fulfillment_base.models import * # noqa: F401 +from .attribute import ( + AttributeType as AttributeType, + Attribute as Attribute, + AttributeValue as AttributeValue, + AttributeLabel as AttributeLabel, + module_attribute as module_attribute, +) from .base import BaseModel as BaseModel from .board import Board as Board -from .module import ( # noqa: F401 - Module as Module, - project_module as project_module, - module_dependencies as module_dependencies, -) from .deal import Deal as Deal from .deal_group import DealGroup as DealGroup from .deal_tag import ( @@ -15,6 +17,11 @@ from .deal_tag import ( DealTagColor as DealTagColor, deals_deal_tags as deals_deal_tags, ) +from .module import ( # noqa: F401 + Module as Module, + project_module as project_module, + module_dependencies as module_dependencies, +) from .project import Project as Project from .status import Status as Status, DealStatusHistory as DealStatusHistory diff --git a/models/attribute.py b/models/attribute.py new file mode 100644 index 0000000..77dddbe --- /dev/null +++ b/models/attribute.py @@ -0,0 +1,139 @@ +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ForeignKey, Table, Column +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 Module, Deal + + +module_attribute = Table( + "module_attribute", + BaseModel.metadata, + Column("module_id", ForeignKey("modules.id"), primary_key=True), + Column("attribute_id", ForeignKey("attributes.id"), primary_key=True), +) + + +class AttributeType(BaseModel, IdMixin, SoftDeleteMixin): + __tablename__ = "attribute_types" + + type: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] = mapped_column(unique=True) + + attributes: Mapped["Attribute"] = relationship( + back_populates="type", + lazy="noload", + ) + + +class Attribute(BaseModel, IdMixin, SoftDeleteMixin): + __tablename__ = "attributes" + + label: Mapped[str] = mapped_column() + is_applicable_to_group: Mapped[bool] = mapped_column( + default=False, + comment="Применять ли изменения атрибута карточки ко всем карточкам в группе", + ) + is_shown_on_dashboard: Mapped[bool] = mapped_column( + default=False, + comment="Отображается ли атрибут на дашборде", + ) + is_highlight_if_expired: Mapped[bool] = mapped_column( + default=False, + comment="Подсветка атрибута, если Дата/ДатаВремя просрочена", + ) + is_nullable: Mapped[bool] = mapped_column(default=False) + default_value: Mapped[Optional[dict[str, any]]] = mapped_column(JSONB) + description: Mapped[str] = mapped_column(default="") + + is_built_in: Mapped[bool] = mapped_column(default=False) + + type_id: Mapped[int] = mapped_column(ForeignKey("attribute_types.id")) + type: Mapped[AttributeType] = relationship( + back_populates="attributes", + lazy="joined", + ) + + modules: Mapped[list["Module"]] = relationship( + secondary=module_attribute, + back_populates="attributes", + lazy="noload", + ) + + values: Mapped[list["AttributeValue"]] = relationship( + uselist=True, + back_populates="attribute", + lazy="noload", + ) + + +class AttributeValue(BaseModel, IdMixin): + __tablename__ = "attribute_values" + + value: Mapped[Optional[dict[str, any]]] = mapped_column(JSONB) + + deal_id: Mapped[int] = mapped_column( + ForeignKey("deals.id"), + primary_key=True, + ) + deal: Mapped["Deal"] = relationship( + back_populates="attributes_values", + lazy="noload", + ) + + module_id: Mapped[int] = mapped_column( + ForeignKey("modules.id"), + primary_key=True, + ) + module: Mapped["Module"] = relationship( + back_populates="attribute_values", + lazy="noload", + ) + + attribute_id: Mapped[int] = mapped_column( + ForeignKey("attributes.id"), + primary_key=True, + ) + attribute: Mapped[Attribute] = relationship( + back_populates="values", + lazy="joined", + ) + + def set_value(self, value: Optional[dict | str | bool | int | float]): + if value is None: + return + self.value = {"value": value} + + def get_value(self) -> Optional[dict | str | bool | int | float]: + if self.value is None: + return None + return self.value["value"] + + +class AttributeLabel(BaseModel): + __tablename__ = "attribute_labels" + + label: Mapped[str] = mapped_column() + + module_id: Mapped[int] = mapped_column( + ForeignKey("modules.id"), + primary_key=True, + ) + module: Mapped["Module"] = relationship( + backref="attribute_labels", + lazy="noload", + ) + + attribute_id: Mapped[int] = mapped_column( + ForeignKey("attributes.id"), + primary_key=True, + ) + attribute: Mapped[Attribute] = relationship( + backref="attribute_labels", + lazy="joined", + ) diff --git a/models/deal.py b/models/deal.py index 6857d5c..6f0cf04 100644 --- a/models/deal.py +++ b/models/deal.py @@ -7,7 +7,14 @@ from models.base import BaseModel from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin if TYPE_CHECKING: - from models import Status, Board, DealStatusHistory, DealGroup, DealTag + from models import ( + Status, + Board, + DealStatusHistory, + DealGroup, + DealTag, + AttributeValue, + ) from modules.clients.models import Client @@ -49,6 +56,10 @@ class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): secondaryjoin="and_(DealTag.id == deals_deal_tags.c.deal_tag_id, DealTag.is_deleted == False)", ) + attributes_values: Mapped[list["AttributeValue"]] = relationship( + back_populates="deal", + ) + # module client client_id: Mapped[Optional[int]] = mapped_column( ForeignKey("clients.id", ondelete="CASCADE"), diff --git a/models/module.py b/models/module.py index 3cd9f34..8c3b264 100644 --- a/models/module.py +++ b/models/module.py @@ -1,9 +1,10 @@ import enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlalchemy import Table, Column, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship +from models import AttributeValue, Attribute from models.base import BaseModel if TYPE_CHECKING: @@ -31,8 +32,9 @@ class Module(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) key: Mapped[str] = mapped_column(unique=True) label: Mapped[str] = mapped_column() - description: Mapped[str] = mapped_column() + description: Mapped[Optional[str]] = mapped_column() is_deleted: Mapped[bool] = mapped_column(default=False) + is_built_in: Mapped[bool] = mapped_column(default=False, server_default="0") depends_on: Mapped[list["Module"]] = relationship( secondary=module_dependencies, @@ -61,6 +63,14 @@ class Module(BaseModel): lazy="immediate", backref="module", cascade="all, delete-orphan" ) + attributes: Mapped[list["Attribute"]] = relationship( + secondary="module_attribute", back_populates="modules" + ) + + attribute_values: Mapped[list["AttributeValue"]] = relationship( + lazy="noload", back_populates="module" + ) + class DeviceType(enum.StrEnum): MOBILE = "mobile" diff --git a/repositories/__init__.py b/repositories/__init__.py index fe0a200..8a46d9e 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -1,7 +1,8 @@ +from .attribute import AttributeRepository as AttributeRepository from .board import BoardRepository as BoardRepository -from .module import ModuleRepository as ModuleRepository from .deal import DealRepository as DealRepository from .deal_group import DealGroupRepository as DealGroupRepository from .deal_tag import DealTagRepository as DealTagRepository +from .module import ModuleRepository as ModuleRepository from .project import ProjectRepository as ProjectRepository from .status import StatusRepository as StatusRepository diff --git a/repositories/attribute.py b/repositories/attribute.py new file mode 100644 index 0000000..a3386bc --- /dev/null +++ b/repositories/attribute.py @@ -0,0 +1,73 @@ +from sqlalchemy.orm import joinedload + +from models import Attribute, AttributeLabel, AttributeType +from repositories.mixins import * +from schemas.attribute import CreateAttributeSchema, UpdateAttributeSchema +from utils.exceptions import ForbiddenException + + +class AttributeRepository( + RepCrudMixin[Attribute, CreateAttributeSchema, UpdateAttributeSchema] +): + session: AsyncSession + entity_class = Attribute + + def _process_get_all_stmt(self, stmt: Select) -> Select: + return ( + stmt.options(joinedload(Attribute.type)) + .where(Attribute.is_deleted.is_(False)) + .order_by(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) + + async def _before_delete(self, attribute: Attribute) -> None: + if attribute.is_built_in: + raise ForbiddenException("Нельзя менять встроенный атрибут") + + async def _get_attribute_module_label( + self, module_id: int, attribute_id: int + ) -> Optional[AttributeLabel]: + stmt = select(AttributeLabel).where( + AttributeLabel.attribute_id == attribute_id, + AttributeLabel.module_id == module_id, + ) + result = await self.session.execute(stmt) + row = result.one_or_none() + return row[0] if row else None + + async def create_or_update_attribute_label( + self, module_id: int, attribute_id: int, label: str + ): + attribute_label = await self._get_attribute_module_label( + module_id, attribute_id + ) + if attribute_label: + attribute_label.label = label + else: + attribute_label = AttributeLabel( + module_id=module_id, + attribute_id=attribute_id, + label=label, + ) + + self.session.add(attribute_label) + await self.session.commit() + + async def get_attribute_types(self) -> list[AttributeType]: + stmt = select(AttributeType).where(AttributeType.is_deleted.is_(False)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/repositories/module.py b/repositories/module.py index c002c3e..8a51fdd 100644 --- a/repositories/module.py +++ b/repositories/module.py @@ -1,18 +1,69 @@ -from models import Board, Module +from sqlalchemy import and_ +from sqlalchemy.orm import selectinload + +from models import Module, Attribute, AttributeLabel, module_attribute from repositories.mixins import * +from schemas.module import UpdateModuleCommonInfoSchema class ModuleRepository( BaseRepository, RepGetAllMixin[Module], + RepGetByIdMixin[Module], + RepUpdateMixin[Module, UpdateModuleCommonInfoSchema], + RepDeleteMixin[Module] ): entity_class = Module - def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: - project_id = args[0] - return stmt.where(Board.project_id == project_id).order_by(Board.lexorank) + def _process_get_by_id_stmt(self, stmt: Select) -> Select: + return stmt.options(selectinload(Module.attributes).joinedload(Attribute.type)) async def get_by_ids(self, ids: list[int]) -> list[Module]: stmt = select(Module).where(Module.id.in_(ids)) modules = await self.session.scalars(stmt) return modules.all() + + @staticmethod + def _get_stmt_modules_with_tuples() -> Select: + return ( + select(Module, Attribute, AttributeLabel) + .join( + module_attribute, + Module.id == module_attribute.c.module_id, + isouter=True, + ) + .join( + Attribute, module_attribute.c.attribute_id == Attribute.id, isouter=True + ) + .join( + AttributeLabel, + and_( + Module.id == AttributeLabel.module_id, + Attribute.id == AttributeLabel.attribute_id, + ), + isouter=True, + ) + .where(Module.is_deleted.is_(False), Attribute.is_deleted.is_(False)) + .order_by(Attribute.id) + ) + + async def get_with_attributes_as_tuples( + self, + ) -> list[tuple[Module, Attribute, AttributeLabel]]: + stmt = self._get_stmt_modules_with_tuples() + return (await self.session.execute(stmt)).unique().all() + + async def get_with_attributes_as_tuple_by_id( + self, pk: int + ) -> list[tuple[Module, Attribute, AttributeLabel]]: + stmt = self._get_stmt_modules_with_tuples() + stmt = stmt.where(Module.id == pk) + return (await self.session.execute(stmt)).unique().all() + + async def add_attribute_to_module(self, module: Module, attribute: Attribute): + module.attributes.append(attribute) + await self.session.commit() + + async def delete_attribute_from_module(self, module: Module, attribute: Attribute): + module.attributes.remove(attribute) + await self.session.commit() diff --git a/routers/crm/v1/attribute.py b/routers/crm/v1/attribute.py new file mode 100644 index 0000000..01b8f5c --- /dev/null +++ b/routers/crm/v1/attribute.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Path + +from backend.dependecies import SessionDependency +from schemas.attribute import * +from services import AttributeService + +router = APIRouter(tags=["attribute"]) + + +@router.get( + "/", + response_model=GetAllAttributesResponse, + operation_id="get_attributes", +) +async def get_attributes( + session: SessionDependency, +): + return await AttributeService(session).get_all() + + +@router.post( + "/", + response_model=CreateAttributeResponse, + operation_id="create_attribute", +) +async def create_attribute( + session: SessionDependency, + request: CreateAttributeRequest, +): + return await AttributeService(session).create(request) + + +@router.patch( + "/{pk}", + response_model=UpdateAttributeResponse, + operation_id="update_attribute", +) +async def update_attribute( + session: SessionDependency, + request: UpdateAttributeRequest, + pk: int = Path(), +): + return await AttributeService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteAttributeResponse, + operation_id="delete_attribute", +) +async def delete_attribute( + session: SessionDependency, + pk: int = Path(), +): + return await AttributeService(session).delete(pk) + + +@router.post( + "/label", + response_model=UpdateAttributeLabelResponse, + operation_id="update_attribute_label", +) +async def update_attribute_label( + session: SessionDependency, + request: UpdateAttributeLabelRequest, +): + return await AttributeService(session).update_attribute_label(request) + + +@router.get( + "/type", + response_model=GetAllAttributeTypesResponse, + operation_id="get_attribute_types", +) +async def get_attribute_types( + session: SessionDependency, +): + return await AttributeService(session).get_attribute_types() diff --git a/routers/crm/v1/module.py b/routers/crm/v1/module.py index d5cf5d6..a4f3e35 100644 --- a/routers/crm/v1/module.py +++ b/routers/crm/v1/module.py @@ -1,14 +1,14 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Path from backend.dependecies import SessionDependency -from schemas.module import GetAllModulesResponse +from schemas.module import * from services.module import ModuleService router = APIRouter(tags=["modules"]) @router.get( - "/built-in/", + "/", response_model=GetAllModulesResponse, operation_id="get_modules", ) @@ -16,3 +16,76 @@ async def get_modules( session: SessionDependency, ): return await ModuleService(session).get_all() + + +@router.get( + "/with-attributes", + response_model=GetAllWithAttributesResponse, + operation_id="get_modules_with_attributes", +) +async def get_modules_with_attributes( + session: SessionDependency, +): + return await ModuleService(session).get_with_attributes() + + +@router.get( + "/{pk}/with-attributes", + response_model=GetByIdWithAttributesResponse, + operation_id="get_module_with_attributes", +) +async def get_module_with_attributes( + session: SessionDependency, + pk: int = Path(), +): + return await ModuleService(session).get_by_id_with_attributes(pk) + + +@router.patch( + "/{pk}/common-info", + response_model=UpdateModuleCommonInfoResponse, + operation_id="update_module", +) +async def update_module_common_info( + session: SessionDependency, + request: UpdateModuleCommonInfoRequest, + pk: int = Path(), +): + return await ModuleService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteModuleResponse, + operation_id="delete_module", +) +async def delete_module( + session: SessionDependency, + pk: int = Path(), +): + return await ModuleService(session).delete(pk) + + + +@router.post( + "/attribute", + response_model=AddAttributeResponse, + operation_id="add_attribute_to_module", +) +async def add_attribute_to_module( + session: SessionDependency, + request: AddAttributeRequest, +): + return await ModuleService(session).add_attribute(request) + + +@router.delete( + "/attribute", + response_model=DeleteAttributeResponse, + operation_id="remove_attribute_from_module", +) +async def remove_attribute_from_module( + session: SessionDependency, + request: DeleteAttributeRequest, +): + return await ModuleService(session).delete_attribute(request) diff --git a/schemas/attribute.py b/schemas/attribute.py new file mode 100644 index 0000000..434c340 --- /dev/null +++ b/schemas/attribute.py @@ -0,0 +1,95 @@ +from typing import Optional, Any + +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class AttributeTypeSchema(BaseSchema): + id: int + type: str + name: str + + +class CreateAttributeSchema(BaseSchema): + label: str + is_applicable_to_group: bool + is_shown_on_dashboard: bool + is_highlight_if_expired: bool + is_nullable: bool + default_value: Optional[dict[str, Any]] + description: str + type_id: int + + +class AttributeSchema(CreateAttributeSchema): + id: int + is_built_in: bool + type: AttributeTypeSchema + + +class UpdateAttributeSchema(BaseSchema): + label: Optional[str] = None + is_applicable_to_group: Optional[bool] = None + is_shown_on_dashboard: Optional[bool] = None + is_highlight_if_expired: Optional[bool] = None + is_nullable: Optional[bool] = None + default_value: Optional[dict[str, Any]] = None + description: Optional[str] = None + type: Optional[AttributeTypeSchema] = None + + +class ModuleAttributeSchema(AttributeSchema): + original_label: str + + +# endregion + +# region Request + + +class CreateAttributeRequest(BaseSchema): + entity: CreateAttributeSchema + + +class UpdateAttributeRequest(BaseSchema): + entity: UpdateAttributeSchema + + +class UpdateAttributeLabelRequest(BaseSchema): + module_id: int + attribute_id: int + label: str + + +# endregion + +# region Response + + +class GetAllAttributesResponse(BaseSchema): + items: list[AttributeSchema] + + +class CreateAttributeResponse(BaseResponse): + pass + + +class UpdateAttributeResponse(BaseResponse): + pass + + +class DeleteAttributeResponse(BaseResponse): + pass + + +class UpdateAttributeLabelResponse(BaseResponse): + pass + + +class GetAllAttributeTypesResponse(BaseSchema): + items: list[AttributeTypeSchema] + + +# endregion diff --git a/schemas/module.py b/schemas/module.py index ecab3c7..59807e0 100644 --- a/schemas/module.py +++ b/schemas/module.py @@ -1,4 +1,8 @@ -from schemas.base import BaseSchema +from typing import Optional + +from schemas.attribute import ModuleAttributeSchema +from schemas.base import BaseSchema, BaseResponse + # region Entity @@ -15,11 +19,41 @@ class ModuleSchema(BaseSchema): id: int key: str label: str - description: str + description: Optional[str] + is_built_in: bool depends_on: list["ModuleSchema"] tabs: list[ModuleTabSchema] +class ModuleWithAttributesSchema(ModuleSchema): + attributes: list[ModuleAttributeSchema] + + +class UpdateModuleCommonInfoSchema(BaseSchema): + label: str + description: Optional[str] + + +# endregion + + +# region Requests + + +class AddAttributeRequest(BaseSchema): + attribute_id: int + module_id: int + + +class DeleteAttributeRequest(BaseSchema): + attribute_id: int + module_id: int + + +class UpdateModuleCommonInfoRequest(BaseSchema): + entity: UpdateModuleCommonInfoSchema + + # endregion # region Response @@ -29,4 +63,28 @@ class GetAllModulesResponse(BaseSchema): items: list[ModuleSchema] +class GetAllWithAttributesResponse(BaseSchema): + items: list[ModuleWithAttributesSchema] + + +class GetByIdWithAttributesResponse(BaseSchema): + entity: ModuleWithAttributesSchema + + +class UpdateModuleCommonInfoResponse(BaseResponse): + pass + + +class DeleteModuleResponse(BaseResponse): + pass + + +class AddAttributeResponse(BaseResponse): + pass + + +class DeleteAttributeResponse(BaseResponse): + pass + + # endregion diff --git a/services/__init__.py b/services/__init__.py index 7ad6107..bc2b513 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -4,3 +4,4 @@ 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 diff --git a/services/attribute.py b/services/attribute.py new file mode 100644 index 0000000..05679b0 --- /dev/null +++ b/services/attribute.py @@ -0,0 +1,31 @@ +from models import Attribute +from repositories import AttributeRepository +from schemas.attribute import * +from services.mixins import * + + +class AttributeService( + ServiceCrudMixin[ + Attribute, AttributeSchema, CreateAttributeRequest, UpdateAttributeRequest + ] +): + schema_class = AttributeSchema + + def __init__(self, session: AsyncSession): + self.repository = AttributeRepository(session) + + async def update_attribute_label( + self, request: UpdateAttributeLabelRequest + ) -> UpdateAttributeLabelResponse: + await self.repository.create_or_update_attribute_label( + request.module_id, request.attribute_id, request.label + ) + return UpdateAttributeLabelResponse( + message="Название атрибута в модуле изменено" + ) + + async def get_attribute_types(self) -> GetAllAttributeTypesResponse: + types = await self.repository.get_attribute_types() + return GetAllAttributeTypesResponse( + items=[AttributeTypeSchema.model_validate(t) for t in types] + ) diff --git a/services/module.py b/services/module.py index 5fd270e..a3f566d 100644 --- a/services/module.py +++ b/services/module.py @@ -1,11 +1,96 @@ -from models import Module -from repositories import ModuleRepository -from schemas.module import ModuleSchema +from models import Module, Attribute +from repositories import ModuleRepository, AttributeRepository +from schemas.module import * from services.mixins import * +from utils.exceptions import ForbiddenException -class ModuleService(ServiceGetAllMixin[Module, ModuleSchema]): +class ModuleService( + ServiceGetAllMixin[Module, ModuleSchema], + ServiceUpdateMixin[Module, UpdateModuleCommonInfoRequest], + ServiceDeleteMixin[Module], +): schema_class = ModuleSchema + entity_updated_msg = "Модуль успешно обновлен" + entity_deleted_msg = "Модуль успешно удален" def __init__(self, session: AsyncSession): self.repository = ModuleRepository(session) + + async def get_with_attributes(self) -> GetAllWithAttributesResponse: + result = await self.repository.get_with_attributes_as_tuples() + modules = self._build_modules_with_attributes(result) + return GetAllWithAttributesResponse(items=list(modules.values())) + + async def get_by_id_with_attributes(self, pk: int) -> GetByIdWithAttributesResponse: + result = await self.repository.get_with_attributes_as_tuple_by_id(pk) + modules = self._build_modules_with_attributes(result) + module = next(iter(modules.values()), None) + if module is None: + raise ObjectNotFoundException(f"Модуль с ID {pk} не найден") + return GetByIdWithAttributesResponse(entity=module) + + async def update_module_common_info( + self, module_id: int, request: UpdateModuleCommonInfoRequest + ) -> UpdateModuleCommonInfoResponse: + module = await self.repository.get_by_id(module_id) + await self.repository.update_(module, request) + return UpdateModuleCommonInfoResponse(message="Данные модуля успешно сохранены") + + def _build_modules_with_attributes( + self, result: list[tuple] + ) -> dict[int, ModuleWithAttributesSchema]: + module_attrs_dict: dict[int, ModuleWithAttributesSchema] = {} + + for module, attribute, attribute_label in result: + new_attr = None + + if attribute: + original_label = attribute.label + label = attribute_label.label if attribute_label else original_label + attr_values = { + **attribute.__dict__, + "label": label, + "original_label": original_label, + } + new_attr = ModuleAttributeSchema(**attr_values) + + module_schema = module_attrs_dict.get(module.id) + if not module_schema: + module_schema = ModuleWithAttributesSchema( + **module.__dict__, + attributes=[new_attr] if new_attr else [], + ) + module_attrs_dict[module.id] = module_schema + elif new_attr: + module_schema.attributes.append(new_attr) + + return module_attrs_dict + + async def is_soft_delete(self, module: Module) -> bool: + if module.is_built_in: + raise ForbiddenException("Нельзя менять встроенный модуль") + return True + + async def add_attribute(self, request: AddAttributeRequest) -> AddAttributeResponse: + module, attribute = await self._get_module_and_attr_from_request(request) + await self.repository.add_attribute_to_module(module, attribute) + return AddAttributeResponse(message="Аттрибут успешно добавлен к модулю") + + async def delete_attribute( + self, request: DeleteAttributeRequest + ) -> DeleteAttributeResponse: + module, attribute = await self._get_module_and_attr_from_request(request) + await self.repository.delete_attribute_from_module(module, attribute) + return DeleteAttributeResponse(message="Аттрибут успешно удален из модуля") + + async def _get_module_and_attr_from_request( + self, request: AddAttributeRequest | DeleteAttributeRequest + ) -> tuple[Module, Attribute]: + module = await self.repository.get_by_id(request.module_id) + if module.is_built_in: + raise ForbiddenException("Нельзя менять встроенный модуль") + + attr_repo = AttributeRepository(self.repository.session) + attribute = await attr_repo.get_by_id(request.attribute_id) + return module, attribute diff --git a/utils/exceptions.py b/utils/exceptions.py index 0607751..107ed95 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -1,3 +1,11 @@ -class ObjectNotFoundException(Exception): +class BaseResponseException(Exception): def __init__(self, name: str): self.name = name + + +class ObjectNotFoundException(BaseResponseException): + pass + + +class ForbiddenException(BaseResponseException): + pass