feat: modules and module-editor pages
This commit is contained in:
9
main.py
9
main.py
@ -7,7 +7,7 @@ from starlette.responses import JSONResponse
|
|||||||
|
|
||||||
import routers
|
import routers
|
||||||
from utils.auto_include_routers import auto_include_routers
|
from utils.auto_include_routers import auto_include_routers
|
||||||
from utils.exceptions import ObjectNotFoundException
|
from utils.exceptions import *
|
||||||
|
|
||||||
origins = ["http://localhost:3000"]
|
origins = ["http://localhost:3000"]
|
||||||
|
|
||||||
@ -31,10 +31,15 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ObjectNotFoundException)
|
@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})
|
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)
|
auto_include_routers(app, routers, True)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
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 .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 .base import BaseModel as BaseModel
|
||||||
from .board import Board as Board
|
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 import Deal as Deal
|
||||||
from .deal_group import DealGroup as DealGroup
|
from .deal_group import DealGroup as DealGroup
|
||||||
from .deal_tag import (
|
from .deal_tag import (
|
||||||
@ -15,6 +17,11 @@ from .deal_tag import (
|
|||||||
DealTagColor as DealTagColor,
|
DealTagColor as DealTagColor,
|
||||||
deals_deal_tags as deals_deal_tags,
|
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 .project import Project as Project
|
||||||
from .status import Status as Status, DealStatusHistory as DealStatusHistory
|
from .status import Status as Status, DealStatusHistory as DealStatusHistory
|
||||||
|
|
||||||
|
|||||||
139
models/attribute.py
Normal file
139
models/attribute.py
Normal file
@ -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",
|
||||||
|
)
|
||||||
@ -7,7 +7,14 @@ from models.base import BaseModel
|
|||||||
from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
|
from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
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)",
|
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
|
# module client
|
||||||
client_id: Mapped[Optional[int]] = mapped_column(
|
client_id: Mapped[Optional[int]] = mapped_column(
|
||||||
ForeignKey("clients.id", ondelete="CASCADE"),
|
ForeignKey("clients.id", ondelete="CASCADE"),
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import enum
|
import enum
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import Table, Column, ForeignKey
|
from sqlalchemy import Table, Column, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from models import AttributeValue, Attribute
|
||||||
from models.base import BaseModel
|
from models.base import BaseModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -31,8 +32,9 @@ class Module(BaseModel):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(unique=True)
|
key: Mapped[str] = mapped_column(unique=True)
|
||||||
label: Mapped[str] = mapped_column()
|
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_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(
|
depends_on: Mapped[list["Module"]] = relationship(
|
||||||
secondary=module_dependencies,
|
secondary=module_dependencies,
|
||||||
@ -61,6 +63,14 @@ class Module(BaseModel):
|
|||||||
lazy="immediate", backref="module", cascade="all, delete-orphan"
|
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):
|
class DeviceType(enum.StrEnum):
|
||||||
MOBILE = "mobile"
|
MOBILE = "mobile"
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
from .attribute import AttributeRepository as AttributeRepository
|
||||||
from .board import BoardRepository as BoardRepository
|
from .board import BoardRepository as BoardRepository
|
||||||
from .module import ModuleRepository as ModuleRepository
|
|
||||||
from .deal import DealRepository as DealRepository
|
from .deal import DealRepository as DealRepository
|
||||||
from .deal_group import DealGroupRepository as DealGroupRepository
|
from .deal_group import DealGroupRepository as DealGroupRepository
|
||||||
from .deal_tag import DealTagRepository as DealTagRepository
|
from .deal_tag import DealTagRepository as DealTagRepository
|
||||||
|
from .module import ModuleRepository as ModuleRepository
|
||||||
from .project import ProjectRepository as ProjectRepository
|
from .project import ProjectRepository as ProjectRepository
|
||||||
from .status import StatusRepository as StatusRepository
|
from .status import StatusRepository as StatusRepository
|
||||||
|
|||||||
73
repositories/attribute.py
Normal file
73
repositories/attribute.py
Normal file
@ -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())
|
||||||
@ -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 repositories.mixins import *
|
||||||
|
from schemas.module import UpdateModuleCommonInfoSchema
|
||||||
|
|
||||||
|
|
||||||
class ModuleRepository(
|
class ModuleRepository(
|
||||||
BaseRepository,
|
BaseRepository,
|
||||||
RepGetAllMixin[Module],
|
RepGetAllMixin[Module],
|
||||||
|
RepGetByIdMixin[Module],
|
||||||
|
RepUpdateMixin[Module, UpdateModuleCommonInfoSchema],
|
||||||
|
RepDeleteMixin[Module]
|
||||||
):
|
):
|
||||||
entity_class = Module
|
entity_class = Module
|
||||||
|
|
||||||
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
|
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
|
||||||
project_id = args[0]
|
return stmt.options(selectinload(Module.attributes).joinedload(Attribute.type))
|
||||||
return stmt.where(Board.project_id == project_id).order_by(Board.lexorank)
|
|
||||||
|
|
||||||
async def get_by_ids(self, ids: list[int]) -> list[Module]:
|
async def get_by_ids(self, ids: list[int]) -> list[Module]:
|
||||||
stmt = select(Module).where(Module.id.in_(ids))
|
stmt = select(Module).where(Module.id.in_(ids))
|
||||||
modules = await self.session.scalars(stmt)
|
modules = await self.session.scalars(stmt)
|
||||||
return modules.all()
|
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()
|
||||||
|
|||||||
78
routers/crm/v1/attribute.py
Normal file
78
routers/crm/v1/attribute.py
Normal file
@ -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()
|
||||||
@ -1,14 +1,14 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Path
|
||||||
|
|
||||||
from backend.dependecies import SessionDependency
|
from backend.dependecies import SessionDependency
|
||||||
from schemas.module import GetAllModulesResponse
|
from schemas.module import *
|
||||||
from services.module import ModuleService
|
from services.module import ModuleService
|
||||||
|
|
||||||
router = APIRouter(tags=["modules"])
|
router = APIRouter(tags=["modules"])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/built-in/",
|
"/",
|
||||||
response_model=GetAllModulesResponse,
|
response_model=GetAllModulesResponse,
|
||||||
operation_id="get_modules",
|
operation_id="get_modules",
|
||||||
)
|
)
|
||||||
@ -16,3 +16,76 @@ async def get_modules(
|
|||||||
session: SessionDependency,
|
session: SessionDependency,
|
||||||
):
|
):
|
||||||
return await ModuleService(session).get_all()
|
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)
|
||||||
|
|||||||
95
schemas/attribute.py
Normal file
95
schemas/attribute.py
Normal file
@ -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
|
||||||
@ -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
|
# region Entity
|
||||||
|
|
||||||
@ -15,11 +19,41 @@ class ModuleSchema(BaseSchema):
|
|||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
description: str
|
description: Optional[str]
|
||||||
|
is_built_in: bool
|
||||||
depends_on: list["ModuleSchema"]
|
depends_on: list["ModuleSchema"]
|
||||||
tabs: list[ModuleTabSchema]
|
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
|
# endregion
|
||||||
|
|
||||||
# region Response
|
# region Response
|
||||||
@ -29,4 +63,28 @@ class GetAllModulesResponse(BaseSchema):
|
|||||||
items: list[ModuleSchema]
|
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
|
# endregion
|
||||||
|
|||||||
@ -4,3 +4,4 @@ from .project import ProjectService as ProjectService
|
|||||||
from .status import StatusService as StatusService
|
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
|
||||||
|
|||||||
31
services/attribute.py
Normal file
31
services/attribute.py
Normal file
@ -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]
|
||||||
|
)
|
||||||
@ -1,11 +1,96 @@
|
|||||||
from models import Module
|
from models import Module, Attribute
|
||||||
from repositories import ModuleRepository
|
from repositories import ModuleRepository, AttributeRepository
|
||||||
from schemas.module import ModuleSchema
|
from schemas.module import *
|
||||||
from services.mixins 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
|
schema_class = ModuleSchema
|
||||||
|
entity_updated_msg = "Модуль успешно обновлен"
|
||||||
|
entity_deleted_msg = "Модуль успешно удален"
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.repository = ModuleRepository(session)
|
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
|
||||||
|
|||||||
@ -1,3 +1,11 @@
|
|||||||
class ObjectNotFoundException(Exception):
|
class BaseResponseException(Exception):
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectNotFoundException(BaseResponseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenException(BaseResponseException):
|
||||||
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user