feat: attributes in cards and projects

This commit is contained in:
2025-02-27 18:34:38 +04:00
parent c6c006d45b
commit a2c9fd8e3b
16 changed files with 391 additions and 27 deletions

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, and_
from card_attributes.exceptions import CardAttributeException from card_attributes.exceptions import CardAttributeException
from card_attributes.handlers.base_handler import BaseHandler from card_attributes.handlers.base_handler import BaseHandler
from models import CardAttribute, Attribute, Card from models import CardAttribute, Attribute, Card, Project
from .card_attributes_query_handler import CardAttributesQueryHandler from .card_attributes_query_handler import CardAttributesQueryHandler
@@ -19,7 +19,7 @@ class CardAttributesCommandHandler(BaseHandler):
self.session.add(card_attribute) self.session.add(card_attribute)
await self.session.flush() await self.session.flush()
async def _set_attribute_after_creation(self, card_id: int, project_attr: Attribute, attributes: Optional[dict]): async def _set_attribute_after_creation(self, card_id: int, project_attr: Attribute, attributes: Optional[dict] = None):
if attributes and project_attr.name in attributes: if attributes and project_attr.name in attributes:
passed_value = attributes[project_attr.name] passed_value = attributes[project_attr.name]
return await self._create_card_attribute(card_id, project_attr.id, passed_value) return await self._create_card_attribute(card_id, project_attr.id, passed_value)
@@ -65,7 +65,7 @@ class CardAttributesCommandHandler(BaseHandler):
else: else:
card_attribute.set_value(value) card_attribute.set_value(value)
async def set_attr_for_each_in_group(self, group_id: int, attribute_name: str, value): async def set_attr_for_each_card_in_group(self, group_id: int, attribute_name: str, value):
query_handler = CardAttributesQueryHandler(self.session) query_handler = CardAttributesQueryHandler(self.session)
card_ids: list[int] = await query_handler.get_card_ids_by_group_id(group_id) card_ids: list[int] = await query_handler.get_card_ids_by_group_id(group_id)
@@ -81,10 +81,33 @@ class CardAttributesCommandHandler(BaseHandler):
try: try:
attr = next(attr for attr in project_attrs if attr.name == attr_name) attr = next(attr for attr in project_attrs if attr.name == attr_name)
if attr.is_applicable_to_group and card.group: if attr.is_applicable_to_group and card.group:
await self.set_attr_for_each_in_group(card.group.id, attr_name, attr_value) await self.set_attr_for_each_card_in_group(card.group.id, attr_name, attr_value)
else: else:
await self._set_card_attribute(card.id, attr_name, attr_value) await self._set_card_attribute(card.id, attr_name, attr_value)
except StopIteration: except StopIteration:
pass pass
except CardAttributeException: except CardAttributeException:
raise raise
async def set_project_attributes(self, project: Project, attribute_ids: list[int]):
query_handler = CardAttributesQueryHandler(self.session)
attributes = await query_handler.get_attributes_by_ids(attribute_ids)
attributes_to_create = []
for attribute in attributes:
project_attr = await query_handler.get_project_attr(project.id, attribute.id)
if not project_attr:
attributes_to_create.append(attribute)
async for card in query_handler.get_all_cards_for_project(project.id):
await self._add_attributes_to_card(card, attributes_to_create)
project.attributes = attributes
await self.session.commit()
async def _add_attributes_to_card(self, card: Card, attributes_to_create: list[Attribute]):
card_attribute_ids: set[int] = set((attr.attribute_id for attr in card.attributes))
for attribute in attributes_to_create:
if attribute.id not in card_attribute_ids:
await self._set_attribute_after_creation(card.id, attribute)

View File

@@ -1,10 +1,11 @@
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select, and_
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.ext.asyncio import AsyncResult
from sqlalchemy.orm import selectinload
from card_attributes.handlers.base_handler import BaseHandler from card_attributes.handlers.base_handler import BaseHandler
from models import Attribute, project_attribute, card_relations from models import Attribute, project_attribute, card_relations, Card, Project, Board
class CardAttributesQueryHandler(BaseHandler): class CardAttributesQueryHandler(BaseHandler):
@@ -12,7 +13,10 @@ class CardAttributesQueryHandler(BaseHandler):
stmt = ( stmt = (
select(Attribute) select(Attribute)
.join(project_attribute, project_attribute.c.attribute_id == Attribute.id) .join(project_attribute, project_attribute.c.attribute_id == Attribute.id)
.where(project_attribute.c.project_id == project_id) .where(
project_attribute.c.project_id == project_id,
Attribute.is_deleted == False,
)
) )
attributes = (await self.session.scalars(stmt)).all() attributes = (await self.session.scalars(stmt)).all()
return list(attributes) return list(attributes)
@@ -23,7 +27,10 @@ class CardAttributesQueryHandler(BaseHandler):
.options( .options(
selectinload(Attribute.projects), selectinload(Attribute.projects),
) )
.where(Attribute.name == attr_name) .where(
Attribute.name == attr_name,
Attribute.is_deleted == False,
)
) )
attribute = (await self.session.scalars(stmt)).first() attribute = (await self.session.scalars(stmt)).first()
return attribute return attribute
@@ -35,3 +42,37 @@ class CardAttributesQueryHandler(BaseHandler):
) )
ids = await self.session.scalars(stmt) ids = await self.session.scalars(stmt)
return list(ids) return list(ids)
async def get_all_cards_for_project(self, project_id: int) -> AsyncResult[Card]:
stmt = (
select(Card)
.join(Board)
.join(Project)
.where(Project.id == project_id)
.options(selectinload(Card.attributes))
.execution_options(yield_per=100)
)
rows: AsyncResult[tuple[Card]] = await self.session.stream(stmt)
async for row in rows:
yield row[0]
async def get_project_attr(self, project_id: int, attribute_id: int) -> project_attribute:
stmt_is_attribute_already_added = (
select(project_attribute)
.where(
and_(
project_attribute.c.project_id == project_id,
project_attribute.c.attribute_id == attribute_id,
)
)
)
project_attribute_inst = await self.session.execute(stmt_is_attribute_already_added)
return project_attribute_inst.first()
async def get_attributes_by_ids(self, attribute_ids: list[int]) -> list[Attribute]:
stmt = (
select(Attribute)
.where(Attribute.id.in_(attribute_ids))
)
attributes = (await self.session.scalars(stmt)).all()
return list(attributes)

View File

@@ -30,6 +30,7 @@ app.add_middleware(
) )
routers_list = [ routers_list = [
routers.attribute_router,
routers.auth_router, routers.auth_router,
routers.card_router, routers.card_router,
routers.card_group_router, routers.card_group_router,

View File

@@ -37,13 +37,15 @@ class Attribute(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(nullable=False) label: Mapped[str] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(nullable=False, unique=True) name: Mapped[str] = mapped_column(nullable=False, index=True)
is_applicable_to_group: Mapped[bool] = mapped_column( is_applicable_to_group: Mapped[bool] = mapped_column(
default=False, default=False,
comment='Применять ли изменения атрибута карточки ко всем карточкам в группе', comment='Применять ли изменения атрибута карточки ко всем карточкам в группе',
) )
is_nullable: Mapped[bool] = mapped_column(default=False, nullable=False) is_nullable: Mapped[bool] = mapped_column(default=False, nullable=False)
default_value: Mapped[bytes] = mapped_column(nullable=True) default_value: Mapped[bytes] = mapped_column(nullable=True)
is_deleted: Mapped[bool] = mapped_column(default=False)
description: Mapped[str] = mapped_column(default="", nullable=False)
projects: Mapped[list['Project']] = relationship( projects: Mapped[list['Project']] = relationship(
'Project', 'Project',

View File

@@ -22,6 +22,7 @@ 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, nullable=False) key: Mapped[str] = mapped_column(unique=True, nullable=False)
label: Mapped[str] = mapped_column(nullable=False)
is_deleted: Mapped[bool] = mapped_column(default=False) is_deleted: Mapped[bool] = mapped_column(default=False)
projects: Mapped[list['Project']] = relationship( projects: Mapped[list['Project']] = relationship(

View File

@@ -1,3 +1,4 @@
from .attribute import attribute_router
from .auth import auth_router from .auth import auth_router
from .card import card_router from .card import card_router
from .group import card_group_router from .group import card_group_router

77
routers/attribute.py Normal file
View File

@@ -0,0 +1,77 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.session import get_session
from schemas.attribute import *
from services.attribute import AttributeService
from services.auth import authorized_user
attribute_router = APIRouter(
prefix='/attribute',
tags=['attribute'],
)
@attribute_router.get(
'/',
response_model=GetAttributesResponse,
operation_id='get_all',
dependencies=[Depends(authorized_user)]
)
async def get_all(
session: Annotated[AsyncSession, Depends(get_session)]
):
return await AttributeService(session).get_all()
@attribute_router.get(
'/types',
response_model=GetAttributeTypesResponse,
operation_id='get_types',
dependencies=[Depends(authorized_user)]
)
async def get_types(
session: Annotated[AsyncSession, Depends(get_session)]
):
return await AttributeService(session).get_types()
@attribute_router.post(
'/',
response_model=CreateAttributeResponse,
operation_id='create',
dependencies=[Depends(authorized_user)]
)
async def create(
request: CreateAttributeRequest,
session: Annotated[AsyncSession, Depends(get_session)]
):
return await AttributeService(session).create(request)
@attribute_router.patch(
'/',
response_model=UpdateAttributeResponse,
operation_id='update',
dependencies=[Depends(authorized_user)]
)
async def update(
request: UpdateAttributeRequest,
session: Annotated[AsyncSession, Depends(get_session)]
):
return await AttributeService(session).update(request)
@attribute_router.delete(
'/{attribute_id}',
response_model=DeleteAttributeResponse,
operation_id='delete',
dependencies=[Depends(authorized_user)]
)
async def delete(
session: Annotated[AsyncSession, Depends(get_session)],
attribute_id: int,
):
return await AttributeService(session).delete(attribute_id)

View File

@@ -63,3 +63,38 @@ async def delete_project(
project_id: int, project_id: int,
): ):
return await ProjectService(session).delete_project(project_id) return await ProjectService(session).delete_project(project_id)
@project_router.get(
"/modules",
response_model=GetAllModulesResponse,
operation_id="get_all_modules",
)
async def get_all_modules(
session: Annotated[AsyncSession, Depends(get_session)],
):
return await ProjectService(session).get_all_modules()
@project_router.post(
"/modules",
response_model=UpdateModulesResponse,
operation_id="update_project_modules",
)
async def update_project_modules(
session: Annotated[AsyncSession, Depends(get_session)],
request: UpdateModulesRequest,
):
return await ProjectService(session).update_project_modules(request)
@project_router.post(
"/attributes",
response_model=UpdateAttributesResponse,
operation_id="update_project_attributes",
)
async def update_project_attributes(
session: Annotated[AsyncSession, Depends(get_session)],
request: UpdateAttributesRequest,
):
return await ProjectService(session).update_project_attributes(request)

View File

@@ -4,7 +4,7 @@ from typing import Optional
from pydantic import field_validator from pydantic import field_validator
from schemas.base import BaseSchema from schemas.base import BaseSchema, OkMessageSchema
# region Entities # region Entities
@@ -16,17 +16,25 @@ class AttributeTypeSchema(BaseSchema):
is_deleted: bool is_deleted: bool
class AttributeSchema(BaseSchema): class BaseAttributeSchema(BaseSchema):
id: int
label: str label: str
name: str name: str
is_applicable_to_group: bool is_applicable_to_group: bool
is_nullable: bool is_nullable: bool
default_value: Optional[bool | int | float | str | date | datetime] default_value: Optional[bool | int | float | str | date | datetime]
type_id: int
description: str
class AttributeSchema(BaseAttributeSchema):
id: int
type: AttributeTypeSchema type: AttributeTypeSchema
is_deleted: bool
@field_validator("default_value", mode="before") @field_validator("default_value", mode="before")
def validate_default_value(cls, value: Optional[bytes]): def validate_default_value(cls, value: Optional[bytes]):
if not isinstance(value, bytes):
return value
return pickle.loads(value) if value else None return pickle.loads(value) if value else None
@@ -41,3 +49,37 @@ class CardAttributeSchema(BaseSchema):
# endregion # endregion
# region Requests
class CreateAttributeRequest(BaseSchema):
attribute: BaseAttributeSchema
class UpdateAttributeRequest(BaseSchema):
attribute: AttributeSchema
# endregion
# region Responses
class GetAttributesResponse(BaseSchema):
attributes: list[AttributeSchema]
class GetAttributeTypesResponse(BaseSchema):
types: list[AttributeTypeSchema]
class CreateAttributeResponse(OkMessageSchema):
pass
class UpdateAttributeResponse(OkMessageSchema):
pass
class DeleteAttributeResponse(OkMessageSchema):
pass
# endregion

View File

@@ -116,6 +116,7 @@ class CardGeneralInfoSchema(BaseSchemaWithAttributes):
board_id: int board_id: int
status_id: int status_id: int
is_services_profit_accounted: bool is_services_profit_accounted: bool
client_id: Optional[int]
class OptionalShippingWarehouseSchema(BaseShippingWarehouseSchema): class OptionalShippingWarehouseSchema(BaseShippingWarehouseSchema):

View File

@@ -1,11 +0,0 @@
from schemas.base import BaseSchema
# region Entities
class ModuleSchema(BaseSchema):
id: int
key: str
is_deleted: bool
# endregion

View File

@@ -1,6 +1,5 @@
from schemas.attribute import AttributeSchema from schemas.attribute import AttributeSchema
from schemas.base import BaseSchema, OkMessageSchema from schemas.base import BaseSchema, OkMessageSchema
from schemas.module import ModuleSchema
# region Entities # region Entities
@@ -10,6 +9,13 @@ class BaseProjectSchema(BaseSchema):
name: str name: str
class ModuleSchema(BaseSchema):
id: int
key: str
label: str
is_deleted: bool
class ProjectSchema(BaseProjectSchema): class ProjectSchema(BaseProjectSchema):
id: int id: int
attributes: list[AttributeSchema] attributes: list[AttributeSchema]
@@ -32,6 +38,16 @@ class UpdateProjectRequest(BaseSchema):
project: ProjectSchema project: ProjectSchema
class UpdateModulesRequest(BaseSchema):
project_id: int
module_ids: list[int]
class UpdateAttributesRequest(BaseSchema):
project_id: int
attribute_ids: list[int]
# endregion # endregion
@@ -52,4 +68,16 @@ class UpdateProjectResponse(OkMessageSchema):
class DeleteProjectResponse(OkMessageSchema): class DeleteProjectResponse(OkMessageSchema):
pass pass
class GetAllModulesResponse(BaseSchema):
modules: list[ModuleSchema]
class UpdateModulesResponse(OkMessageSchema):
pass
class UpdateAttributesResponse(OkMessageSchema):
pass
# endregion # endregion

85
services/attribute.py Normal file
View File

@@ -0,0 +1,85 @@
from sqlalchemy import select, and_
from models import Attribute, AttributeType
from schemas.attribute import *
from services.base import BaseService
class AttributeService(BaseService):
async def get_all(self) -> GetAttributesResponse:
stmt = (
select(Attribute)
.where(Attribute.is_deleted == False)
.order_by(Attribute.label)
)
attrs = (await self.session.scalars(stmt)).all()
return GetAttributesResponse(attributes=attrs)
async def get_types(self) -> GetAttributeTypesResponse:
stmt = (
select(AttributeType)
.where(AttributeType.is_deleted == False)
)
types = (await self.session.scalars(stmt)).all()
return GetAttributeTypesResponse(types=types)
async def get_attr_by_name(self, attr_name: str) -> Optional[Attribute]:
stmt = (
select(Attribute)
.where(
and_(
Attribute.name == attr_name,
Attribute.is_deleted == False,
)
)
)
attribute = (await self.session.scalars(stmt)).first()
return attribute
async def create(self, request: CreateAttributeRequest) -> CreateAttributeResponse:
existing_attr = await self.get_attr_by_name(request.attribute.name)
if existing_attr:
return CreateAttributeResponse(ok=False, message="Атрибут с данным уникальным ключом уже существует")
default_value = pickle.dumps(request.attribute.default_value)
values = request.attribute.model_dump()
del values["default_value"]
attribute = Attribute(
**values,
default_value=default_value,
)
self.session.add(attribute)
await self.session.commit()
return CreateAttributeResponse(ok=True, message="Атрибут успешно создан")
async def update(self, request: UpdateAttributeRequest) -> UpdateAttributeResponse:
attribute = await self.session.get(Attribute, request.attribute.id)
if not attribute:
return UpdateAttributeResponse(ok=False, message=f"Атрибут с ID {request.attribute.id} не найден")
if attribute.name != request.attribute.name:
attr_with_same_name = await self.get_attr_by_name(request.attribute.name)
if attr_with_same_name:
return CreateAttributeResponse(ok=False, message="Атрибут с данным уникальным ключом уже существует")
default_value = pickle.dumps(request.attribute.default_value) if request.attribute.default_value else None
attribute.name = request.attribute.name
attribute.label = request.attribute.label
attribute.default_value = default_value
attribute.is_applicable_to_group = request.attribute.is_applicable_to_group
attribute.is_nullable = request.attribute.is_nullable
attribute.description = request.attribute.description
await self.session.commit()
return UpdateAttributeResponse(ok=True, message="Атрибут успешно обновлен")
async def delete(self, attribute_id: int) -> DeleteAttributeResponse:
attribute: Optional[Attribute] = await self.session.get(Attribute, attribute_id)
if not attribute:
return DeleteAttributeResponse(ok=False, message=f"Атрибут с ID {attribute_id} не найден")
attribute.is_deleted = True
await self.session.commit()
return DeleteAttributeResponse(ok=True, message=f"Атрибут успешно удален")

View File

@@ -356,6 +356,7 @@ class CardsService(BaseService):
card.comment = request.data.comment card.comment = request.data.comment
card.is_deleted = request.data.is_deleted card.is_deleted = request.data.is_deleted
card.is_completed = request.data.is_completed card.is_completed = request.data.is_completed
card.client_id = request.data.client_id
if card.board_id != request.data.board_id or card.current_status_id != request.data.status_id: if card.board_id != request.data.board_id or card.current_status_id != request.data.status_id:
if card.group: if card.group:

View File

@@ -1,9 +1,11 @@
from datetime import datetime from datetime import datetime
from typing import Optional
from sqlalchemy import select, update, func, delete from sqlalchemy import select, update, func, delete
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models import Project, Board from card_attributes import CardAttributesCommandHandler
from models import Project, Board, Module, Attribute
from schemas.project import * from schemas.project import *
from services.base import BaseService from services.base import BaseService
@@ -28,6 +30,7 @@ class ProjectService(BaseService):
selectinload(Project.attributes), selectinload(Project.attributes),
selectinload(Project.modules), selectinload(Project.modules),
) )
.order_by(Project.id)
) )
project_data = (await self.session.execute(stmt)).all() project_data = (await self.session.execute(stmt)).all()
@@ -81,3 +84,37 @@ class ProjectService(BaseService):
await self.session.execute(stmt) await self.session.execute(stmt)
await self.session.commit() await self.session.commit()
return DeleteProjectResponse(ok=True, message="Проект успешно удален") return DeleteProjectResponse(ok=True, message="Проект успешно удален")
async def get_all_modules(self) -> GetAllModulesResponse:
stmt = (
select(Module)
.where(Module.is_deleted == False)
)
modules = await self.session.scalars(stmt)
return GetAllModulesResponse(modules=modules.all())
async def update_project_modules(self, request: UpdateModulesRequest) -> UpdateModulesResponse:
project: Optional[Project] = await self.session.get(Project, request.project_id)
if not project:
return UpdateModulesResponse(ok=False, message=f"Проект с ID {request.project_id} не найден")
modules_stmt = (
select(Module)
.where(Module.id.in_(request.module_ids))
)
modules = (await self.session.scalars(modules_stmt)).all()
project.modules = modules
await self.session.commit()
return UpdateModulesResponse(ok=True, message="Модули успешно обновлены")
async def update_project_attributes(self, request: UpdateAttributesRequest) -> UpdateAttributesResponse:
project: Optional[Project] = await self.session.get(Project, request.project_id)
if not project:
return UpdateAttributesResponse(ok=False, message=f"Проект с ID {request.project_id} не найден")
card_attrs_handler = CardAttributesCommandHandler(self.session)
await card_attrs_handler.set_project_attributes(project, request.attribute_ids)
return UpdateAttributesResponse(ok=True, message="Атрибуты успешно обновлены")

View File

@@ -178,7 +178,7 @@ class ResiduesService(BaseService):
pallet = ResidualPallet(client_id=client_id, created_at=datetime.now()) pallet = ResidualPallet(client_id=client_id, created_at=datetime.now())
self.session.add(pallet) self.session.add(pallet)
await self.session.flush() await self.session.flush()
await self._load_receipt_boxes(receipt_pallet.boxes, pallet.id) await self._load_receipt_boxes(receipt_pallet.boxes, None, pallet.id)
await self._load_receipt_products(receipt_pallet.products, pallet_id=pallet.id) await self._load_receipt_products(receipt_pallet.products, pallet_id=pallet.id)
async def _load_receipt_products( async def _load_receipt_products(