feat: added tags for cards, aligned status headers

This commit is contained in:
2025-03-09 19:30:52 +04:00
parent 487174c4ff
commit 6030591e3c
17 changed files with 375 additions and 23 deletions

View File

@@ -10,3 +10,4 @@ class ProfitTableGroupBy(IntEnum):
BY_WAREHOUSES = 5
BY_MARKETPLACES = 6
BY_MANAGERS = 7
BY_TAGS = 8

View File

@@ -57,6 +57,7 @@ routers_list = [
routers.project_router,
routers.board_router,
routers.status_router,
routers.card_tag_router,
]
for router in routers_list:
app.include_router(router)

View File

@@ -6,6 +6,7 @@ from .board import *
from .status import *
from .attribute import *
from .card import *
from .card_tag import *
from .auth import *
from .card import *
from .client import *

View File

@@ -10,8 +10,10 @@ from .shipping import Pallet, Box
from .shipping_warehouse import ShippingWarehouse
if TYPE_CHECKING:
from . import CardBillRequest, User, BaseModel, Board, CardStatus, CardGroup, CardAttribute, CardService as CardServiceModel, \
CardProduct, Client
from . import (
CardBillRequest, User, BaseModel, Board, CardStatus, CardGroup, CardAttribute, Client, CardTag,
CardService as CardServiceModel, CardProduct,
)
class Card(BaseModel):
@@ -56,6 +58,13 @@ class Card(BaseModel):
lazy='joined',
back_populates='cards'
)
tags: Mapped[list['CardTag']] = relationship(
'CardTag',
secondary='cards_card_tags',
back_populates='cards',
lazy='selectin',
)
# endregion
# region Attributes handled by modules
@@ -107,6 +116,7 @@ class Card(BaseModel):
# endregion
class CardEmployees(BaseModel):
__tablename__ = 'card_employees'
user_id: Mapped[int] = mapped_column(ForeignKey('users.id'), primary_key=True)
@@ -116,4 +126,3 @@ class CardEmployees(BaseModel):
card: Mapped[Card] = relationship('Card', back_populates='employees', lazy='selectin')
created_at: Mapped[datetime] = mapped_column()

45
models/card_tag.py Normal file
View File

@@ -0,0 +1,45 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Column, Table, UniqueConstraint, Index
from sqlalchemy.orm import mapped_column, Mapped, relationship
from models import BaseModel
if TYPE_CHECKING:
from models import Project, Card
cards_card_tags = Table(
'cards_card_tags',
BaseModel.metadata,
Column('card_id', ForeignKey('cards.id'), primary_key=True),
Column('card_tag_id', ForeignKey('card_tags.id'), primary_key=True),
)
class CardTag(BaseModel):
__tablename__ = 'card_tags'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
is_deleted: Mapped[bool] = mapped_column(default=False, server_default='0')
project_id: Mapped[int] = mapped_column(
ForeignKey('projects.id'),
nullable=False,
)
project: Mapped['Project'] = relationship(
'Project',
back_populates='tags',
lazy='noload',
)
cards: Mapped[list['Card']] = relationship(
secondary='cards_card_tags',
lazy='noload',
back_populates='tags',
)
__table_args__ = (
Index('idx_card_name_project_id', 'name', 'project_id', 'is_deleted'),
)

View File

@@ -9,6 +9,7 @@ if TYPE_CHECKING:
from board import Board
from attribute import Attribute
from module import Module
from card_tag import CardTag
class Project(BaseModel):
@@ -27,7 +28,6 @@ class Project(BaseModel):
attributes: Mapped[list['Attribute']] = relationship(
'Attribute',
uselist=True,
secondary='project_attribute',
back_populates='projects',
lazy='selectin',
@@ -35,10 +35,16 @@ class Project(BaseModel):
modules: Mapped[list['Module']] = relationship(
'Module',
uselist=True,
secondary='project_module',
back_populates='projects',
lazy='selectin',
order_by='asc(Module.id)',
)
tags: Mapped[list['CardTag']] = relationship(
'CardTag',
back_populates='project',
primaryjoin="and_(Project.id == CardTag.project_id, CardTag.is_deleted == False)",
order_by='asc(CardTag.id)',
lazy='selectin',
)

View File

@@ -25,3 +25,4 @@ from .residues import residues_router
from .project import project_router
from .board import board_router
from .status import status_router
from .card_tag import card_tag_router

63
routers/card_tag.py Normal file
View File

@@ -0,0 +1,63 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.session import get_session
from schemas.card_tag import *
from services.auth import authorized_user
from services.card_tag import CardTagService
card_tag_router = APIRouter(
prefix='/card-tag',
tags=['card-tag'],
dependencies=[Depends(authorized_user)]
)
@card_tag_router.post(
'/',
operation_id='create_tag',
response_model=CreateTagResponse
)
async def create_tag(
request: CreateTagRequest,
session: Annotated[AsyncSession, Depends(get_session)]
):
return await CardTagService(session).create(request)
@card_tag_router.patch(
'/',
operation_id='update_tag',
response_model=UpdateTagResponse
)
async def update_tag(
request: UpdateTagRequest,
session: Annotated[AsyncSession, Depends(get_session)]
):
return await CardTagService(session).update(request)
@card_tag_router.delete(
'/{card_tag_id}',
response_model=DeleteTagResponse,
operation_id="delete_tag",
)
async def delete_tag(
session: Annotated[AsyncSession, Depends(get_session)],
card_tag_id: int,
):
return await CardTagService(session).delete(card_tag_id)
@card_tag_router.post(
'/switch',
response_model=SwitchTagResponse,
operation_id='switch_tag',
)
async def switch_tag(
session: Annotated[AsyncSession, Depends(get_session)],
request: SwitchTagRequest,
):
return await CardTagService(session).switch_tag(request)

View File

@@ -7,6 +7,7 @@ from schemas.attribute import CardAttributeSchema
from schemas.base import BaseSchema, OkMessageSchema
from schemas.billing import CardBillRequestSchema
from schemas.board import BoardSchema
from schemas.card_tag import CardTagSchema
from schemas.client import ClientSchema
from schemas.group import CardGroupSchema
from schemas.marketplace import BaseMarketplaceSchema
@@ -41,6 +42,7 @@ class CardSummary(BaseSchema):
rank: int
base_marketplace: Optional[BaseMarketplaceSchema] = None
total_products: int
tags: list[CardTagSchema]
shipment_warehouse_id: Optional[int]
shipment_warehouse_name: Optional[str]
@@ -100,6 +102,7 @@ class BaseCardSchema(BaseSchema):
pallets: List[PalletSchema] = []
boxes: List[BoxSchema] = []
employees: List[CardEmployeesSchema] = []
tags: List[CardTagSchema] = []
class CardSchema(BaseCardSchema):
@@ -115,6 +118,7 @@ class CardGeneralInfoSchema(BaseSchemaWithAttributes):
board_id: int
status_id: int
client_id: Optional[int]
tags: List[str]
class ProductsAndServicesGeneralInfoSchema(BaseSchema):

53
schemas/card_tag.py Normal file
View File

@@ -0,0 +1,53 @@
from typing import Optional
from schemas.base import BaseSchema, OkMessageSchema
# region Entities
class BaseCardTagSchema(BaseSchema):
name: str
project_id: int
class CardTagSchema(BaseCardTagSchema):
id: int
# endregion
# region Requests
class CreateTagRequest(BaseSchema):
tag: BaseCardTagSchema
class UpdateTagRequest(BaseSchema):
tag: CardTagSchema
class SwitchTagRequest(BaseSchema):
tag_id: int
card_id: Optional[int] = None
group_id: Optional[int] = None
# endregion
# region Responses
class CreateTagResponse(OkMessageSchema):
pass
class UpdateTagResponse(OkMessageSchema):
pass
class DeleteTagResponse(OkMessageSchema):
pass
class SwitchTagResponse(OkMessageSchema):
pass
# endregion

View File

@@ -2,6 +2,7 @@ from typing import Optional
from schemas.attribute import AttributeSchema
from schemas.base import BaseSchema, OkMessageSchema
from schemas.card_tag import CardTagSchema
# region Entities
@@ -26,6 +27,7 @@ class ModuleSchema(BaseSchema):
class ProjectSchema(ProjectGeneralInfoSchema):
attributes: list[AttributeSchema]
modules: list[ModuleSchema]
tags: list[CardTagSchema]
class FullProjectSchema(ProjectSchema):

View File

@@ -32,6 +32,7 @@ class CommonProfitFilters(BaseSchema):
project_id: int
board_id: int
card_status_id: int
card_tag_id: int
manager_id: int
expense_tag_id: int
income_tag_id: int

View File

@@ -148,8 +148,6 @@ class BarcodeService(BaseService):
"template": barcode_template,
"num_duplicates": card_product.quantity
})
else:
print("jaja")
default_generator = DefaultBarcodeGenerator()
filename = f'{card.id}_deal_barcodes.pdf'

View File

@@ -14,6 +14,7 @@ from schemas.client import ClientDetailsSchema
from services import card_group
from services.auth import AuthService
from services.base import BaseService
from services.card_tag import CardTagService
from services.client import ClientService
from services.service import ServiceService
from services.shipping_warehouse import ShippingWarehouseService
@@ -240,6 +241,7 @@ class CardsService(BaseService):
shipment_warehouse_name=shipment_warehouse_name,
total_products=products_count,
bill_request=card.bill_request,
tags=card.tags,
)
)
return CardSummaryResponse(summaries=summaries)
@@ -347,7 +349,8 @@ class CardsService(BaseService):
select(Card)
.options(
selectinload(Card.group)
.selectinload(CardGroup.cards)
.selectinload(CardGroup.cards),
joinedload(Card.board),
)
.where(Card.id == request.card_id)
)
@@ -374,6 +377,9 @@ class CardsService(BaseService):
else:
card.manager = None
tag_service = CardTagService(self.session)
await tag_service.apply_tags(card, request.data.tags)
card_attrs_handler = CardAttributesCommandHandler(self.session)
await card_attrs_handler.set_attributes(card, request.data.attributes)
@@ -388,7 +394,7 @@ class CardsService(BaseService):
):
card: Optional[Card] = await self.session.get(Card, request.card_id)
if not card:
raise HTTPException(status_code=404, detail='Карточка не найдена')
return ProductsAndServicesGeneralInfoRequest(ok=False, message='Карточка не найдена')
# Updating shipping warehouse
shipping_warehouse_service = ShippingWarehouseService(self.session)
@@ -409,7 +415,7 @@ class CardsService(BaseService):
async def update_card_manager(self, request: UpdateCardManagerRequest) -> UpdateCardManagerResponse:
card: Optional[Card] = await self.session.get(Card, request.card_id)
if not card:
raise HTTPException(status_code=404, detail='Карточка не найдена')
return UpdateCardManagerResponse(ok=False, message='Карточка не найдена')
card.manager_id = request.manager_id
await self.session.commit()
@@ -418,7 +424,7 @@ class CardsService(BaseService):
async def update_card_client(self, request: UpdateCardClientRequest) -> UpdateCardClientResponse:
card: Optional[Card] = await self.session.get(Card, request.card_id)
if not card:
raise HTTPException(status_code=404, detail='Карточка не найдена')
return UpdateCardClientResponse(ok=False, message='Карточка не найдена')
card.client_id = request.client_id
await self.session.commit()
@@ -513,8 +519,6 @@ class CardsService(BaseService):
return CardAddKitResponse(ok=False, message=str(e))
def create_guest_url(self, user: User, request: CardCreateGuestUrlRequest) -> CardCreateGuestUrlResponse:
# if not user.is_admin:
# return CardCreateGuestUrlResponse(ok=False, message='Создать ссылку может только администратор', url="")
access_token = AuthService(self.session).create_deal_guest_token(request.card_id)
url = f"deals/{request.card_id}?accessToken={access_token}"
return CardCreateGuestUrlResponse(ok=True, message='Ссылка успешно создана!', url=url)

122
services/card_tag.py Normal file
View File

@@ -0,0 +1,122 @@
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models import CardTag, Card, CardGroup
from schemas.card_tag import *
from services.base import BaseService
class CardTagService(BaseService):
async def _get_by_name_and_project_id(self, name: str, project_id: int) -> Optional[CardTag]:
stmt = (
select(CardTag)
.where(
CardTag.name == name,
CardTag.project_id == project_id,
CardTag.is_deleted == False,
)
)
card_tag = await self.session.scalars(stmt)
return card_tag.first()
async def create(self, request: CreateTagRequest) -> CreateTagResponse:
existing_tag = await self._get_by_name_and_project_id(request.tag.name, request.tag.project_id)
if existing_tag:
return UpdateTagResponse(ok=False, message='Тег с таким названием уже существует')
tag = CardTag(name=request.tag.name, project_id=request.tag.project_id)
self.session.add(tag)
await self.session.commit()
return CreateTagResponse(ok=True, message='Тег успешно создан')
async def update(self, request: UpdateTagRequest) -> UpdateTagResponse:
card_tag = await self.session.get(CardTag, request.tag.id)
if not card_tag:
return UpdateTagResponse(ok=False, message=f'Тег с ID {request.tag.id} не найден')
if card_tag.name != request.tag.name:
existing_tag = await self._get_by_name_and_project_id(request.tag.name, request.tag.project_id)
if existing_tag:
return UpdateTagResponse(ok=False, message='Тег с таким названием уже существует')
card_tag.name = request.tag.name
await self.session.commit()
return UpdateTagResponse(ok=True, message='Тег успешно обновлен')
async def delete(self, card_tag_id: int) -> DeleteTagResponse:
card_tag = await self.session.get(CardTag, card_tag_id)
if not card_tag:
return DeleteTagResponse(ok=False, message=f'Тег с ID {card_tag_id} не найден')
card_tag.is_deleted = True
await self.session.commit()
return DeleteTagResponse(ok=True, message='Тег успешно удален')
async def _switch_tag_in_card(self, card_tag: CardTag, card_id: int) -> tuple[bool, str]:
stmt = (
select(Card)
.options(selectinload(Card.tags))
.where(Card.id == card_id)
)
card: Optional[Card] = (await self.session.scalars(stmt)).first()
if not card:
return False, f'Карточка с ID {card_id} не найдена'
if card_tag in card.tags:
card.tags.remove(card_tag)
return True, 'Тег откреплен от карточки'
card.tags.append(card_tag)
return True, 'Тег прикреплен к карточке'
async def _get_cards_by_group_id(self, group_id) -> list[Card]:
group: CardGroup | None = await self.session.scalar(
select(CardGroup)
.where(CardGroup.id == group_id)
)
return group.cards if group else []
async def _switch_tag_in_group(self, card_tag: CardTag, group_id: int):
cards = await self._get_cards_by_group_id(group_id)
for card in cards:
if card_tag in card.tags:
card.tags.remove(card_tag)
else:
card.tags.append(card_tag)
return True, 'Теги группы изменены'
async def switch_tag(self, request: SwitchTagRequest) -> SwitchTagResponse:
card_tag: Optional[CardTag] = await self.session.get(CardTag, request.tag_id)
if not card_tag:
return SwitchTagResponse(ok=False, message=f'Тег с ID {request.tag_id} не найден')
if request.card_id:
ok, message = await self._switch_tag_in_card(card_tag, request.card_id)
else:
ok, message = await self._switch_tag_in_group(card_tag, request.group_id)
await self.session.commit()
return SwitchTagResponse(ok=True, message=message)
async def apply_tags(self, card: Card, tag_names: list[str]):
stmt = (
select(CardTag)
.where(
CardTag.name.in_(tag_names),
CardTag.is_deleted == False,
CardTag.project_id == card.board.project_id
)
)
tags = (await self.session.scalars(stmt)).all()
cards = [card]
if card.group:
cards = await self._get_cards_by_group_id(card.group.id)
for card in cards:
card.tags = tags
await self.session.flush()

View File

@@ -1,11 +1,10 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update, func, delete
from sqlalchemy.orm import selectinload
from card_attributes import CardAttributesCommandHandler
from models import Project, Board, Module, Attribute
from models import Project, Board, Module
from schemas.project import *
from services.base import BaseService
@@ -30,6 +29,7 @@ class ProjectService(BaseService):
selectinload(Project.attributes),
selectinload(Project.modules),
)
.where(Project.is_deleted == False)
.order_by(Project.id)
)
project_data = (await self.session.execute(stmt)).all()
@@ -42,6 +42,7 @@ class ProjectService(BaseService):
boards_count=boards_count,
attributes=project.attributes,
modules=project.modules,
tags=project.tags,
)
projects.append(project_schema)

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, and_, union_all, func, Subquery, literal
from enums.profit_table_group_by import ProfitTableGroupBy
from models import CardService, Card, CardStatusHistory, CardProductService, CardProduct, Service, Client, \
ShippingWarehouse, BaseMarketplace, User, Project, Board
ShippingWarehouse, BaseMarketplace, User, Project, Board, CardTag, cards_card_tags, user_position
from schemas.statistics import GetProfitChartDataResponse, GetProfitChartDataRequest, ProfitChartDataItem, \
GetProfitTableDataResponse, GetProfitTableDataRequest, ProfitTableDataItem, CommonProfitFilters
from services.base import BaseService
@@ -112,13 +112,11 @@ class ProfitStatisticsService(BaseService):
.join(Service, CardService.service_id == Service.id)
.join(sub_filtered_status_history, Card.id == sub_filtered_status_history.c.card_id)
.where(
and_(
Card.is_deleted == False,
Card.is_services_profit_accounted == True,
Card.is_completed == True if self.is_completed_only else True
)
Card.is_deleted == False,
Card.is_services_profit_accounted == True,
Card.is_completed == True if self.is_completed_only else True,
)
.group_by(Card.id, "date")
.group_by(Card.id, sub_filtered_status_history.c.date)
)
@staticmethod
@@ -129,6 +127,14 @@ class ProfitStatisticsService(BaseService):
)
return stmt.where(Card.board_id.in_(board_ids_stmt))
@staticmethod
def _apply_tag_filter(tag_id: int, stmt):
sub_card_ids = (
select(cards_card_tags.c.card_id)
.where(cards_card_tags.c.card_tag_id == tag_id)
)
return stmt.where(Card.id.in_(sub_card_ids))
@staticmethod
def _apply_filters(request: CommonProfitFilters, stmt_card_services, stmt_card_product_services):
if request.client_id != -1:
@@ -156,6 +162,13 @@ class ProfitStatisticsService(BaseService):
stmt_card_product_services = stmt_card_product_services.where(
Card.current_status_id == request.card_status_id)
if request.card_tag_id != -1:
stmt_card_services = ProfitStatisticsService._apply_tag_filter(request.card_tag_id, stmt_card_services)
stmt_card_product_services = ProfitStatisticsService._apply_tag_filter(
request.card_tag_id,
stmt_card_product_services
)
if request.manager_id != -1:
stmt_card_services = stmt_card_services.where(Card.manager_id == request.manager_id)
stmt_card_product_services = stmt_card_product_services.where(Card.manager_id == request.manager_id)
@@ -334,7 +347,8 @@ class ProfitStatisticsService(BaseService):
def _join_and_group_by_managers(stmt):
managers = (
select(User)
.where(User.role_key == "employee")
.join(user_position)
.where(and_(User.is_deleted == False, user_position.c.position_key == "sales_manager"))
.subquery()
)
return (
@@ -350,6 +364,23 @@ class ProfitStatisticsService(BaseService):
.group_by(managers.c.id, "grouped_value")
)
@staticmethod
def _join_and_group_by_tags(stmt):
return (
select(
CardTag.id,
CardTag.name.label("grouped_value"),
CardTag.is_deleted,
func.count(stmt.c.card_id).label("cards_count"),
func.sum(stmt.c.revenue).label("revenue"),
func.sum(stmt.c.profit).label("profit"),
)
.join(cards_card_tags, cards_card_tags.c.card_id == stmt.c.card_id)
.join(CardTag, cards_card_tags.c.card_tag_id == CardTag.id)
.where(CardTag.is_deleted == False)
.group_by(CardTag.is_deleted, CardTag.id, CardTag.name)
)
async def _get_data_rows_grouped_by_date(
self,
stmt_card_services,
@@ -476,6 +507,7 @@ class ProfitStatisticsService(BaseService):
async def _get_table_grouped_by_statuses(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
date_from, date_to = request.date_range
self.is_completed_only = request.is_completed_only
sub_cards_dates = self._get_filtered_sub_status_history(date_from, date_to)
@@ -518,6 +550,12 @@ class ProfitStatisticsService(BaseService):
return await self._table_data_from_stmt(stmt_grouped_by_managers)
async def _get_table_grouped_by_tags(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
sub_grouped_by_cards = self._get_common_table_grouped(request)
stmt_grouped_by_tags = self._join_and_group_by_tags(sub_grouped_by_cards)
return await self._table_data_from_stmt(stmt_grouped_by_tags)
async def get_profit_table_data(self, request: GetProfitTableDataRequest) -> GetProfitTableDataResponse:
match request.group_table_by:
case ProfitTableGroupBy.BY_DATES:
@@ -536,5 +574,7 @@ class ProfitStatisticsService(BaseService):
return await self._get_table_grouped_by_marketplace(request)
case ProfitTableGroupBy.BY_MANAGERS:
return await self._get_table_grouped_by_managers(request)
case ProfitTableGroupBy.BY_TAGS:
return await self._get_table_grouped_by_tags(request)
raise HTTPException(status_code=400, detail='Указана некорректная группировка')