Compare commits

...

2 Commits

Author SHA1 Message Date
44f00b1057 feat: default name for groups 2025-10-19 12:22:47 +04:00
ffee658349 feat: deal tags 2025-10-19 12:12:08 +04:00
17 changed files with 358 additions and 9 deletions

View File

@ -5,7 +5,6 @@ from fastapi.responses import ORJSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
import modules
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 ObjectNotFoundException

View File

@ -10,6 +10,11 @@ from .built_in_module import ( # noqa: F401
) )
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 (
DealTag as DealTag,
DealTagColor as DealTagColor,
deals_deal_tags as deals_deal_tags,
)
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

View File

@ -7,7 +7,7 @@ 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 from models import Status, Board, DealStatusHistory, DealGroup, DealTag
from modules.clients.models import Client from modules.clients.models import Client
@ -41,6 +41,14 @@ class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
lazy="noload", back_populates="deals" 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 # 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"),

51
models/deal_tag.py Normal file
View File

@ -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"),
)

View File

@ -6,7 +6,7 @@ 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 Board, BuiltInModule from models import Board, BuiltInModule, DealTag
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
@ -25,3 +25,10 @@ class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
lazy="selectin", lazy="selectin",
order_by="asc(BuiltInModule.id)", 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",
)

View File

@ -1,6 +1,7 @@
from .board import BoardRepository as BoardRepository from .board import BoardRepository as BoardRepository
from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository
from .deal import DealRepository as DealRepository 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 .project import ProjectRepository as ProjectRepository
from .status import StatusRepository as StatusRepository from .status import StatusRepository as StatusRepository
from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository
from .deal_group import DealGroupRepository as DealGroupRepository

View File

@ -99,6 +99,7 @@ class DealRepository(
joinedload(Deal.status), joinedload(Deal.status),
joinedload(Deal.board), joinedload(Deal.board),
selectinload(Deal.group), selectinload(Deal.group),
selectinload(Deal.tags),
) )
.where(Deal.is_deleted.is_(False)) .where(Deal.is_deleted.is_(False))
) )

View File

@ -14,8 +14,10 @@ class DealGroupRepository(
async def create(self, deals: list[Deal], lexorank: str) -> DealGroup: async def create(self, deals: list[Deal], lexorank: str) -> DealGroup:
group = DealGroup(deals=deals, lexorank=lexorank) group = DealGroup(deals=deals, lexorank=lexorank)
self.session.add(group) self.session.add(group)
await self.session.commit() await self.session.flush()
await self.session.refresh(group) await self.session.refresh(group)
group.name = "Группа ID: " + str(group.id)
await self.session.commit()
return group return group
async def update(self, entity: DealGroup, data: UpdateDealGroupSchema) -> DealGroup: async def update(self, entity: DealGroup, data: UpdateDealGroupSchema) -> DealGroup:

62
repositories/deal_tag.py Normal file
View File

@ -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

View File

@ -1,5 +1,6 @@
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models import DealTag
from models.project import Project from models.project import Project
from repositories.built_in_module import BuiltInModuleRepository from repositories.built_in_module import BuiltInModuleRepository
from repositories.mixins import * from repositories.mixins import *
@ -12,11 +13,17 @@ class ProjectRepository(
entity_class = Project entity_class = Project
entity_not_found_msg = "Проект не найден" 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: 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: 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: async def update(self, project: Project, data: UpdateProjectSchema) -> Project:
if data.built_in_modules is not None: if data.built_in_modules is not None:

View File

@ -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()

View File

@ -5,6 +5,7 @@ from modules.clients.schemas.client import ClientSchema
from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema
from schemas.board import BoardSchema from schemas.board import BoardSchema
from schemas.deal_group import DealGroupSchema from schemas.deal_group import DealGroupSchema
from schemas.deal_tag import DealTagSchema
from schemas.status import StatusSchema from schemas.status import StatusSchema
@ -19,6 +20,7 @@ class DealSchema(BaseSchema):
board: BoardSchema board: BoardSchema
created_at: datetime created_at: datetime
group: Optional[DealGroupSchema] group: Optional[DealGroupSchema]
tags: list[DealTagSchema]
# FF module # FF module
products_quantity: int = 0 products_quantity: int = 0
total_price: float = 0 total_price: float = 0

79
schemas/deal_tag.py Normal file
View File

@ -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

View File

@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from schemas.base import BaseSchema, BaseResponse from schemas.base import BaseSchema, BaseResponse
from schemas.deal_tag import DealTagSchema
from schemas.module import BuiltInModuleSchema from schemas.module import BuiltInModuleSchema
@ -11,6 +12,7 @@ class ProjectSchema(BaseSchema):
id: int id: int
name: str name: str
built_in_modules: list[BuiltInModuleSchema] built_in_modules: list[BuiltInModuleSchema]
tags: list[DealTagSchema]
class CreateProjectSchema(BaseSchema): class CreateProjectSchema(BaseSchema):

View File

@ -3,3 +3,4 @@ from .deal import DealService as DealService
from .project import ProjectService as ProjectService 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

View File

@ -1,7 +1,7 @@
from lexorank import lexorank from lexorank import lexorank
from models import DealGroup, Deal from models import DealGroup, Deal
from repositories import DealGroupRepository, DealRepository from repositories import DealGroupRepository, DealRepository, DealTagRepository
from schemas.deal_group import * from schemas.deal_group import *
from services.mixins import * from services.mixins import *
@ -21,6 +21,10 @@ class DealGroupService(
if deal.status_id != main_deal.status_id: if deal.status_id != main_deal.status_id:
await deal_repo.update_status(deal, 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: async def create(self, request: CreateDealGroupRequest) -> CreateDealGroupResponse:
deal_repo = DealRepository(self.repository.session) deal_repo = DealRepository(self.repository.session)
main_deal: Deal = await deal_repo.get_by_id(request.main_deal_id) main_deal: Deal = await deal_repo.get_by_id(request.main_deal_id)

37
services/deal_tag.py Normal file
View File

@ -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]
)