diff --git a/main.py b/main.py index dbec219..eae841c 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,6 @@ from fastapi.responses import ORJSONResponse from fastapi.staticfiles import StaticFiles from starlette.responses import JSONResponse -import modules import routers from utils.auto_include_routers import auto_include_routers from utils.exceptions import ObjectNotFoundException diff --git a/models/__init__.py b/models/__init__.py index 29e8be3..65319b2 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -10,6 +10,11 @@ from .built_in_module import ( # noqa: F401 ) from .deal import Deal as Deal from .deal_group import DealGroup as DealGroup +from .deal_tag import ( + DealTag as DealTag, + DealTagColor as DealTagColor, + deals_deal_tags as deals_deal_tags, +) from .project import Project as Project from .status import Status as Status, DealStatusHistory as DealStatusHistory diff --git a/models/deal.py b/models/deal.py index a766e06..6857d5c 100644 --- a/models/deal.py +++ b/models/deal.py @@ -7,7 +7,7 @@ from models.base import BaseModel from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin if TYPE_CHECKING: - from models import Status, Board, DealStatusHistory, DealGroup + from models import Status, Board, DealStatusHistory, DealGroup, DealTag from modules.clients.models import Client @@ -41,6 +41,14 @@ class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): lazy="noload", back_populates="deals" ) + tags: Mapped[list["DealTag"]] = relationship( + secondary="deals_deal_tags", + back_populates="deals", + lazy="selectin", + primaryjoin="Deal.id == deals_deal_tags.c.deal_id", + secondaryjoin="and_(DealTag.id == deals_deal_tags.c.deal_tag_id, DealTag.is_deleted == False)", + ) + # module client client_id: Mapped[Optional[int]] = mapped_column( ForeignKey("clients.id", ondelete="CASCADE"), diff --git a/models/deal_tag.py b/models/deal_tag.py new file mode 100644 index 0000000..3f3ef77 --- /dev/null +++ b/models/deal_tag.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Column, Table, Index +from sqlalchemy.orm import mapped_column, Mapped, relationship + +from models import BaseModel +from models.mixins import IdMixin, SoftDeleteMixin + +if TYPE_CHECKING: + from models import Project, Deal + +deals_deal_tags = Table( + "deals_deal_tags", + BaseModel.metadata, + Column("deal_id", ForeignKey("deals.id"), primary_key=True), + Column("deal_tag_id", ForeignKey("deal_tags.id"), primary_key=True), +) + + +class DealTagColor(BaseModel, IdMixin): + __tablename__ = "deal_tag_colors" + + label: Mapped[str] = mapped_column(unique=True) + color: Mapped[str] = mapped_column(unique=True) + background_color: Mapped[str] = mapped_column(unique=True) + is_deleted: Mapped[bool] = mapped_column(default=False) + + +class DealTag(BaseModel, IdMixin, SoftDeleteMixin): + __tablename__ = "deal_tags" + + name: Mapped[str] = mapped_column() + + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id")) + project: Mapped["Project"] = relationship( + back_populates="tags", + lazy="noload", + ) + + deals: Mapped[list["Deal"]] = relationship( + secondary="deals_deal_tags", + lazy="noload", + back_populates="tags", + ) + + tag_color_id: Mapped[int] = mapped_column(ForeignKey("deal_tag_colors.id")) + tag_color: Mapped[DealTagColor] = relationship(lazy="immediate") + + __table_args__ = ( + Index("idx_deal_name_project_id", "name", "project_id", "is_deleted"), + ) diff --git a/models/project.py b/models/project.py index 708062d..aa34307 100644 --- a/models/project.py +++ b/models/project.py @@ -6,7 +6,7 @@ from models.base import BaseModel from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin if TYPE_CHECKING: - from models import Board, BuiltInModule + from models import Board, BuiltInModule, DealTag class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): @@ -25,3 +25,10 @@ class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): lazy="selectin", order_by="asc(BuiltInModule.id)", ) + + tags: Mapped[list["DealTag"]] = relationship( + back_populates="project", + primaryjoin="and_(Project.id == DealTag.project_id, DealTag.is_deleted == False)", + order_by="asc(DealTag.id)", + lazy="selectin", + ) diff --git a/repositories/__init__.py b/repositories/__init__.py index a1ac5ac..4cb5d95 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -1,6 +1,7 @@ from .board import BoardRepository as BoardRepository +from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository from .deal import DealRepository as DealRepository +from .deal_group import DealGroupRepository as DealGroupRepository +from .deal_tag import DealTagRepository as DealTagRepository from .project import ProjectRepository as ProjectRepository from .status import StatusRepository as StatusRepository -from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository -from .deal_group import DealGroupRepository as DealGroupRepository diff --git a/repositories/deal.py b/repositories/deal.py index 14ac510..c979396 100644 --- a/repositories/deal.py +++ b/repositories/deal.py @@ -99,6 +99,7 @@ class DealRepository( joinedload(Deal.status), joinedload(Deal.board), selectinload(Deal.group), + selectinload(Deal.tags), ) .where(Deal.is_deleted.is_(False)) ) diff --git a/repositories/deal_tag.py b/repositories/deal_tag.py new file mode 100644 index 0000000..932fa60 --- /dev/null +++ b/repositories/deal_tag.py @@ -0,0 +1,62 @@ +from models import DealTag, DealTagColor, Deal +from models.project import Project +from repositories.mixins import * +from schemas.deal_tag import * + + +class DealTagRepository( + RepCrudMixin[DealTag, CreateDealTagSchema, UpdateDealTagSchema] +): + session: AsyncSession + entity_class = DealTag + entity_not_found_msg = "Тег не найден" + + def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: + project_id = args[0] + return stmt.where( + DealTag.project_id == project_id, DealTag.is_deleted.is_(False) + ).order_by(DealTag.id) + + async def _get_tag_color(self, tag_id: int) -> DealTagColor: + stmt = select(DealTagColor).where(DealTagColor.id == tag_id) + result = await self.session.execute(stmt) + tag = result.one_or_none() + if not tag: + raise ObjectNotFoundException("Цвет тега не найден") + return tag[0] + + async def update(self, deal_tag: DealTag, data: UpdateDealTagSchema) -> Project: + if data.tag_color is not None: + data.tag_color = await self._get_tag_color(data.tag_color.id) + + return await self._apply_update_data_to_model(deal_tag, data, True) + + async def switch_tag_in_deal(self, tag: DealTag, deal: Deal): + if tag in deal.tags: + deal.tags.remove(tag) + else: + deal.tags.append(tag) + await self.session.commit() + + async def switch_tag_in_deals(self, tag: DealTag, deals: list[Deal]): + for deal in deals: + if tag in deal.tags: + deal.tags.remove(tag) + else: + deal.tags.append(tag) + await self.session.commit() + + async def get_tag_colors(self) -> list[DealTagColor]: + stmt = select(DealTagColor) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def sync_deals_tags(self, deals: list[Deal]): + tags_set: set[DealTag] = set() + for deal in deals: + for tag in deal.tags: + tags_set.add(tag) + + tags: list[DealTag] = list(tags_set) + for deal in deals: + deal.tags = tags diff --git a/repositories/project.py b/repositories/project.py index cc196d3..0301a48 100644 --- a/repositories/project.py +++ b/repositories/project.py @@ -1,5 +1,6 @@ from sqlalchemy.orm import selectinload +from models import DealTag from models.project import Project from repositories.built_in_module import BuiltInModuleRepository from repositories.mixins import * @@ -12,11 +13,17 @@ class ProjectRepository( entity_class = Project entity_not_found_msg = "Проект не найден" + def _apply_options(self, stmt: Select) -> Select: + return stmt.options( + selectinload(Project.boards), + selectinload(Project.tags).joinedload(DealTag.tag_color), + ) + def _process_get_all_stmt(self, stmt: Select) -> Select: - return stmt.order_by(Project.id) + return self._apply_options(stmt).order_by(Project.id) def _process_get_by_id_stmt(self, stmt: Select) -> Select: - return stmt.options(selectinload(Project.boards)) + return self._apply_options(stmt) async def update(self, project: Project, data: UpdateProjectSchema) -> Project: if data.built_in_modules is not None: diff --git a/routers/crm/v1/deal_tag.py b/routers/crm/v1/deal_tag.py new file mode 100644 index 0000000..9738fed --- /dev/null +++ b/routers/crm/v1/deal_tag.py @@ -0,0 +1,81 @@ +from pathlib import Path + +from fastapi import APIRouter + +from backend.dependecies import SessionDependency +from schemas.deal_tag import * +from services import DealTagService + +router = APIRouter(tags=["deal-tag"]) + + +@router.get( + "/{projectId}", + response_model=GetDealTagsResponse, + operation_id="get_deal_tags", +) +async def get_deal_tags( + session: SessionDependency, + projectId: int = Path(alias="projectId"), +): + return await DealTagService(session).get_all(projectId) + + +@router.post( + "/", + operation_id="create_deal_tag", + response_model=CreateDealTagResponse, +) +async def create_deal_tag( + request: CreateDealTagRequest, + session: SessionDependency, +): + return await DealTagService(session).create(request) + + +@router.patch( + "/{pk}", + operation_id="update_deal_tag", + response_model=UpdateDealTagResponse, +) +async def update_deal_tag( + request: UpdateDealTagRequest, + session: SessionDependency, + pk: int = Path(), +): + return await DealTagService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteDealTagResponse, + operation_id="delete_deal_tag", +) +async def delete_deal_tag( + session: SessionDependency, + pk: int = Path(), +): + return await DealTagService(session).delete(pk) + + +@router.post( + "/switch", + response_model=SwitchDealTagResponse, + operation_id="switch_deal_tag", +) +async def switch_deal_tag( + session: SessionDependency, + request: SwitchDealTagRequest, +): + return await DealTagService(session).switch_tag(request) + + +@router.post( + "/colors", + response_model=GetTagColorsResponse, + operation_id="get_deal_tag_colors", +) +async def get_deal_tag_colors( + session: SessionDependency, +): + return await DealTagService(session).get_tag_colors() diff --git a/schemas/deal.py b/schemas/deal.py index f564999..6c95c20 100644 --- a/schemas/deal.py +++ b/schemas/deal.py @@ -5,6 +5,7 @@ from modules.clients.schemas.client import ClientSchema from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema from schemas.board import BoardSchema from schemas.deal_group import DealGroupSchema +from schemas.deal_tag import DealTagSchema from schemas.status import StatusSchema @@ -19,6 +20,7 @@ class DealSchema(BaseSchema): board: BoardSchema created_at: datetime group: Optional[DealGroupSchema] + tags: list[DealTagSchema] # FF module products_quantity: int = 0 total_price: float = 0 diff --git a/schemas/deal_tag.py b/schemas/deal_tag.py new file mode 100644 index 0000000..0fa1726 --- /dev/null +++ b/schemas/deal_tag.py @@ -0,0 +1,79 @@ +from typing import Optional + +from schemas.base import BaseSchema, BaseResponse + + +# region Entities + + +class DealTagColorSchema(BaseSchema): + id: int + color: str + background_color: str + label: str + + +class CreateDealTagSchema(BaseSchema): + name: str + project_id: int + tag_color_id: int + + +class DealTagSchema(CreateDealTagSchema): + id: int + tag_color: DealTagColorSchema + + +class UpdateDealTagSchema(BaseSchema): + name: Optional[str] = None + tag_color: Optional[DealTagColorSchema] = None + + +# endregion + +# region Requests + + +class CreateDealTagRequest(BaseSchema): + entity: CreateDealTagSchema + + +class UpdateDealTagRequest(BaseSchema): + entity: UpdateDealTagSchema + + +class SwitchDealTagRequest(BaseSchema): + tag_id: int + deal_id: Optional[int] = None + group_id: Optional[int] = None + + +# endregion + +# region Responses + +class GetDealTagsResponse(BaseSchema): + items: list[DealTagSchema] + + +class CreateDealTagResponse(BaseResponse): + entity: DealTagSchema + + +class UpdateDealTagResponse(BaseResponse): + pass + + +class DeleteDealTagResponse(BaseResponse): + pass + + +class SwitchDealTagResponse(BaseResponse): + pass + + +class GetTagColorsResponse(BaseSchema): + items: list[DealTagColorSchema] + + +# endregion diff --git a/schemas/project.py b/schemas/project.py index 6e8a418..e02c659 100644 --- a/schemas/project.py +++ b/schemas/project.py @@ -1,6 +1,7 @@ from typing import Optional from schemas.base import BaseSchema, BaseResponse +from schemas.deal_tag import DealTagSchema from schemas.module import BuiltInModuleSchema @@ -11,6 +12,7 @@ class ProjectSchema(BaseSchema): id: int name: str built_in_modules: list[BuiltInModuleSchema] + tags: list[DealTagSchema] class CreateProjectSchema(BaseSchema): diff --git a/services/__init__.py b/services/__init__.py index 1eb8b8e..7ad6107 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -3,3 +3,4 @@ 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 diff --git a/services/deal_group.py b/services/deal_group.py index 8d8d176..569f255 100644 --- a/services/deal_group.py +++ b/services/deal_group.py @@ -1,7 +1,7 @@ from lexorank import lexorank from models import DealGroup, Deal -from repositories import DealGroupRepository, DealRepository +from repositories import DealGroupRepository, DealRepository, DealTagRepository from schemas.deal_group import * from services.mixins import * @@ -21,6 +21,10 @@ class DealGroupService( if deal.status_id != main_deal.status_id: await deal_repo.update_status(deal, main_deal.status_id) + await DealTagRepository(self.repository.session).sync_deals_tags( + [main_deal, *other_deals] + ) + async def create(self, request: CreateDealGroupRequest) -> CreateDealGroupResponse: deal_repo = DealRepository(self.repository.session) main_deal: Deal = await deal_repo.get_by_id(request.main_deal_id) diff --git a/services/deal_tag.py b/services/deal_tag.py new file mode 100644 index 0000000..2e5e7aa --- /dev/null +++ b/services/deal_tag.py @@ -0,0 +1,37 @@ +from models import DealTag +from repositories import DealTagRepository, DealRepository +from schemas.deal_tag import * +from services.mixins import * + + +class DealTagService( + ServiceCrudMixin[DealTag, DealTagSchema, CreateDealTagRequest, UpdateDealTagRequest] +): + schema_class = DealTagSchema + entity_deleted_msg = "Тег успешно удален" + entity_updated_msg = "Тег успешно обновлен" + entity_created_msg = "Тег успешно создан" + + def __init__(self, session: AsyncSession): + self.repository = DealTagRepository(session) + + async def switch_tag(self, request: SwitchDealTagRequest) -> SwitchDealTagResponse: + tag: DealTag = await self.repository.get_by_id(request.tag_id) + if request.deal_id: + deal = await DealRepository(self.repository.session).get_by_id( + request.deal_id + ) + await self.repository.switch_tag_in_deal(tag, deal) + else: + deals = await DealRepository(self.repository.session).get_by_group_id( + request.group_id + ) + await self.repository.switch_tag_in_deals(tag, deals) + + return SwitchDealTagResponse(ok=True, message="Успешно") + + async def get_tag_colors(self) -> GetTagColorsResponse: + colors = await self.repository.get_tag_colors() + return GetTagColorsResponse( + items=[DealTagColorSchema.model_validate(color) for color in colors] + )