Compare commits

..

77 Commits

Author SHA1 Message Date
eab801e41b feat: lexoranks for attr options 2025-11-05 20:52:50 +04:00
7defcbdbd4 fix: removed celery tasks tables 2025-11-04 15:05:48 +04:00
ee18f16250 fix: fixed deleted attr selects showing 2025-11-04 12:48:25 +04:00
c266814c96 feat: attr options and selects editing 2025-11-04 12:18:53 +04:00
a7bda3d9f6 fix: default option in attributes 2025-11-03 10:43:10 +04:00
be878717e5 fix: fixed attribute removing from module 2025-11-02 12:29:18 +04:00
2700538945 fix: fixed attribute creating 2025-11-01 23:18:11 +04:00
80a74ac8e6 feat: get deal barcodes pdf 2025-11-01 14:25:22 +04:00
ef657c4939 refactor: cleaned main file 2025-10-31 11:29:24 +04:00
36b3e056dc feat: blank taskiq setup 2025-10-30 17:06:30 +04:00
307e6573e3 feat: printing uploaded product barcode images 2025-10-30 15:41:32 +04:00
82fcd6e8cb feat: deal attributes with select and options 2025-10-29 19:37:27 +04:00
0e8c9077c9 feat: setting default attributes after deal creating 2025-10-28 17:20:48 +04:00
9b109a7270 fix: applied timezone to default values, removed value nesting 2025-10-28 11:43:42 +04:00
c1196497d4 fix: removed attr is_shown_on_dashboard 2025-10-27 17:32:02 +04:00
759a8d6478 feat: deal attributes editing 2025-10-27 10:02:02 +04:00
a579ae4145 fix: fixed module updating 2025-10-25 18:04:58 +04:00
fcaa7fe177 feat: modules creation 2025-10-25 18:00:05 +04:00
281600c72d feat: modules and module-editor pages 2025-10-25 12:11:48 +04:00
62aeebf079 refactor: renamed built_in_modules into modules 2025-10-21 12:35:22 +04:00
83f3b55f49 feat: product barcode images 2025-10-21 11:10:08 +04:00
90c0bae8f1 feat: images uploader, endpoint for product images uploading 2025-10-20 16:09:29 +04:00
34ac2a0a69 fix: using uv 2025-10-19 21:44:02 +04:00
79a1dff720 refactor: refactored Board model 2025-10-19 12:55:29 +04:00
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
d7c7d1775f feat: routers client and version prefixes 2025-10-17 21:40:54 +04:00
6b1b4109c6 feat: groups 2025-10-17 19:48:51 +04:00
35869e2ea5 feat: marketplaces endpoints 2025-10-13 12:48:06 +04:00
d8eba188c9 feat: products quantity and total price in deal schemas 2025-10-11 16:22:38 +04:00
636821e74a feat: statuses colors 2025-10-11 12:15:17 +04:00
fbb0c72bce refactor: removed nullable in models 2025-10-11 10:34:05 +04:00
bd4f4138be fix: fixed get deal product by id 2025-10-10 23:01:10 +04:00
4c871e1e1b feat: barcode printing 2025-10-10 23:00:39 +04:00
6b0f8a1aa5 feat: product endpoints changes for products table 2025-10-08 22:30:43 +04:00
7d6155ff6c feat: client endpoints fixes for client tab in deal editor 2025-10-05 12:05:03 +04:00
986712d5b7 feat: client endpoints for clients page 2025-10-04 18:12:13 +04:00
66b50fb951 feat: barcode templates 2025-10-04 10:13:24 +04:00
9c9b3f4706 fix: service schemas fixed 2025-10-03 09:06:01 +04:00
c2594f9d55 fix: service update fixed 2025-09-28 12:45:19 +04:00
fbadddeada feat: service categories endpoints and service creation endpoint 2025-09-27 18:21:20 +04:00
8cf589c54e fix: services kit create and update fix 2025-09-25 09:43:03 +04:00
22b8428035 feat: a few tabs for module 2025-09-21 09:48:55 +04:00
6b3d124adf feat: deal status history table 2025-09-20 10:07:56 +04:00
44f315b4a0 fix: moved add_services_kit repo method 2025-09-19 18:20:32 +04:00
1df57c69c1 fix: cascade delete for deal products and product services 2025-09-19 17:11:34 +04:00
7eeb24f8ff fix: default values for product 2025-09-18 20:12:31 +04:00
8794241541 feat: module dependencies 2025-09-18 17:54:30 +04:00
1a9dbd857a feat: adding services kit to deal 2025-09-16 18:13:19 +04:00
98d3026e0d refactor: entity not found exceptions handler 2025-09-16 16:56:10 +04:00
276626d6f7 feat: modules, products, services, services kits 2025-09-16 10:54:10 +04:00
be8052848c refactor: crud mixins for repositories and services 2025-09-08 18:00:34 +04:00
d73748deab refactor: mixins for services 2025-09-08 10:59:06 +04:00
67634836dc refactor: update repository mixin 2025-09-07 21:28:06 +04:00
7a76da4058 fix: project_id in board schema and fixed get mixin 2025-09-06 11:28:48 +04:00
7990e7d460 refactor: repository get all mixin 2025-09-05 11:13:49 +04:00
c1d3ac98f0 refactor: repository get by id mixin 2025-09-05 09:53:16 +04:00
e5be35be35 refactor: repository create mixin 2025-09-05 00:04:09 +04:00
c632fb8037 refactor: repository delete mixin 2025-09-04 20:53:44 +04:00
fbab70d6c1 fix: only soft delete for deals 2025-09-02 18:02:11 +04:00
404a58735d feat: deal's status and board update 2025-09-02 14:42:11 +04:00
de5ffed7de Revert "feat: status and board in deal schema"
This reverts commit b9ae3bc18a.
2025-09-01 20:39:20 +04:00
b9ae3bc18a feat: status and board in deal schema 2025-09-01 20:33:40 +04:00
57c3ada2fa feat: deals filters 2025-09-01 17:54:45 +04:00
93141da22c feat: common style for getters 2025-08-29 23:51:38 +04:00
5fbd6d6185 feat: pagination and query params for a deal end-point 2025-08-28 20:24:24 +04:00
4c7a997be6 feat: deal status history and default created_at in db 2025-08-26 18:12:28 +04:00
b776ad6758 feat: datetimes with timezones 2025-08-24 14:53:50 +04:00
b4b29d448b refactor: enabled importing all from module in ruff 2025-08-24 12:58:46 +04:00
dd1f4145ae feat: deals create, update, delete 2025-08-24 12:51:16 +04:00
c862544ae0 feat: common style for crud endpoints 2025-08-23 10:37:42 +04:00
c5e4dea52c feat: ordering for getters 2025-08-22 21:58:39 +04:00
5e20da8356 feat: projects create, update, delete 2025-08-13 15:01:22 +04:00
71c0901909 fix: status creating fix 2025-08-08 11:28:56 +04:00
3b1b6f0523 feat: create and delete status endpoints 2025-08-07 15:47:07 +04:00
2fed828768 feat: board deletion endpoint 2025-08-07 10:12:54 +04:00
734099165b feat: board creation endpoint 2025-08-07 09:18:23 +04:00
166 changed files with 8257 additions and 884 deletions

View File

@ -5,3 +5,14 @@ PG_DATABASE=
PG_HOST= PG_HOST=
SECRET_KEY= SECRET_KEY=
S3_URL=
S3_ACCESS_KEY=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_BUCKET=
REDIS_PASSWORD=
REDIS_HOST=
REDIS_DB=
REDIS_URL=

Binary file not shown.

BIN
assets/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@ -12,3 +12,11 @@ PG_DATABASE = os.environ.get("PG_DATABASE")
PG_HOST = os.environ.get("PG_HOST") PG_HOST = os.environ.get("PG_HOST")
SECRET_KEY = os.environ.get("SECRET_KEY") SECRET_KEY = os.environ.get("SECRET_KEY")
S3_URL = os.environ.get("S3_URL")
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY")
S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY")
S3_REGION = os.environ.get("S3_REGION")
S3_BUCKET = os.environ.get("S3_BUCKET")
RABBITMQ_URL = os.environ.get("RABBITMQ_URL")

View File

@ -4,8 +4,10 @@ from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.session import get_session from backend.session import get_session
from schemas.base import PaginationSchema from schemas.base import PaginationSchema, SortingSchema
from utils.pagination import pagination_parameters from utils.pagination import pagination_parameters
from utils.sorting import sorting_parameters
SessionDependency = Annotated[AsyncSession, Depends(get_session)] SessionDependency = Annotated[AsyncSession, Depends(get_session)]
PaginationDependency = Annotated[PaginationSchema, Depends(pagination_parameters)] PaginationDependency = Annotated[PaginationSchema, Depends(pagination_parameters)]
SortingDependency = Annotated[SortingSchema, Depends(sorting_parameters)]

4
core/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .lifespan import lifespan as lifespan
from .app_settings import settings as settings
from .middlewares import register_middlewares as register_middlewares
from .exceptions import register_exception_handlers as register_exception_handlers

11
core/app_settings.py Normal file
View File

@ -0,0 +1,11 @@
from fastapi.responses import ORJSONResponse
class Settings:
ROOT_PATH = "/api"
DEFAULT_RESPONSE_CLASS = ORJSONResponse
ORIGINS = ["http://localhost:3000"]
STATIC_DIR = "static"
settings = Settings()

17
core/exceptions.py Normal file
View File

@ -0,0 +1,17 @@
from fastapi import Request
from fastapi.applications import AppType
from starlette.responses import JSONResponse
from utils.exceptions import ObjectNotFoundException, ForbiddenException
def register_exception_handlers(app: AppType):
@app.exception_handler(ObjectNotFoundException)
async def not_found_exception_handler(
request: Request, exc: ObjectNotFoundException
):
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})

16
core/lifespan.py Normal file
View File

@ -0,0 +1,16 @@
import contextlib
from fastapi.applications import AppType
from task_management import broker
@contextlib.asynccontextmanager
async def lifespan(app: AppType):
if not broker.is_worker_process:
await broker.startup()
yield
if not broker.is_worker_process:
await broker.shutdown()

19
core/middlewares.py Normal file
View File

@ -0,0 +1,19 @@
from fastapi.applications import AppType
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from .app_settings import settings
def register_middlewares(app: AppType):
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(
GZipMiddleware,
minimum_size=1_000,
)

0
external/__init__.py vendored Normal file
View File

1
external/s3_uploader/__init__.py vendored Normal file
View File

@ -0,0 +1 @@
from .uploader import S3Uploader as S3Uploader

81
external/s3_uploader/uploader.py vendored Normal file
View File

@ -0,0 +1,81 @@
import uuid
from typing import Optional
from aioboto3 import Session
from fastapi import UploadFile
from backend import config
from logger import logger_builder
class S3Uploader:
session: Session
def __init__(self):
self.session = Session()
def _get_client(self) -> int:
return self.session.client(
"s3",
endpoint_url=config.S3_URL,
aws_access_key_id=config.S3_ACCESS_KEY,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION,
)
@staticmethod
async def _generate_s3_key() -> str:
return uuid.uuid4().hex
@staticmethod
def get_file_path_from_name(filename: str) -> str:
return f"{config.S3_URL}/{config.S3_BUCKET}/{filename}"
async def upload_from_bytes(
self,
image_bytes: bytes,
content_type: str,
extension: str,
unique_s3_key: Optional[str] = None,
) -> str:
logger = logger_builder.get_logger()
if unique_s3_key is None:
unique_s3_key = await self._generate_s3_key()
filename = unique_s3_key + "." + extension
file_url = self.get_file_path_from_name(filename)
try:
async with self._get_client() as s3_client:
await s3_client.put_object(
Bucket=config.S3_BUCKET,
Key=filename,
Body=image_bytes,
ContentType=content_type,
)
logger.info(f"Successfully uploaded {filename} to S3")
return file_url
except Exception as e:
logger.error(f"Error uploading image bytes: {e}")
raise
async def upload_from_upload_file_obj(self, upload_file: UploadFile) -> str:
file_bytes = upload_file.file.read()
extension = upload_file.filename.split(".")[-1]
file_url = await self.upload_from_bytes(
file_bytes, upload_file.content_type, extension
)
return file_url
async def delete_image(self, s3_key: str):
logger = logger_builder.get_logger()
try:
async with self._get_client() as s3_client:
await s3_client.delete_object(Bucket=config.S3_BUCKET, Key=s3_key)
logger.info(f"Successfully deleted {s3_key} from S3")
except Exception as e:
logger.error(f"Error deleting image from S3: {e}")

View File

@ -10,7 +10,7 @@ from logger.constants import (
from logger.formatter import JsonFormatter from logger.formatter import JsonFormatter
from logger.gunzip_rotating_file_handler import GunZipRotatingFileHandler from logger.gunzip_rotating_file_handler import GunZipRotatingFileHandler
from logger.filters import LevelFilter, RequestIdFilter from logger.filters import LevelFilter, RequestIdFilter
from core.singleton import Singleton from utils.singleton import Singleton
class LoggerBuilder(metaclass=Singleton): class LoggerBuilder(metaclass=Singleton):

42
main.py
View File

@ -1,32 +1,28 @@
import taskiq_fastapi
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import ORJSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from fastapi_endpoints import auto_include_routers
import routers import routers
from core import lifespan, settings, register_middlewares, register_exception_handlers
from task_management import broker
from utils.auto_include_routers import auto_include_routers
origins = ["http://localhost:3000"]
app = FastAPI( def create_app() -> FastAPI:
separate_input_output_schemas=True, app = FastAPI(
default_response_class=ORJSONResponse, separate_input_output_schemas=True,
root_path="/api" default_response_class=settings.DEFAULT_RESPONSE_CLASS,
) root_path=settings.ROOT_PATH,
# lifespan=lifespan,
)
app.add_middleware( register_middlewares(app)
CORSMiddleware, register_exception_handlers(app)
allow_origins=origins, auto_include_routers(app, routers, full_path=True)
allow_credentials=True, app.mount("/static", StaticFiles(directory=settings.STATIC_DIR), name="static")
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(
GZipMiddleware,
minimum_size=1_000,
)
auto_include_routers(app, routers) taskiq_fastapi.init(broker, "main:app")
return app
app.mount("/static", StaticFiles(directory="static"), name="static")
app = create_app()

View File

@ -1,9 +1,34 @@
from sqlalchemy.orm import configure_mappers from sqlalchemy.orm import configure_mappers
from modules.fulfillment_base.models import * # noqa: F401
from .attr_select import (
AttributeOption as AttributeOption,
AttributeSelect as AttributeSelect,
)
from .attribute import (
AttributeType as AttributeType,
Attribute as Attribute,
AttributeValue as AttributeValue,
AttributeLabel as AttributeLabel,
module_attribute as module_attribute,
)
from .auth import User as User
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 .deal import Deal as Deal 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 .module import ( # noqa: F401
Module as Module,
ModuleTab as ModuleTab,
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 from .status import Status as Status, DealStatusHistory as DealStatusHistory
configure_mappers() configure_mappers()

43
models/attr_select.py Normal file
View File

@ -0,0 +1,43 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
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 Attribute
class AttributeSelect(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attribute_selects"
name: Mapped[str] = mapped_column()
is_built_in: Mapped[bool] = mapped_column(
default=False,
comment="Если встроенный select, то запрещено редактировать пользователю",
)
options: Mapped[list["AttributeOption"]] = relationship(
back_populates="select",
lazy="noload",
)
attributes: Mapped[list["Attribute"]] = relationship(
back_populates="select",
lazy="noload",
)
class AttributeOption(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attribute_options"
name: Mapped[str] = mapped_column()
lexorank: Mapped[str] = mapped_column(comment="Ранг опции")
select_id: Mapped[int] = mapped_column(ForeignKey("attribute_selects.id"))
select: Mapped[AttributeSelect] = relationship(
back_populates="options",
lazy="noload",
)

139
models/attribute.py Normal file
View 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, AttributeSelect, AttributeOption
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_nullable: Mapped[bool] = mapped_column(default=False)
default_value: Mapped[Optional[dict[str, any]]] = mapped_column(JSONB)
default_option_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("attribute_options.id")
)
default_option: Mapped[Optional["AttributeOption"]] = relationship(
backref="attributes", lazy="joined"
)
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",
)
select_id: Mapped[Optional[int]] = mapped_column(ForeignKey("attribute_selects.id"))
select: Mapped[Optional["AttributeSelect"]] = 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):
__tablename__ = "attribute_values"
id: Mapped[int] = mapped_column(
primary_key=True,
autoincrement=True,
)
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="noload",
)
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="noload",
)

6
models/auth.py Normal file
View File

@ -0,0 +1,6 @@
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
class User(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "users"

View File

@ -13,10 +13,10 @@ if TYPE_CHECKING:
class Board(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): class Board(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
__tablename__ = "boards" __tablename__ = "boards"
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column()
lexorank: Mapped[str] = mapped_column(nullable=False) lexorank: Mapped[str] = mapped_column()
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False) project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
project: Mapped["Project"] = relationship(back_populates="boards") project: Mapped["Project"] = relationship(back_populates="boards")
statuses: Mapped[list["Status"]] = relationship(back_populates="board") statuses: Mapped[list["Status"]] = relationship(back_populates="board")

View File

@ -1,29 +1,69 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship, backref
from models.base import BaseModel 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 from models import (
Status,
Board,
DealStatusHistory,
DealGroup,
DealTag,
AttributeValue,
)
from modules.clients.models import Client
class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
__tablename__ = "deals" __tablename__ = "deals"
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column()
lexorank: Mapped[str] = mapped_column(nullable=False) lexorank: Mapped[str] = mapped_column()
status_id: Mapped[int] = mapped_column( status_id: Mapped[int] = mapped_column(
ForeignKey("statuses.id"), ForeignKey("statuses.id"),
nullable=False,
comment="Текущий статус", comment="Текущий статус",
) )
status: Mapped["Status"] = relationship(lazy="noload") status: Mapped["Status"] = relationship()
board_id: Mapped[int] = mapped_column( board_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("boards.id"), nullable=True, server_default="1" ForeignKey("boards.id"), server_default="1"
)
board: Mapped[Optional["Board"]] = relationship(back_populates="deals")
status_history: Mapped[list["DealStatusHistory"]] = relationship(
back_populates="deal",
cascade="all, delete-orphan",
lazy="noload",
)
group_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("deal_groups.id"), default=None, server_default=None
)
group: Mapped[Optional["DealGroup"]] = relationship(
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)",
)
attributes_values: Mapped[list["AttributeValue"]] = relationship(
back_populates="deal",
)
# module client
client_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("clients.id", ondelete="CASCADE"),
)
client: Mapped["Client"] = relationship(
backref=backref("deals", cascade="all, delete-orphan"), lazy="immediate"
) )
board: Mapped["Board"] = relationship(back_populates="deals")

17
models/deal_group.py Normal file
View File

@ -0,0 +1,17 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy.orm import mapped_column, Mapped, relationship
from models.base import BaseModel
from models.mixins import IdMixin
if TYPE_CHECKING:
from models import Deal
class DealGroup(BaseModel, IdMixin):
__tablename__ = "deal_groups"
name: Mapped[Optional[str]] = mapped_column()
lexorank: Mapped[str] = mapped_column()
deals: Mapped[list["Deal"]] = relationship(back_populates="group", lazy="noload")

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

@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy import DateTime, Numeric
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -8,11 +9,26 @@ class IdMixin:
class SoftDeleteMixin: class SoftDeleteMixin:
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(default=False)
default=False,
nullable=False,
)
class CreatedAtMixin: class CreatedAtMixin:
created_at: Mapped[datetime] = mapped_column(nullable=False) created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
class LastModifiedAtMixin:
last_modified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
class PriceMixin:
price: Mapped[float] = mapped_column(Numeric(12, 2), comment="Стоимость")
class CostMixin:
cost: Mapped[float] = mapped_column(Numeric(12, 2), comment="Себестоимость")

89
models/module.py Normal file
View File

@ -0,0 +1,89 @@
import enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Table, Column, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models import AttributeValue, Attribute
from models.base import BaseModel
if TYPE_CHECKING:
from models import Project
project_module = Table(
"project_module",
BaseModel.metadata,
Column("project_id", ForeignKey("projects.id"), primary_key=True),
Column("module_id", ForeignKey("modules.id"), primary_key=True),
)
module_dependencies = Table(
"module_dependencies",
BaseModel.metadata,
Column("module_id", ForeignKey("modules.id"), primary_key=True),
Column("depends_on_id", ForeignKey("modules.id"), primary_key=True),
)
class Module(BaseModel):
__tablename__ = "modules"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(unique=True)
label: Mapped[str] = mapped_column()
description: Mapped[Optional[str]] = mapped_column()
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(
secondary=module_dependencies,
primaryjoin="Module.id == module_dependencies.c.module_id",
secondaryjoin="Module.id == module_dependencies.c.depends_on_id",
back_populates="depended_on_by",
lazy="immediate",
)
depended_on_by: Mapped[list["Module"]] = relationship(
secondary="module_dependencies",
primaryjoin="Module.id == module_dependencies.c.depends_on_id",
secondaryjoin="Module.id == module_dependencies.c.module_id",
back_populates="depends_on",
lazy="noload",
)
projects: Mapped[list["Project"]] = relationship(
uselist=True,
secondary="project_module",
back_populates="modules",
lazy="noload",
)
tabs: Mapped[list["ModuleTab"]] = relationship(
lazy="immediate", backref="module", cascade="all, delete-orphan"
)
attributes: Mapped[list["Attribute"]] = relationship(
secondary="module_attribute", back_populates="modules", lazy="noload"
)
attribute_values: Mapped[list["AttributeValue"]] = relationship(
lazy="noload", back_populates="module"
)
class DeviceType(enum.StrEnum):
MOBILE = "mobile"
DESKTOP = "desktop"
BOTH = "both"
class ModuleTab(BaseModel):
__tablename__ = "module_tab"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(unique=True)
label: Mapped[str] = mapped_column()
icon_name: Mapped[Optional[str]] = mapped_column()
module_id: Mapped[int] = mapped_column(ForeignKey("modules.id"))
device: Mapped[DeviceType] = mapped_column(default=DeviceType.BOTH)

View File

@ -6,15 +6,29 @@ 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 from models import Board, Module, DealTag
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
__tablename__ = "projects" __tablename__ = "projects"
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column()
boards: Mapped[list["Board"]] = relationship( boards: Mapped[list["Board"]] = relationship(
back_populates="project", back_populates="project",
lazy="noload", lazy="noload",
) )
modules: Mapped[list["Module"]] = relationship(
secondary="project_module",
back_populates="projects",
lazy="selectin",
order_by="asc(Module.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

@ -4,17 +4,43 @@ from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel from models.base import BaseModel
from models.mixins import SoftDeleteMixin, IdMixin from models.mixins import SoftDeleteMixin, IdMixin, CreatedAtMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from models import Board from models import Board, Deal
class Status(BaseModel, IdMixin, SoftDeleteMixin): class Status(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "statuses" __tablename__ = "statuses"
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column()
lexorank: Mapped[str] = mapped_column(nullable=False) lexorank: Mapped[str] = mapped_column()
color: Mapped[str] = mapped_column()
board_id: Mapped[int] = mapped_column(ForeignKey("boards.id"), nullable=False) board_id: Mapped[int] = mapped_column(ForeignKey("boards.id"))
board: Mapped["Board"] = relationship(back_populates="statuses") board: Mapped["Board"] = relationship(back_populates="statuses")
class DealStatusHistory(BaseModel, IdMixin, CreatedAtMixin):
__tablename__ = "status_history"
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"))
deal: Mapped["Deal"] = relationship(back_populates="status_history")
from_status_id: Mapped[int] = mapped_column(
ForeignKey("statuses.id"),
comment="Старый статус",
)
from_status: Mapped[Status] = relationship(
foreign_keys=[from_status_id],
lazy="joined",
)
to_status_id: Mapped[int] = mapped_column(
ForeignKey("statuses.id"),
comment="Новый статус",
)
to_status: Mapped[Status] = relationship(
foreign_keys=[to_status_id],
lazy="joined",
)

0
modules/__init__.py Normal file
View File

View File

View File

@ -0,0 +1 @@
from .client import Client as Client, ClientDetails as ClientDetails

View File

@ -0,0 +1,47 @@
from typing import Optional, TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column
from models.base import BaseModel
from models.mixins import IdMixin, CreatedAtMixin, SoftDeleteMixin, LastModifiedAtMixin
if TYPE_CHECKING:
from models import Product
class Client(BaseModel, IdMixin, CreatedAtMixin, SoftDeleteMixin):
__tablename__ = "clients"
name: Mapped[str] = mapped_column(unique=True, comment="Название клиента")
company_name: Mapped[str] = mapped_column(comment="Название компании")
products: Mapped[list["Product"]] = relationship(
back_populates="client", lazy="noload"
)
details: Mapped["ClientDetails"] = relationship(
uselist=False,
back_populates="client",
cascade="all, delete",
lazy="joined",
)
comment: Mapped[Optional[str]] = mapped_column(comment="Комментарий")
class ClientDetails(BaseModel, IdMixin, LastModifiedAtMixin):
__tablename__ = "client_details"
client_id: Mapped[int] = mapped_column(
ForeignKey("clients.id"), unique=True, comment="ID клиента"
)
client: Mapped[Client] = relationship(
back_populates="details", cascade="all, delete", uselist=False
)
telegram: Mapped[Optional[str]] = mapped_column()
phone_number: Mapped[Optional[str]] = mapped_column()
inn: Mapped[Optional[str]] = mapped_column()
email: Mapped[Optional[str]] = mapped_column()

View File

@ -0,0 +1 @@
from .client import ClientRepository as ClientRepository

View File

@ -0,0 +1,51 @@
from datetime import timezone, datetime
from sqlalchemy import update
from modules.clients.models import Client, ClientDetails
from modules.clients.schemas.client import UpdateClientSchema, CreateClientSchema
from repositories.mixins import *
class ClientRepository(
BaseRepository,
RepGetAllMixin[Client],
RepDeleteMixin[Client],
RepUpdateMixin[Client, UpdateClientSchema],
RepGetByIdMixin[Client],
):
entity_class = Client
entity_not_found_msg = "Клиент не найден"
def _process_get_all_stmt_with_args(
self, stmt: Select, include_deleted: bool
) -> Select:
if not include_deleted:
stmt = stmt.where(Client.is_deleted.is_(False))
return stmt.order_by(Client.created_at)
async def create(self, data: CreateClientSchema) -> int:
details = ClientDetails(**data.details.model_dump())
data_dict = data.model_dump()
data_dict["details"] = details
client = Client(**data_dict)
self.session.add(client)
await self.session.commit()
await self.session.refresh(client)
return client.id
async def update(self, client: Client, data: UpdateClientSchema) -> Client:
if data.details is not None:
stmt = (
update(ClientDetails)
.where(ClientDetails.client_id == client.id)
.values(
**data.details.model_dump(),
last_modified_at=datetime.now(timezone.utc),
)
)
await self.session.execute(stmt)
del data.details
return await self._apply_update_data_to_model(client, data, True)

View File

@ -0,0 +1,75 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
# region Entities
class ClientDetailsSchema(BaseSchema):
telegram: str
phone_number: str
inn: str
email: str
class CreateClientSchema(BaseSchema):
name: str
company_name: str
comment: Optional[str] = ""
details: ClientDetailsSchema
class ClientSchema(CreateClientSchema):
id: int
is_deleted: bool = False
class UpdateClientSchema(BaseSchema):
name: Optional[str] = None
company_name: Optional[str] = None
comment: Optional[str] = None
details: Optional[ClientDetailsSchema] = None
# endregion
# region Requests
class ClientUpdateDetailsRequest(BaseSchema):
client_id: int
details: ClientDetailsSchema
class CreateClientRequest(BaseSchema):
entity: CreateClientSchema
class UpdateClientRequest(BaseSchema):
entity: UpdateClientSchema
# endregion
# region Responses
class GetClientsResponse(BaseSchema):
items: list[ClientSchema]
class UpdateClientResponse(BaseResponse):
pass
class CreateClientResponse(BaseResponse):
pass
class DeleteClientResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1 @@
from .client import ClientService as ClientService

View File

@ -0,0 +1,26 @@
from modules.clients.models import Client
from modules.clients.repositories import ClientRepository
from modules.clients.schemas.client import (
ClientSchema,
CreateClientRequest,
UpdateClientRequest,
)
from services.mixins import *
class ClientService(
ServiceGetAllMixin[Client, ClientSchema],
ServiceCreateMixin[Client, CreateClientRequest, ClientSchema],
ServiceUpdateMixin[Client, UpdateClientRequest],
ServiceDeleteMixin[Client],
):
schema_class = ClientSchema
entity_deleted_msg = "Клиент успешно удален"
entity_updated_msg = "Клиент успешно обновлен"
entity_created_msg = "Клиент успешно создан"
def __init__(self, session: AsyncSession):
self.repository = ClientRepository(session)
async def is_soft_delete(self, client: ClientSchema) -> bool:
return True

View File

View File

@ -0,0 +1,2 @@
from .barcode_pdf_generator import BarcodePdfGenerator as BarcodePdfGenerator
from .types import BarcodeData as BarcodeData

View File

@ -0,0 +1,170 @@
from io import BytesIO
from typing import Any, Optional
import aiohttp
from reportlab.graphics.barcode import code128
from reportlab.lib.units import mm
from reportlab.platypus import Spacer, PageBreak, Paragraph
from modules.fulfillment_base.barcodes_pdf_gen.types import *
from utils.pdf import PdfMaker, PDFGenerator
class BarcodePdfGenerator(PDFGenerator):
def _get_attr_by_path(
self, value: Any, path: str
) -> Optional[str | int | float | bool]:
keys = path.split(".")
for key in keys:
try:
if isinstance(value, dict):
value = value[key]
else:
value = getattr(value, key)
except (KeyError, AttributeError, TypeError):
return None
return value
async def generate(
self, barcodes_data: list[BarcodeData | PdfBarcodeImageGenData]
) -> BytesIO:
pdf_barcodes_gen_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData] = []
for barcode_data in barcodes_data:
if "barcode" not in barcode_data:
pdf_barcodes_gen_data.append(barcode_data)
continue
attributes = {}
for attribute in barcode_data["template"].attributes:
value = self._get_attr_by_path(barcode_data["product"], attribute.key)
if not value or not value.strip():
continue
attributes[attribute.name] = value
barcode_text = "<br/>".join(
[f"{key}: {value}" for key, value in attributes.items()]
)
pdf_barcodes_gen_data.append(
{
"barcode_value": barcode_data["barcode"],
"text": barcode_text,
"num_duplicates": barcode_data["num_duplicates"],
}
)
return await self._generate(pdf_barcodes_gen_data)
async def _generate(
self, barcodes_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData]
) -> BytesIO:
pdf_maker = PdfMaker((self.page_width, self.page_height))
pdf_files: list[BytesIO] = []
for barcode_data in barcodes_data:
if "barcode_value" in barcode_data:
result = self._generate_for_one_product(barcode_data)
else:
result = await self._generate_for_one_product_using_img(barcode_data)
pdf_files.append(result)
pdf_files.append(self._generate_spacers())
for file in pdf_files[:-1]:
pdf_maker.add_pdfs(file)
return pdf_maker.get_bytes()
def _generate_for_one_product(self, barcode_data: PdfBarcodeGenData) -> BytesIO:
buffer = BytesIO()
doc = self._create_doc(buffer)
# Создаем абзац с новым стилем
paragraph = Paragraph(barcode_data["text"], self.small_style)
# Получаем ширину и высоту абзаца
paragraph_width, paragraph_height = paragraph.wrap(
self.page_width - 2 * mm, self.page_height
)
# Рассчитываем доступное пространство для штрихкода
human_readable_height = 6 * mm # Высота human-readable текста
space_between_text_and_barcode = 4 * mm # Отступ между текстом и штрихкодом
barcode_height = (
self.page_height
- paragraph_height
- human_readable_height
- space_between_text_and_barcode
- 4 * mm
) # Учитываем поля и отступы
# Создаем штрихкод
available_width = self.page_width - 4 * mm # Учитываем поля
# Приблизительное количество элементов в штрихкоде Code 128 для средней длины
num_elements = 11 * len(
barcode_data["barcode_value"]
) # Примерная оценка: 11 элементов на символ
# Рассчитываем ширину штриха
bar_width = available_width / num_elements
barcode = code128.Code128(
barcode_data["barcode_value"],
barWidth=bar_width,
barHeight=barcode_height,
humanReadable=True,
)
# Добавление штрихкодов в список элементов документа
elements = []
for _ in range(barcode_data["num_duplicates"]):
elements.append(paragraph)
elements.append(
Spacer(1, space_between_text_and_barcode)
) # Отступ между текстом и штрихкодом
elements.append(PageBreak())
# Функция для отрисовки штрихкода на canvas
def add_barcode(canvas, doc):
barcode_width = barcode.width
barcode_x = (self.page_width - barcode_width) / 2 # Центрируем штрихкод
# Размещаем штрихкод снизу с учетом отступа
barcode_y = human_readable_height + 2 * mm
barcode.drawOn(canvas, barcode_x, barcode_y)
# Создаем документ
doc.build(
elements, onFirstPage=add_barcode, onLaterPages=add_barcode
) # Убираем последний PageBreak
buffer.seek(0)
return buffer
async def _generate_for_one_product_using_img(
self, barcode_data: PdfBarcodeImageGenData
) -> BytesIO:
pdf_url = barcode_data["barcode_image_url"]
async with aiohttp.ClientSession() as session:
async with session.get(pdf_url) as response:
if response.status != 200:
raise ValueError(
f"Failed to download PDF from {pdf_url} (status {response.status})"
)
pdf_bytes = await response.read()
pdf_maker = PdfMaker((self.page_width, self.page_height))
for _ in range(barcode_data["num_duplicates"]):
pdf_maker.add_pdfs(BytesIO(pdf_bytes))
return pdf_maker.get_bytes()
def _generate_spacers(self) -> BytesIO:
buffer = BytesIO()
doc = self._create_doc(buffer)
elements = []
for _ in range(self.number_of_spacing_pages):
elements.append(PageBreak())
doc.build(elements)
buffer.seek(0)
return buffer

View File

@ -0,0 +1,21 @@
from typing import TypedDict
from modules.fulfillment_base.models import BarcodeTemplate, Product
class BarcodeData(TypedDict):
barcode: str
template: BarcodeTemplate
product: Product
num_duplicates: int
class PdfBarcodeGenData(TypedDict):
barcode_value: str
text: str
num_duplicates: int
class PdfBarcodeImageGenData(TypedDict):
barcode_image_url: str
num_duplicates: int

View File

@ -0,0 +1 @@
from .service import *

View File

@ -0,0 +1,7 @@
from enum import IntEnum, unique
@unique
class ServiceType(IntEnum):
DEAL_SERVICE = 0
PRODUCT_SERVICE = 1

View File

@ -0,0 +1,17 @@
from .barcode import (
ProductBarcode as ProductBarcode,
ProductBarcodeImage as ProductBarcodeImage,
)
from .barcode_template import (
BarcodeTemplateAttribute as BarcodeTemplateAttribute,
BarcodeTemplateSize as BarcodeTemplateSize,
BarcodeTemplate as BarcodeTemplate,
)
from .deal_product import (
DealProduct as DealProduct,
DealProductService as DealProductService,
)
from .deal_service import DealService as DealService
from .product import Product as Product
from .service import Service as Service, ServiceCategory as ServiceCategory
from .marketplace import BaseMarketplace as BaseMarketplace, Marketplace as Marketplace

View File

@ -0,0 +1,36 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
if TYPE_CHECKING:
from modules.fulfillment_base.models import Product
class ProductBarcode(BaseModel):
__tablename__ = "fulfillment_base_product_barcodes"
product_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_products.id"),
primary_key=True,
)
product: Mapped["Product"] = relationship(back_populates="barcodes")
barcode: Mapped[str] = mapped_column(
primary_key=True, index=True, comment="ШК товара"
)
class ProductBarcodeImage(BaseModel):
__tablename__ = "fulfillment_base_product_barcode_images"
product_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_products.id"),
primary_key=True,
comment="ID товара",
)
product: Mapped["Product"] = relationship(back_populates="barcode_image")
image_url: Mapped[str] = mapped_column()

View File

@ -0,0 +1,42 @@
from sqlalchemy import ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
barcode_template_attribute_link = Table(
"barcode_template_attribute_links",
BaseModel.metadata,
Column("barcode_template_id", ForeignKey("barcode_templates.id")),
Column("attribute_id", ForeignKey("barcode_template_attributes.id")),
)
class BarcodeTemplateAttribute(BaseModel, IdMixin):
__tablename__ = "barcode_template_attributes"
key: Mapped[str] = mapped_column(index=True, comment="Ключ атрибута")
name: Mapped[str] = mapped_column(index=True, comment="Метка атрибута")
class BarcodeTemplateSize(BaseModel, IdMixin):
__tablename__ = "barcode_template_sizes"
name: Mapped[str] = mapped_column(index=True, comment="Название размера")
width: Mapped[int] = mapped_column(comment="Ширина в мм")
height: Mapped[int] = mapped_column(comment="Высота в мм")
class BarcodeTemplate(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "barcode_templates"
name: Mapped[str] = mapped_column(index=True, comment="Название шаблона")
attributes: Mapped[list["BarcodeTemplateAttribute"]] = relationship(
secondary=barcode_template_attribute_link,
lazy="selectin",
)
is_default: Mapped[bool] = mapped_column(default=False, comment="По умолчанию")
size_id: Mapped[int] = mapped_column(ForeignKey("barcode_template_sizes.id"))
size: Mapped["BarcodeTemplateSize"] = relationship(lazy="joined")

View File

@ -0,0 +1,62 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, ForeignKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import PriceMixin
if TYPE_CHECKING:
from models import Deal, Service, Product
class DealProduct(BaseModel):
__tablename__ = "fulfillment_base_deal_products"
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_products.id"), primary_key=True
)
quantity: Mapped[int] = mapped_column(default=1)
comment: Mapped[str] = mapped_column(comment="Комментарий к товару")
deal: Mapped["Deal"] = relationship(backref="deal_products")
product: Mapped["Product"] = relationship()
product_services: Mapped[list["DealProductService"]] = relationship(
back_populates="deal_product",
primaryjoin="and_(DealProduct.deal_id==DealProductService.deal_id, DealProduct.product_id==DealProductService.product_id)",
cascade="all, delete-orphan",
)
class DealProductService(BaseModel, PriceMixin):
__tablename__ = "fulfillment_base_deal_products_services"
deal_id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(primary_key=True)
service_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_services.id"), primary_key=True
)
is_fixed_price: Mapped[bool] = mapped_column(
default=False, server_default="0", comment="Фиксированная цена"
)
deal_product: Mapped["DealProduct"] = relationship(
back_populates="product_services",
primaryjoin="and_(DealProductService.deal_id==DealProduct.deal_id, DealProductService.product_id==DealProduct.product_id)",
)
service: Mapped["Service"] = relationship()
__table_args__ = (
ForeignKeyConstraint(
["deal_id", "product_id"],
[
"fulfillment_base_deal_products.deal_id",
"fulfillment_base_deal_products.product_id",
],
ondelete="CASCADE",
),
)

View File

@ -0,0 +1,28 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import PriceMixin
if TYPE_CHECKING:
from models import Deal
from modules.fulfillment_base.models import Service
class DealService(BaseModel, PriceMixin):
__tablename__ = "fulfillment_base_deal_services"
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), primary_key=True)
deal: Mapped["Deal"] = relationship(backref="deal_services")
service_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_services.id"), primary_key=True
)
service: Mapped["Service"] = relationship()
quantity: Mapped[int] = mapped_column(default=1)
is_fixed_price: Mapped[bool] = mapped_column(
default=False, server_default="0", comment="Фиксированная цена"
)

View File

@ -0,0 +1,32 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
if TYPE_CHECKING:
from modules.clients.models import Client
class BaseMarketplace(BaseModel, IdMixin):
__tablename__ = "fulfillment_base_base_marketplaces"
name: Mapped[str] = mapped_column()
icon_url: Mapped[str] = mapped_column()
class Marketplace(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "fulfillment_base_marketplaces"
base_marketplace_id: Mapped[str] = mapped_column(
ForeignKey("fulfillment_base_base_marketplaces.id")
)
base_marketplace: Mapped["BaseMarketplace"] = relationship(lazy="joined")
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"))
client: Mapped["Client"] = relationship()
name: Mapped[str] = mapped_column()
auth_data: Mapped[dict] = mapped_column(type_=JSON)

View File

@ -0,0 +1,64 @@
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
from modules.clients.models import Client
from modules.fulfillment_base.models import (
ProductBarcode,
BarcodeTemplate,
ProductBarcodeImage,
)
class Product(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "fulfillment_base_products"
name: Mapped[str] = mapped_column()
article: Mapped[str] = mapped_column(index=True)
factory_article: Mapped[str] = mapped_column(
index=True, default="", server_default=""
)
brand: Mapped[str] = mapped_column(default="", comment="Бренд")
color: Mapped[str] = mapped_column(default="", comment="Цвет")
composition: Mapped[str] = mapped_column(default="", comment="Состав")
size: Mapped[str] = mapped_column(default="", comment="Размер")
additional_info: Mapped[str] = mapped_column(
default="", comment="Дополнительная информация"
)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"))
client: Mapped["Client"] = relationship(back_populates="products")
images: Mapped[list["ProductImage"]] = relationship(
"ProductImage",
back_populates="product",
lazy="selectin",
cascade="all, delete-orphan",
)
barcodes: Mapped[list["ProductBarcode"]] = relationship(
back_populates="product",
cascade="all, delete-orphan",
)
barcode_template_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("barcode_templates.id")
)
barcode_template: Mapped["BarcodeTemplate"] = relationship(lazy="joined")
barcode_image: Mapped["ProductBarcodeImage"] = relationship(
back_populates="product",
lazy="joined",
)
class ProductImage(BaseModel, IdMixin):
__tablename__ = "fulfillment_base_product_images"
product_id: Mapped[int] = mapped_column(ForeignKey("fulfillment_base_products.id"))
product: Mapped["Product"] = relationship(back_populates="images")
image_url: Mapped[str] = mapped_column()

View File

@ -0,0 +1,69 @@
from sqlalchemy import ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin, CostMixin, PriceMixin
from modules.fulfillment_base import enums
services_kit_services = Table(
"fulfillment_base_services_kit_services",
BaseModel.metadata,
Column("services_kit_id", ForeignKey("fulfillment_base_services_kits.id")),
Column("service_id", ForeignKey("fulfillment_base_services.id")),
)
class Service(BaseModel, IdMixin, SoftDeleteMixin, PriceMixin, CostMixin):
__tablename__ = "fulfillment_base_services"
name: Mapped[str] = mapped_column(index=True)
price_ranges: Mapped[list["ServicePriceRange"]] = relationship(
back_populates="service",
lazy="selectin",
order_by="asc(ServicePriceRange.from_quantity)",
cascade="all, delete-orphan",
)
category_id: Mapped[int] = mapped_column(
ForeignKey("fulfillment_base_service_categories.id"),
comment="ID категории услуги",
)
category: Mapped["ServiceCategory"] = relationship("ServiceCategory", lazy="joined")
service_type: Mapped[int] = mapped_column(
server_default=f"{enums.service.ServiceType.DEAL_SERVICE}",
comment="Тип услуги",
)
lexorank: Mapped[str] = mapped_column(comment="Ранг услуги")
class ServiceCategory(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "fulfillment_base_service_categories"
name: Mapped[str] = mapped_column()
deal_service_rank: Mapped[str] = mapped_column(comment="Ранг услуги для сделки")
product_service_rank: Mapped[str] = mapped_column(comment="Ранг услуги для товара")
class ServicesKit(BaseModel, IdMixin):
__tablename__ = "fulfillment_base_services_kits"
name: Mapped[str] = mapped_column()
service_type: Mapped[int] = mapped_column(
server_default=f"{enums.ServiceType.DEAL_SERVICE}",
comment="Тип услуги",
)
services: Mapped[list["Service"]] = relationship(
secondary=services_kit_services, lazy="selectin"
)
class ServicePriceRange(BaseModel, IdMixin, PriceMixin):
__tablename__ = "fulfillment_base_service_price_ranges"
service_id: Mapped[int] = mapped_column(ForeignKey("fulfillment_base_services.id"))
service: Mapped[Service] = relationship(back_populates="price_ranges")
from_quantity: Mapped[int] = mapped_column(comment="От количества")
to_quantity: Mapped[int] = mapped_column(comment="До количества")

View File

@ -0,0 +1,9 @@
from .barcode_template import BarcodeTemplateRepository as BarcodeTemplateRepository
from .deal_product import DealProductRepository as DealProductRepository
from .deal_service import DealServiceRepository as DealServiceRepository
from .marketplace import MarketplaceRepository as MarketplaceRepository
from .product import ProductRepository as ProductRepository
from .product_service import ProductServiceRepository as ProductServiceRepository
from .service import ServiceRepository as ServiceRepository
from .service_category import ServiceCategoryRepository as ServiceCategoryRepository
from .services_kit import ServicesKitRepository as ServicesKitRepository

View File

@ -0,0 +1,119 @@
from sqlalchemy import update
from sqlalchemy.orm import joinedload, selectinload
from modules.fulfillment_base.models import (
BarcodeTemplate,
BarcodeTemplateAttribute,
BarcodeTemplateSize,
)
from modules.fulfillment_base.schemas.barcode_template import (
CreateBarcodeTemplateSchema,
UpdateBarcodeTemplateSchema,
)
from repositories.mixins import *
class BarcodeTemplateRepository(
RepCrudMixin[
BarcodeTemplate, CreateBarcodeTemplateSchema, UpdateBarcodeTemplateSchema
],
):
session: AsyncSession
entity_class = BarcodeTemplate
entity_not_found_msg = "Шаблон штрихкода не найден"
def _process_get_all_stmt(self, stmt: Select) -> Select:
return (
stmt.options(
selectinload(BarcodeTemplate.attributes),
joinedload(BarcodeTemplate.size),
)
.where(BarcodeTemplate.is_deleted.is_(False))
.order_by(BarcodeTemplate.id)
)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(
selectinload(BarcodeTemplate.attributes),
joinedload(BarcodeTemplate.size),
)
async def _get_size_by_id(self, size_id: int) -> Optional[BarcodeTemplateSize]:
stmt = select(BarcodeTemplateSize).where(BarcodeTemplateSize.id == size_id)
result = await self.session.scalars(stmt)
return result.one_or_none()
async def _get_attrs_by_ids(
self, attrs_ids: list[int]
) -> list[BarcodeTemplateAttribute]:
stmt = select(BarcodeTemplateAttribute).where(
BarcodeTemplateAttribute.id.in_(attrs_ids)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def create(self, data: CreateBarcodeTemplateSchema) -> int:
if data.is_default is not None and data.is_default:
await self._turn_off_defaults()
data_dict = data.model_dump()
data_dict["size"] = await self._get_size_by_id(data.size.id)
data_dict["attributes"] = await self._get_attrs_by_ids(
[a.id for a in data.attributes]
)
obj = BarcodeTemplate(**data_dict)
self.session.add(obj)
await self.session.commit()
await self.session.refresh(obj)
return obj.id
async def _turn_off_defaults(self):
stmt = (
update(BarcodeTemplate)
.where(BarcodeTemplate.is_default.is_(True))
.values({"is_default": False})
)
await self.session.execute(stmt)
async def _set_first_as_default(self, with_commit: bool = False):
stmt = select(BarcodeTemplate).limit(1)
result = await self.session.execute(stmt)
obj = result.scalar()
if not obj:
return
obj.is_default = True
self.session.add(obj)
if with_commit:
await self.session.commit()
await self.session.refresh(obj)
async def update(
self, template: BarcodeTemplate, data: UpdateBarcodeTemplateSchema
) -> BarcodeTemplate:
if data.size is not None:
data.size = await self._get_size_by_id(data.size.id)
if data.attributes is not None:
data.attributes = await self._get_attrs_by_ids(
[a.id for a in data.attributes]
)
if data.is_default is not None:
if data.is_default:
await self._turn_off_defaults()
else:
await self._set_first_as_default()
return await self._apply_update_data_to_model(template, data, True)
async def _before_delete(self, template: BarcodeTemplate):
if template.is_default:
await self._set_first_as_default()
async def get_attributes(self) -> list[BarcodeTemplateAttribute]:
stmt = select(BarcodeTemplateAttribute)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_sizes(self) -> list[BarcodeTemplateSize]:
stmt = select(BarcodeTemplateSize)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@ -0,0 +1,63 @@
from typing import Optional
from sqlalchemy import Select, select
from sqlalchemy.orm import joinedload, selectinload
from modules.fulfillment_base.models import DealProductService, Product
from modules.fulfillment_base.models.deal_product import DealProduct
from modules.fulfillment_base.schemas.deal_product import (
UpdateDealProductSchema,
CreateDealProductSchema,
)
from repositories.base import BaseRepository
from repositories.mixins import RepGetAllMixin, RepUpdateMixin
from utils.exceptions import ObjectNotFoundException
class DealProductRepository(
BaseRepository,
RepGetAllMixin[DealProduct],
RepUpdateMixin[DealProduct, UpdateDealProductSchema],
):
entity_class = DealProduct
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
deal_id = args[0]
return (
stmt.options(
joinedload(DealProduct.product).selectinload(Product.barcodes),
joinedload(DealProduct.product).joinedload(Product.client),
selectinload(DealProduct.product_services).joinedload(
DealProductService.service
),
)
.where(DealProduct.deal_id == deal_id)
.order_by(DealProduct.product_id)
)
async def get_by_id(
self, deal_id: int, product_id: int, raise_if_not_found: Optional[bool] = True
) -> Optional[DealProduct]:
stmt = (
select(DealProduct)
.options(
joinedload(DealProduct.product).selectinload(Product.barcodes),
selectinload(DealProduct.product_services).joinedload(
DealProductService.service
),
)
.where(DealProduct.deal_id == deal_id, DealProduct.product_id == product_id)
)
result = (await self.session.execute(stmt)).scalar_one_or_none()
if result is None and raise_if_not_found:
raise ObjectNotFoundException("Связь сделки с товаром не найдена")
return result
async def create(self, data: CreateDealProductSchema):
deal_product = DealProduct(**data.model_dump())
self.session.add(deal_product)
await self.session.commit()
async def delete(self, obj: DealProduct):
await self.session.delete(obj)
await self.session.commit()

View File

@ -0,0 +1,71 @@
from typing import Optional
from sqlalchemy import Select, select, delete
from sqlalchemy.orm import joinedload
from models import Deal
from modules.fulfillment_base.models import DealService
from modules.fulfillment_base.models.service import ServicesKit
from modules.fulfillment_base.schemas.deal_service import (
UpdateDealServiceSchema,
CreateDealServiceSchema,
)
from repositories.base import BaseRepository
from repositories.mixins import RepGetAllMixin, RepUpdateMixin
from utils.exceptions import ObjectNotFoundException
class DealServiceRepository(
BaseRepository,
RepGetAllMixin[DealService],
RepUpdateMixin[DealService, UpdateDealServiceSchema],
):
entity_class = DealService
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
deal_id = args[0]
return (
stmt.options(
joinedload(DealService.service),
)
.where(DealService.deal_id == deal_id)
.order_by(DealService.service_id)
)
async def get_by_id(
self, deal_id: int, service_id: int, raise_if_not_found: Optional[bool] = True
) -> Optional[DealService]:
stmt = (
select(DealService)
.options(joinedload(DealService.service))
.where(DealService.deal_id == deal_id, DealService.service_id == service_id)
)
result = (await self.session.execute(stmt)).scalar_one_or_none()
if result is None and raise_if_not_found:
raise ObjectNotFoundException("Связь сделки с услугой не найдена")
return result
async def create(self, data: CreateDealServiceSchema):
deal_service = DealService(**data.model_dump())
self.session.add(deal_service)
await self.session.commit()
async def delete(self, obj: DealService):
await self.session.delete(obj)
await self.session.commit()
async def delete_deal_services(self, deal_id: int):
stmt = delete(DealService).where(DealService.deal_id == deal_id)
await self.session.execute(stmt)
await self.session.flush()
async def add_services_kit(self, deal: Deal, services_kit: ServicesKit):
for service in services_kit.services:
deal_service = DealService(
deal_id=deal.id,
service_id=service.id,
price=service.price,
quantity=1,
)
self.session.add(deal_service)
await self.session.commit()

View File

@ -0,0 +1,62 @@
from sqlalchemy.orm import joinedload
from modules.clients.models import Client
from modules.fulfillment_base.models import (
Marketplace,
BaseMarketplace,
)
from modules.fulfillment_base.schemas.marketplace import (
CreateMarketplaceSchema,
UpdateMarketplaceSchema,
)
from repositories.mixins import *
class MarketplaceRepository(
RepCrudMixin[Marketplace, CreateMarketplaceSchema, UpdateMarketplaceSchema],
):
session: AsyncSession
entity_class = Marketplace
entity_not_found_msg = "Маркетплейс не найден"
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
client_id: int = args[0]
return (
stmt.options(
joinedload(Marketplace.base_marketplace),
joinedload(Marketplace.client),
)
.where(
Marketplace.is_deleted.is_(False), Marketplace.client_id == client_id
)
.order_by(Marketplace.id)
)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(
joinedload(Marketplace.base_marketplace), joinedload(Marketplace.client)
)
async def get_base_marketplaces(self) -> list[BaseMarketplace]:
stmt = select(BaseMarketplace)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def _prepare_create(self, data: CreateMarketplaceSchema) -> dict:
dict_data = data.model_dump()
dict_data["base_marketplace_id"] = data.base_marketplace.id
del dict_data["base_marketplace"]
dict_data["client_id"] = data.client.id
del dict_data["client"]
return dict_data
async def update(
self, template: Marketplace, data: UpdateMarketplaceSchema
) -> Marketplace:
if data.base_marketplace:
data.base_marketplace = BaseMarketplace(
**data.base_marketplace.model_dump()
)
if data.client:
data.client = Client(**data.client.model_dump())
return await self._apply_update_data_to_model(template, data, True)

View File

@ -0,0 +1,132 @@
from sqlalchemy import or_, delete
from sqlalchemy.orm import selectinload, joinedload
from modules.fulfillment_base.models import Product, ProductBarcode, BarcodeTemplate, ProductBarcodeImage
from modules.fulfillment_base.models.product import ProductImage
from modules.fulfillment_base.schemas.product import (
CreateProductSchema,
UpdateProductSchema,
)
from repositories.mixins import *
class ProductRepository(
BaseRepository,
RepDeleteMixin[Product],
RepCreateMixin[Product, CreateProductSchema],
RepUpdateMixin[Product, UpdateProductSchema],
RepGetByIdMixin[Product],
):
session: AsyncSession
entity_class = Product
entity_not_found_msg = "Товар не найден"
async def get_all(
self,
page: Optional[int],
items_per_page: Optional[int],
client_id: Optional[int],
search_input: Optional[str],
) -> tuple[list[Product], int]:
stmt = (
select(Product)
.options(selectinload(Product.barcodes))
.where(Product.is_deleted.is_(False))
)
if client_id:
stmt = stmt.where(Product.client_id == client_id)
if search_input:
stmt = stmt.where(
or_(
Product.name.ilike(f"%{search_input}%"),
Product.barcodes.any(
ProductBarcode.barcode.ilike(f"%{search_input}%")
),
Product.article.ilike(f"%{search_input}%"),
Product.factory_article.ilike(f"%{search_input}%"),
)
)
total_items = len((await self.session.execute(stmt)).all())
if page and items_per_page:
stmt = self._apply_pagination(stmt, page, items_per_page)
result = await self.session.execute(stmt)
return list(result.scalars().all()), total_items
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(
selectinload(Product.barcodes),
joinedload(Product.client),
joinedload(Product.barcode_template).selectinload(
BarcodeTemplate.attributes
),
)
async def _after_create(self, product: Product, data: CreateProductSchema) -> None:
new_barcodes = [
ProductBarcode(product_id=product.id, barcode=barcode)
for barcode in data.barcodes
]
self.session.add_all(new_barcodes)
async def _update_barcodes(self, product: Product, new_barcodes: list[str]):
new_barcodes_set: set[str] = set(new_barcodes)
old_barcodes_set: set[str] = set(obj.barcode for obj in product.barcodes)
barcodes_to_add = new_barcodes_set - old_barcodes_set
barcodes_to_delete = old_barcodes_set - new_barcodes_set
del_stmt = delete(ProductBarcode).where(
ProductBarcode.product_id == product.id,
ProductBarcode.barcode.in_(barcodes_to_delete),
)
await self.session.execute(del_stmt)
new_barcodes = [
ProductBarcode(product_id=product.id, barcode=barcode)
for barcode in barcodes_to_add
]
self.session.add_all(new_barcodes)
await self.session.commit()
await self.session.refresh(product)
async def update(self, product: Product, data: UpdateProductSchema) -> Product:
if data.barcodes is not None:
await self._update_barcodes(product, data.barcodes)
del data.barcodes
return await self._apply_update_data_to_model(product, data, True)
async def delete_images(self, product_images: list[ProductImage], with_commit: bool = False):
for img in product_images:
await self.session.delete(img)
if with_commit:
await self.session.commit()
else:
await self.session.flush()
async def delete_barcode_image(self, barcode_image: ProductBarcodeImage, with_commit: bool = False):
await self.session.delete(barcode_image)
if with_commit:
await self.session.commit()
else:
await self.session.flush()
async def create_image(self, product_id: int, image_url: str) -> ProductImage:
product_image = ProductImage(
product_id=product_id,
image_url=image_url,
)
self.session.add(product_image)
await self.session.commit()
return product_image
async def create_barcode_image(self, product_id: int, image_url: str) -> ProductBarcodeImage:
product_barcode_image = ProductBarcodeImage(
product_id=product_id,
image_url=image_url,
)
self.session.add(product_barcode_image)
await self.session.commit()
return product_barcode_image

View File

@ -0,0 +1,105 @@
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from modules.fulfillment_base.models import DealProductService, DealProduct
from modules.fulfillment_base.models.service import ServicesKit
from modules.fulfillment_base.schemas.product_service import *
from repositories.base import BaseRepository
from repositories.mixins import RepUpdateMixin
from utils.exceptions import ObjectNotFoundException
class ProductServiceRepository(
BaseRepository,
RepUpdateMixin[DealProductService, UpdateProductServiceSchema],
):
entity_class = DealProductService
session: AsyncSession
async def get_by_id(
self,
deal_id: int,
product_id: int,
service_id: int,
raise_if_not_found: Optional[bool] = True,
) -> Optional[DealProductService]:
stmt = (
select(DealProductService)
.options(
joinedload(DealProductService.service),
)
.where(
DealProductService.deal_id == deal_id,
DealProductService.product_id == product_id,
DealProductService.service_id == service_id,
)
)
result = (await self.session.execute(stmt)).scalar_one_or_none()
if result is None and raise_if_not_found:
raise ObjectNotFoundException("Связь услуги с товаром не найдена")
return result
async def create(self, data: CreateProductServiceSchema):
deal_product_service = DealProductService(**data.model_dump())
self.session.add(deal_product_service)
await self.session.commit()
async def delete(self, obj: DealProductService):
await self.session.delete(obj)
await self.session.commit()
async def get_product_services(
self, deal_id: int, product_id: int
) -> list[DealProductService]:
stmt = (
select(DealProductService)
.options(
joinedload(DealProductService.service),
)
.where(
DealProductService.deal_id == deal_id,
DealProductService.product_id == product_id,
)
)
return list(await self.session.scalars(stmt))
async def delete_product_services(self, deal_id: int, product_ids: list[int]):
stmt = delete(DealProductService).where(
DealProductService.deal_id == deal_id,
DealProductService.product_id.in_(product_ids),
)
await self.session.execute(stmt)
await self.session.flush()
async def duplicate_services(
self, deal_id: int, product_ids: list[int], services: list[DealProductService]
):
await self.delete_product_services(deal_id, product_ids)
for product_id in product_ids:
for prod_service in services:
product_service = DealProductService(
deal_id=deal_id,
product_id=product_id,
service_id=prod_service.service.id,
price=prod_service.price,
is_fixed_price=prod_service.is_fixed_price,
)
self.session.add(product_service)
await self.session.commit()
async def add_services_kit(
self, deal_product: DealProduct, services_kit: ServicesKit
):
for service in services_kit.services:
deal_product_service = DealProductService(
deal_id=deal_product.deal_id,
product_id=deal_product.product_id,
service_id=service.id,
price=service.price,
)
self.session.add(deal_product_service)
await self.session.commit()

View File

@ -0,0 +1,78 @@
from sqlalchemy import delete
from modules.fulfillment_base.models import Service
from modules.fulfillment_base.models.service import ServicePriceRange
from modules.fulfillment_base.schemas.service import (
CreateServiceSchema,
UpdateServiceSchema,
ServicePriceRangeSchema,
)
from repositories.mixins import *
class ServiceRepository(
BaseRepository,
RepGetAllMixin[Service],
RepDeleteMixin[Service],
RepUpdateMixin[Service, UpdateServiceSchema],
RepGetByIdMixin[Service],
):
entity_class = Service
entity_not_found_msg = "Услуга не найдена"
def _process_get_all_stmt(self, stmt: Select) -> Select:
return stmt.order_by(Service.lexorank)
@staticmethod
def _price_ranges_schemas_to_models(
price_ranges: list[ServicePriceRangeSchema],
) -> list[ServicePriceRange]:
models = []
for range in price_ranges:
models.append(
ServicePriceRange(
from_quantity=range.from_quantity,
to_quantity=range.to_quantity,
price=range.price,
)
)
return models
async def create(self, data: CreateServiceSchema) -> int:
price_ranges = self._price_ranges_schemas_to_models(data.price_ranges)
data_dict = data.model_dump()
data_dict["price_ranges"] = price_ranges
data_dict["category_id"] = data.category.id
del data_dict["category"]
service = Service(**data_dict)
self.session.add(service)
await self.session.commit()
await self.session.refresh(service)
return service.id
async def _delete_price_ranges_by_service_id(self, service_id: int) -> None:
stmt = delete(ServicePriceRange).where(
ServicePriceRange.service_id == service_id
)
await self.session.execute(stmt)
await self.session.commit()
async def update(self, service: Service, data: UpdateServiceSchema) -> Service:
if data.price_ranges is not None:
await self._delete_price_ranges_by_service_id(service.id)
price_ranges = self._price_ranges_schemas_to_models(data.price_ranges)
for price_range in price_ranges:
service.price_ranges.append(price_range)
del data.price_ranges
if data.category is not None:
data.category_id = data.category.id
del data.category
return await self._apply_update_data_to_model(service, data, True)
async def get_by_ids(self, ids: list[int]) -> list[Service]:
stmt = select(Service).where(Service.id.in_(ids))
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,18 @@
from modules.fulfillment_base.models import ServiceCategory
from modules.fulfillment_base.schemas.service_category import (
CreateServiceCategorySchema,
UpdateServiceCategorySchema,
)
from repositories.mixins import *
class ServiceCategoryRepository(
RepCrudMixin[ServiceCategory, CreateServiceCategorySchema, UpdateServiceCategorySchema]
):
entity_class = ServiceCategory
entity_not_found_msg = "Категория услуги не найдена"
async def update(
self, service: ServiceCategory, data: UpdateServiceCategorySchema
) -> ServiceCategory:
return await self._apply_update_data_to_model(service, data, True)

View File

@ -0,0 +1,43 @@
from sqlalchemy.orm import selectinload
from modules.fulfillment_base.models.service import ServicesKit
from modules.fulfillment_base.repositories import ServiceRepository
from modules.fulfillment_base.schemas.services_kit import (
CreateServicesKitSchema,
UpdateServicesKitSchema,
)
from repositories.mixins import *
class ServicesKitRepository(
RepCrudMixin[ServicesKit, CreateServicesKitSchema, UpdateServicesKitSchema],
):
entity_class = ServicesKit
entity_not_found_msg = "Набор услуг не найден"
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(selectinload(ServicesKit.services))
async def create(self, data: CreateServicesKitSchema) -> int:
if data.services is not None:
service_ids: list[int] = [service.id for service in data.services]
data.services = await ServiceRepository(self.session).get_by_ids(
service_ids
)
kit = ServicesKit(
name=data.name, service_type=data.service_type, services=data.services
)
self.session.add(kit)
await self.session.commit()
await self.session.refresh(kit)
return kit.id
async def update(
self, service_kit: ServicesKit, data: UpdateServicesKitSchema
) -> ServicesKit:
if data.services is not None:
service_ids: list[int] = [service.id for service in data.services]
data.services = await ServiceRepository(self.session).get_by_ids(
service_ids
)
return await self._apply_update_data_to_model(service_kit, data, True)

View File

@ -0,0 +1,82 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
# region Entity
class BarcodeTemplateAttributeSchema(BaseSchema):
id: int
key: str
name: str
class BarcodeTemplateSizeSchema(BaseSchema):
id: int
name: str
width: int
height: int
class CreateBarcodeTemplateSchema(BaseSchema):
name: str
attributes: list[BarcodeTemplateAttributeSchema]
is_default: bool
size: BarcodeTemplateSizeSchema
class BarcodeTemplateSchema(CreateBarcodeTemplateSchema):
id: int
class UpdateBarcodeTemplateSchema(BaseSchema):
name: Optional[str] = None
attributes: Optional[list[BarcodeTemplateAttributeSchema]] = None
is_default: Optional[bool] = None
size: Optional[BarcodeTemplateSizeSchema] = None
# endregion
# region Request
class CreateBarcodeTemplateRequest(BaseSchema):
entity: CreateBarcodeTemplateSchema
class UpdateBarcodeTemplateRequest(BaseSchema):
entity: UpdateBarcodeTemplateSchema
# endregion
# region Response
class GetBarcodeTemplatesResponse(BaseSchema):
items: list[BarcodeTemplateSchema]
class CreateBarcodeTemplateResponse(BaseResponse):
entity: BarcodeTemplateSchema
class UpdateBarcodeTemplateResponse(BaseResponse):
pass
class DeleteBarcodeTemplateResponse(BaseResponse):
pass
class GetBarcodeAttributesResponse(BaseSchema):
items: list[BarcodeTemplateAttributeSchema]
class GetBarcodeTemplateSizesResponse(BaseSchema):
items: list[BarcodeTemplateSizeSchema]
# endregion

View File

@ -0,0 +1,64 @@
from modules.fulfillment_base.schemas.product import ProductSchema
from modules.fulfillment_base.schemas.product_service import ProductServiceSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class DealProductSchema(BaseSchema):
deal_id: int
product_id: int
product: ProductSchema
quantity: int
comment: str
product_services: list[ProductServiceSchema]
class CreateDealProductSchema(BaseSchema):
deal_id: int
product_id: int
quantity: int
comment: str
class UpdateDealProductSchema(BaseSchema):
quantity: int
comment: str
# endregion
# region Request
class CreateDealProductRequest(BaseSchema):
entity: CreateDealProductSchema
class UpdateDealProductRequest(BaseSchema):
entity: UpdateDealProductSchema
# endregion
# region Response
class GetDealProductsResponse(BaseSchema):
items: list[DealProductSchema]
class CreateDealProductResponse(BaseResponse):
entity: DealProductSchema
class UpdateDealProductResponse(BaseResponse):
pass
class DeleteDealProductResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,72 @@
from modules.fulfillment_base.schemas.service import ServiceSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class DealServiceSchema(BaseSchema):
deal_id: int
service_id: int
service: ServiceSchema
quantity: int
price: float
is_fixed_price: bool
class CreateDealServiceSchema(BaseSchema):
deal_id: int
service_id: int
quantity: int
price: float
class UpdateDealServiceSchema(BaseSchema):
quantity: int
price: float
is_fixed_price: bool
# endregion
# region Request
class CreateDealServiceRequest(BaseSchema):
entity: CreateDealServiceSchema
class UpdateDealServiceRequest(BaseSchema):
entity: UpdateDealServiceSchema
class DealAddKitRequest(BaseSchema):
deal_id: int
kit_id: int
# endregion
# region Response
class GetDealServicesResponse(BaseSchema):
items: list[DealServiceSchema]
class CreateDealServiceResponse(BaseResponse):
entity: DealServiceSchema
class UpdateDealServiceResponse(BaseResponse):
pass
class DeleteDealServiceResponse(BaseResponse):
pass
class DealAddKitResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,77 @@
from typing import Optional
from modules.clients.schemas.client import ClientSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class BaseMarketplaceSchema(BaseSchema):
id: int
name: str
icon_url: str
class MarketplaceSchema(BaseSchema):
id: int
base_marketplace_id: int
base_marketplace: BaseMarketplaceSchema
client: ClientSchema
name: str
auth_data: dict
class CreateMarketplaceSchema(BaseSchema):
base_marketplace: BaseMarketplaceSchema
client: ClientSchema
name: str
auth_data: dict
class UpdateMarketplaceSchema(BaseSchema):
base_marketplace: Optional[BaseMarketplaceSchema] = None
client: Optional[ClientSchema] = None
name: Optional[str] = None
auth_data: Optional[dict] = None
# endregion
# region Request
class CreateMarketplaceRequest(BaseSchema):
entity: CreateMarketplaceSchema
class UpdateMarketplaceRequest(BaseSchema):
entity: UpdateMarketplaceSchema
# endregion
# region Response
class GetBaseMarketplacesResponse(BaseSchema):
items: list[BaseMarketplaceSchema]
class GetMarketplacesResponse(BaseSchema):
items: list[MarketplaceSchema]
class CreateMarketplaceResponse(BaseResponse):
entity: MarketplaceSchema
class UpdateMarketplaceResponse(BaseResponse):
pass
class DeleteMarketplaceResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,146 @@
from typing import Optional
from pydantic import field_validator, model_validator
from modules.fulfillment_base.models import ProductBarcode
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema
from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema, BasePdfResponse
# region Entity
class ProductImageSchema(BaseSchema):
id: int
product_id: int
image_url: str
class ProductBarcodeImageSchema(BaseSchema):
product_id: int
image_url: str
class CreateProductSchema(BaseSchema):
name: str
article: str
factory_article: str
client_id: int
barcode_template_id: int
brand: Optional[str]
color: Optional[str]
composition: Optional[str]
size: Optional[str]
additional_info: Optional[str]
barcodes: list[str] = []
class BaseProductSchema(CreateProductSchema):
image_url: Optional[str] = None
images: Optional[list[ProductImageSchema]] = []
barcode_image_url: Optional[str] = None
barcode_image: Optional[ProductBarcodeImageSchema] = None
@model_validator(mode="after")
def images_list_to_image_url(cls, values):
if values.images:
latest_image = values.images[-1]
values.image_url = latest_image.image_url
if values.barcode_image:
values.barcode_image_url = values.barcode_image.image_url
return values
class ProductSchema(BaseProductSchema):
id: int
barcode_template: BarcodeTemplateSchema
@field_validator("barcodes", mode="before")
def barcodes_to_list(cls, v: Optional[list[ProductBarcode]]):
if isinstance(v, list):
return [barcode.barcode for barcode in v]
return v
class UpdateProductSchema(BaseSchema):
name: Optional[str] = None
article: Optional[str] = None
factory_article: Optional[str] = None
barcode_template_id: Optional[int] = None
brand: Optional[str] = None
color: Optional[str] = None
composition: Optional[str] = None
size: Optional[str] = None
additional_info: Optional[str] = None
barcodes: Optional[list[str]] = None
images: list[ProductImageSchema] | None = []
# endregion
# region Request
class CreateProductRequest(BaseSchema):
entity: CreateProductSchema
class UpdateProductRequest(BaseSchema):
entity: UpdateProductSchema
class GetProductBarcodePdfRequest(BaseSchema):
quantity: int
product_id: int
barcode: str
class GetDealBarcodesPdfRequest(BaseSchema):
deal_id: int
# endregion
# region Response
class GetProductsResponse(BaseSchema):
items: list[ProductSchema]
pagination_info: PaginationInfoSchema
class CreateProductResponse(BaseResponse):
entity: ProductSchema
class UpdateProductResponse(BaseResponse):
pass
class ProductUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteProductResponse(BaseResponse):
pass
class GetProductBarcodePdfResponse(BasePdfResponse):
pass
class GetDealBarcodesPdfResponse(BasePdfResponse):
pass
class BarcodeUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteBarcodeImageResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,79 @@
from modules.fulfillment_base.schemas.service import ServiceSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class ProductServiceSchema(BaseSchema):
deal_id: int
product_id: int
service_id: int
service: ServiceSchema
price: float
is_fixed_price: bool
class CreateProductServiceSchema(BaseSchema):
deal_id: int
product_id: int
service_id: int
price: float
class UpdateProductServiceSchema(BaseSchema):
price: float
is_fixed_price: bool
# endregion
# region Request
class CreateProductServiceRequest(BaseSchema):
entity: CreateProductServiceSchema
class UpdateProductServiceRequest(BaseSchema):
entity: UpdateProductServiceSchema
class ProductServicesDuplicateRequest(BaseSchema):
deal_id: int
source_deal_product_id: int
target_deal_product_ids: list[int]
class DealProductAddKitRequest(BaseSchema):
deal_id: int
product_id: int
kit_id: int
# endregion
# region Response
class CreateProductServiceResponse(BaseResponse):
entity: ProductServiceSchema
class UpdateProductServiceResponse(BaseResponse):
pass
class DeleteProductServiceResponse(BaseResponse):
pass
class ProductServicesDuplicateResponse(BaseResponse):
pass
class DealProductAddKitResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,77 @@
from typing import Optional
from modules.fulfillment_base.schemas.service_category import ServiceCategorySchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class ServicePriceRangeSchema(BaseSchema):
id: int | None
from_quantity: int
to_quantity: int
price: float
class CreateServiceSchema(BaseSchema):
name: str
category: ServiceCategorySchema
category_id: Optional[int] = None
price: float
service_type: int
price_ranges: list[ServicePriceRangeSchema]
cost: Optional[float]
lexorank: str
class ServiceSchema(CreateServiceSchema):
id: int
class UpdateServiceSchema(BaseSchema):
name: Optional[str] = None
category: Optional[ServiceCategorySchema] = None
category_id: Optional[int] = None
price: Optional[float] = None
service_type: Optional[int] = None
price_ranges: Optional[list[ServicePriceRangeSchema]] = None
cost: Optional[float] = None
lexorank: Optional[str] = None
# endregion
# region Request
class CreateServiceRequest(BaseSchema):
entity: CreateServiceSchema
class UpdateServiceRequest(BaseSchema):
entity: UpdateServiceSchema
# endregion
# region Response
class GetServicesResponse(BaseSchema):
items: list[ServiceSchema]
class CreateServiceResponse(BaseResponse):
entity: ServiceSchema
class UpdateServiceResponse(BaseResponse):
pass
class DeleteServiceResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,59 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
# region Entity
class CreateServiceCategorySchema(BaseSchema):
name: str
deal_service_rank: str
product_service_rank: str
class ServiceCategorySchema(CreateServiceCategorySchema):
id: int
class UpdateServiceCategorySchema(BaseSchema):
name: Optional[str] = None
deal_service_rank: Optional[str] = None
product_service_rank: Optional[str] = None
# endregion
# region Request
class CreateServiceCategoryRequest(BaseSchema):
entity: CreateServiceCategorySchema
class UpdateServiceCategoryRequest(BaseSchema):
entity: UpdateServiceCategorySchema
# endregion
# region Response
class GetServiceCategoriesResponse(BaseSchema):
items: list[ServiceCategorySchema]
class CreateServiceCategoryResponse(BaseResponse):
entity: ServiceCategorySchema
class UpdateServiceCategoryResponse(BaseResponse):
pass
class DeleteServiceCategoryResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,60 @@
from modules.fulfillment_base.schemas.service import ServiceSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class BaseServicesKitSchema(BaseSchema):
name: str
service_type: int
class ServicesKitSchema(BaseServicesKitSchema):
id: int
services: list[ServiceSchema]
class CreateServicesKitSchema(BaseServicesKitSchema):
services: list[ServiceSchema]
class UpdateServicesKitSchema(BaseServicesKitSchema):
services: list[ServiceSchema]
# endregion
# region Request
class CreateServicesKitRequest(BaseSchema):
entity: CreateServicesKitSchema
class UpdateServicesKitRequest(BaseSchema):
entity: UpdateServicesKitSchema
# endregion
# region Response
class GetServicesKitResponse(BaseSchema):
items: list[ServicesKitSchema]
class CreateServicesKitResponse(BaseResponse):
entity: ServicesKitSchema
class UpdateServicesKitResponse(BaseResponse):
pass
class DeleteServicesKitResponse(BaseResponse):
pass
# endregion

View File

@ -0,0 +1,10 @@
from .deal_product import DealProductService as DealProductService
from .deal_service import DealServiceService as DealServiceService
from .product import ProductService as ProductService
from .product_service import ProductServiceService as ProductServiceService
from .service import ServiceModelService as ServiceModelService
from .services_kit import ServicesKitService as ServicesKitService
from .service_category import ServiceCategoryService as ServiceCategoryService
from .barcode_template import BarcodeTemplateService as BarcodeTemplateService
from .barcode_printer_service import BarcodePrinterService as BarcodePrinterService
from .marketplace import MarketplaceService as MarketplaceService

View File

@ -0,0 +1,96 @@
import base64
from io import BytesIO
from sqlalchemy.ext.asyncio import AsyncSession
from modules.fulfillment_base.barcodes_pdf_gen import BarcodePdfGenerator, BarcodeData
from modules.fulfillment_base.barcodes_pdf_gen.types import PdfBarcodeImageGenData
from modules.fulfillment_base.models import Product, DealProduct
from modules.fulfillment_base.repositories import (
ProductRepository,
DealProductRepository,
)
from modules.fulfillment_base.schemas.product import (
GetProductBarcodePdfRequest,
GetDealBarcodesPdfRequest,
)
class BarcodePrinterService:
session: AsyncSession
def __init__(self, session: AsyncSession):
self.session = session
async def generate_product_pdf(
self, request: GetProductBarcodePdfRequest
) -> tuple[str, BytesIO]:
product: Product = await ProductRepository(self.session).get_by_id(
request.product_id
)
if product.barcode_image:
barcode_data: PdfBarcodeImageGenData = {
"barcode_image_url": product.barcode_image.image_url,
"num_duplicates": request.quantity,
}
else:
barcode_data: BarcodeData = {
"barcode": request.barcode,
"template": product.barcode_template,
"product": product,
"num_duplicates": request.quantity,
}
filename = f"{product.id}_barcode.pdf"
size = product.barcode_template.size
generator = BarcodePdfGenerator(size.width, size.height)
return filename, await generator.generate([barcode_data])
async def generate_product_base64(
self, request: GetProductBarcodePdfRequest
) -> tuple[str, str]:
filename, pdf_buffer = await self.generate_product_pdf(request)
base64_string = base64.b64encode(pdf_buffer.read()).decode("utf-8")
return filename, base64_string
async def generate_deal_pdf(
self, request: GetDealBarcodesPdfRequest
) -> tuple[str, BytesIO]:
deal_product_repo = DealProductRepository(self.session)
deal_products: list[DealProduct] = await deal_product_repo.get_all(
request.deal_id
)
if len(deal_products) == 0:
return "no_content.pdf", BytesIO()
barcodes_data: list[BarcodeData | PdfBarcodeImageGenData] = []
for deal_product in deal_products:
if deal_product.product.barcode_image:
barcode_data: PdfBarcodeImageGenData = {
"barcode_image_url": deal_product.product.barcode_image.image_url,
"num_duplicates": deal_product.quantity,
}
barcodes_data.append(barcode_data)
elif len(deal_product.product.barcodes) > 0:
barcode_data: BarcodeData = {
"barcode": deal_product.product.barcodes[0].barcode,
"template": deal_product.product.barcode_template,
"product": deal_product.product,
"num_duplicates": deal_product.quantity,
}
barcodes_data.append(barcode_data)
size = deal_products[0].product.barcode_template.size
generator = BarcodePdfGenerator(size.width, size.height)
filename = "deal_barcodes.pdf"
return filename, await generator.generate(barcodes_data)
async def generate_deal_base64(
self, request: GetDealBarcodesPdfRequest
) -> tuple[str, str]:
filename, pdf_buffer = await self.generate_deal_pdf(request)
base64_string = base64.b64encode(pdf_buffer.read()).decode("utf-8")
return filename, base64_string

View File

@ -0,0 +1,36 @@
from modules.fulfillment_base.models import BarcodeTemplate
from modules.fulfillment_base.repositories import BarcodeTemplateRepository
from modules.fulfillment_base.schemas.barcode_template import *
from services.mixins import *
class BarcodeTemplateService(
ServiceCrudMixin[
BarcodeTemplate,
BarcodeTemplateSchema,
CreateBarcodeTemplateRequest,
UpdateBarcodeTemplateRequest,
]
):
schema_class = BarcodeTemplateSchema
entity_deleted_msg = "Шаблон штрихкода успешно удален"
entity_updated_msg = "Шаблон штрихкода успешно обновлен"
entity_created_msg = "Шаблон штрихкода успешно создан"
def __init__(self, session: AsyncSession):
self.repository = BarcodeTemplateRepository(session)
async def get_attributes(self) -> GetBarcodeAttributesResponse:
attributes = await self.repository.get_attributes()
return GetBarcodeAttributesResponse(
items=[
BarcodeTemplateAttributeSchema.model_validate(attr)
for attr in attributes
]
)
async def get_sizes(self) -> GetBarcodeTemplateSizesResponse:
sizes = await self.repository.get_sizes()
return GetBarcodeTemplateSizesResponse(
items=[BarcodeTemplateSizeSchema.model_validate(size) for size in sizes]
)

View File

@ -0,0 +1,37 @@
from sqlalchemy.ext.asyncio import AsyncSession
from modules.fulfillment_base.models import DealProduct
from modules.fulfillment_base.repositories import DealProductRepository
from modules.fulfillment_base.schemas.deal_product import *
from services.mixins import ServiceGetAllMixin
class DealProductService(ServiceGetAllMixin[DealProduct, DealProductSchema]):
schema_class = DealProductSchema
def __init__(self, session: AsyncSession):
self.repository = DealProductRepository(session)
async def create(
self, request: CreateDealProductRequest
) -> CreateDealProductResponse:
await self.repository.create(request.entity)
deal_product = await self.repository.get_by_id(
request.entity.deal_id, request.entity.product_id
)
return CreateDealProductResponse(
entity=DealProductSchema.model_validate(deal_product),
message="Товар добавлен в сделку",
)
async def update(
self, deal_id: int, product_id: int, data: UpdateDealProductRequest
) -> UpdateDealProductResponse:
entity = await self.repository.get_by_id(deal_id, product_id)
await self.repository.update(entity, data.entity)
return UpdateDealProductResponse(message="Товар сделки обновлен")
async def delete(self, deal_id: int, product_id: int) -> DeleteDealProductResponse:
entity = await self.repository.get_by_id(deal_id, product_id)
await self.repository.delete(entity)
return DeleteDealProductResponse(message="Товар удален из сделки")

View File

@ -0,0 +1,53 @@
from sqlalchemy.ext.asyncio import AsyncSession
from modules.fulfillment_base.models import DealService
from modules.fulfillment_base.repositories import (
DealServiceRepository,
ServicesKitRepository,
)
from modules.fulfillment_base.schemas.deal_service import *
from repositories import DealRepository
from services.mixins import ServiceGetAllMixin
class DealServiceService(ServiceGetAllMixin[DealService, DealServiceSchema]):
schema_class = DealServiceSchema
def __init__(self, session: AsyncSession):
self.repository = DealServiceRepository(session)
async def create(
self, request: CreateDealServiceRequest
) -> CreateDealServiceResponse:
await self.repository.create(request.entity)
deal_service = await self.repository.get_by_id(
request.entity.deal_id, request.entity.service_id
)
return CreateDealServiceResponse(
entity=DealServiceSchema.model_validate(deal_service),
message="Услуга добавлена в сделку",
)
async def update(
self, deal_id: int, service_id: int, data: UpdateDealServiceRequest
) -> UpdateDealServiceResponse:
entity = await self.repository.get_by_id(deal_id, service_id)
await self.repository.update(entity, data.entity)
return UpdateDealServiceResponse(message="Услуга сделки обновлена")
async def delete(self, deal_id: int, service_id: int) -> DeleteDealServiceResponse:
entity = await self.repository.get_by_id(deal_id, service_id)
await self.repository.delete(entity)
return DeleteDealServiceResponse(message="Услуга удалена из сделки")
async def add_services_kit(self, request: DealAddKitRequest) -> DealAddKitResponse:
services_kit_repo = ServicesKitRepository(self.repository.session)
services_kit = await services_kit_repo.get_by_id(request.kit_id)
deal_repo = DealRepository(self.repository.session)
deal = await deal_repo.get_by_id(request.deal_id)
await self.repository.delete_deal_services(request.deal_id)
await self.repository.add_services_kit(deal, services_kit)
return DealAddKitResponse(message="Комплект добавлен в сделку")

View File

@ -0,0 +1,27 @@
from modules.fulfillment_base.models import Marketplace
from modules.fulfillment_base.repositories import MarketplaceRepository
from modules.fulfillment_base.schemas.marketplace import *
from services.mixins import *
class MarketplaceService(
ServiceCrudMixin[
Marketplace,
MarketplaceSchema,
CreateMarketplaceRequest,
UpdateMarketplaceRequest,
]
):
schema_class = MarketplaceSchema
entity_deleted_msg = "Маркетплейс успешно удален"
entity_updated_msg = "Маркетплейс успешно обновлен"
entity_created_msg = "Маркетплейс успешно создан"
def __init__(self, session: AsyncSession):
self.repository = MarketplaceRepository(session)
async def get_base_marketplaces(self) -> GetBaseMarketplacesResponse:
mps = await self.repository.get_base_marketplaces()
return GetBaseMarketplacesResponse(
items=[BaseMarketplaceSchema.model_validate(mp) for mp in mps]
)

View File

@ -0,0 +1,98 @@
import math
from fastapi import UploadFile
from external.s3_uploader import S3Uploader
from modules.fulfillment_base.models import Product
from modules.fulfillment_base.repositories import ProductRepository
from modules.fulfillment_base.schemas.product import *
from schemas.base import PaginationSchema, PaginationInfoSchema
from services.mixins import *
class ProductService(
ServiceCreateMixin[Product, CreateProductRequest, ProductSchema],
ServiceUpdateMixin[Product, UpdateProductRequest],
ServiceDeleteMixin[Product],
):
schema_class = ProductSchema
entity_deleted_msg = "Товар успешно удален"
entity_updated_msg = "Товар успешно обновлен"
entity_created_msg = "Товар успешно создан"
def __init__(self, session: AsyncSession):
self.repository = ProductRepository(session)
async def get_all(
self,
pagination: PaginationSchema,
*filters,
) -> GetProductsResponse:
products, total_items = await self.repository.get_all(
pagination.page,
pagination.items_per_page,
*filters,
)
total_pages = 1
if pagination.items_per_page:
total_pages = math.ceil(total_items / pagination.items_per_page)
return GetProductsResponse(
items=[ProductSchema.model_validate(product) for product in products],
pagination_info=PaginationInfoSchema(
total_pages=total_pages, total_items=total_items
),
)
async def upload_image(
self, product_id: int, upload_file: UploadFile
) -> ProductUploadImageResponse:
product: Product = await self.repository.get_by_id(product_id)
s3_uploader = S3Uploader()
if len(product.images) > 0:
for image in product.images:
s3_key = image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_images(product.images)
image_url = await s3_uploader.upload_from_upload_file_obj(upload_file)
await self.repository.create_image(product_id, image_url)
return ProductUploadImageResponse(
message="Изображение успешно загружено", image_url=image_url
)
async def upload_barcode_image(
self, product_id: int, upload_file: UploadFile
) -> BarcodeUploadImageResponse:
product: Product = await self.repository.get_by_id(product_id)
s3_uploader = S3Uploader()
if product.barcode_image:
s3_key = product.barcode_image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_barcode_image(product.barcode_image)
image_url = await s3_uploader.upload_from_upload_file_obj(upload_file)
await self.repository.create_barcode_image(product_id, image_url)
return BarcodeUploadImageResponse(
message="Изображение штрихкода успешно загружено", image_url=image_url
)
async def delete_barcode_image(self, product_id: int) -> DeleteBarcodeImageResponse:
product: Product = await self.repository.get_by_id(product_id)
if not product.barcode_image:
return DeleteBarcodeImageResponse(
message="У товара нет изображения штрихкода"
)
s3_uploader = S3Uploader()
s3_key = product.barcode_image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_barcode_image(product.barcode_image, True)
return DeleteBarcodeImageResponse(
message="Изображение штрихкода успешно удалено"
)

View File

@ -0,0 +1,81 @@
from sqlalchemy.ext.asyncio import AsyncSession
from modules.fulfillment_base.models import DealProductService
from modules.fulfillment_base.repositories import (
ProductServiceRepository,
ServicesKitRepository,
DealProductRepository,
)
from modules.fulfillment_base.schemas.product_service import *
class ProductServiceService:
schema_class = ProductServiceSchema
def __init__(self, session: AsyncSession):
self.repository = ProductServiceRepository(session)
async def create(
self, request: CreateProductServiceRequest
) -> CreateProductServiceResponse:
await self.repository.create(request.entity)
deal_product = await self.repository.get_by_id(
request.entity.deal_id,
request.entity.product_id,
request.entity.service_id,
)
return CreateProductServiceResponse(
entity=ProductServiceSchema.model_validate(deal_product),
message="Услуга добавлена к товару",
)
async def update(
self,
deal_id: int,
product_id: int,
service_id: int,
data: UpdateProductServiceRequest,
) -> UpdateProductServiceResponse:
entity = await self.repository.get_by_id(deal_id, product_id, service_id)
await self.repository.update(entity, data.entity)
return UpdateProductServiceResponse(message="Услуга обновлена")
async def delete(
self, deal_id: int, product_id: int, service_id: int
) -> DeleteProductServiceResponse:
entity = await self.repository.get_by_id(deal_id, product_id, service_id)
await self.repository.delete(entity)
return DeleteProductServiceResponse(message="Товар удален из сделки")
async def duplicate_product_services(
self, request: ProductServicesDuplicateRequest
) -> ProductServicesDuplicateResponse:
services_to_copy: list[
DealProductService
] = await self.repository.get_product_services(
request.deal_id, request.source_deal_product_id
)
await self.repository.duplicate_services(
request.deal_id, request.target_deal_product_ids, services_to_copy
)
return ProductServicesDuplicateResponse(message="Услуги продублированы")
async def add_services_kit(
self, request: DealProductAddKitRequest
) -> DealProductAddKitResponse:
services_kit_repo = ServicesKitRepository(self.repository.session)
services_kit = await services_kit_repo.get_by_id(request.kit_id)
deal_product_repo = DealProductRepository(self.repository.session)
deal_product = await deal_product_repo.get_by_id(
request.deal_id, request.product_id
)
await self.repository.delete_product_services(
request.deal_id, [request.product_id]
)
await self.repository.add_services_kit(deal_product, services_kit)
return DealProductAddKitResponse(message="Комплект добавлен в товар")

View File

@ -0,0 +1,26 @@
from modules.fulfillment_base.models import Service
from modules.fulfillment_base.repositories import ServiceRepository
from modules.fulfillment_base.schemas.service import (
ServiceSchema,
CreateServiceRequest,
UpdateServiceRequest,
)
from services.mixins import *
class ServiceModelService(
ServiceGetAllMixin[Service, ServiceSchema],
ServiceCreateMixin[Service, CreateServiceRequest, ServiceSchema],
ServiceUpdateMixin[Service, UpdateServiceRequest],
ServiceDeleteMixin[Service],
):
schema_class = ServiceSchema
entity_deleted_msg = "Услуга успешно удалена"
entity_updated_msg = "Услуга успешно обновлена"
entity_created_msg = "Услуга успешно создана"
def __init__(self, session: AsyncSession):
self.repository = ServiceRepository(session)
async def is_soft_delete(self, service: ServiceSchema) -> bool:
return True

View File

@ -0,0 +1,28 @@
from modules.fulfillment_base.models import ServiceCategory
from modules.fulfillment_base.repositories import ServiceCategoryRepository
from modules.fulfillment_base.schemas.service_category import (
ServiceCategorySchema,
CreateServiceCategoryRequest,
UpdateServiceCategoryRequest,
)
from services.mixins import *
class ServiceCategoryService(
ServiceGetAllMixin[ServiceCategory, ServiceCategorySchema],
ServiceCreateMixin[
ServiceCategory, CreateServiceCategoryRequest, ServiceCategorySchema
],
ServiceUpdateMixin[ServiceCategory, UpdateServiceCategoryRequest],
ServiceDeleteMixin[ServiceCategory],
):
schema_class = ServiceCategorySchema
entity_deleted_msg = "Категория услуг успешно удалена"
entity_updated_msg = "Категория услуг успешно обновлена"
entity_created_msg = "Категория услуг успешно создана"
def __init__(self, session: AsyncSession):
self.repository = ServiceCategoryRepository(session)
async def is_soft_delete(self, service: ServiceCategorySchema) -> bool:
return True

View File

@ -0,0 +1,23 @@
from modules.fulfillment_base.models.service import ServicesKit
from modules.fulfillment_base.repositories import ServicesKitRepository
from modules.fulfillment_base.schemas.services_kit import (
ServicesKitSchema,
CreateServicesKitRequest,
UpdateServicesKitRequest,
)
from services.mixins import *
class ServicesKitService(
ServiceCrudMixin[ServicesKit, ServicesKitSchema, CreateServicesKitRequest, UpdateServicesKitRequest]
):
schema_class = ServicesKitSchema
entity_deleted_msg = "Набор услуг успешно удален"
entity_updated_msg = "Набор услуг успешно обновлен"
entity_created_msg = "Набор услуг успешно создан"
def __init__(self, session: AsyncSession):
self.repository = ServicesKitRepository(session)
async def is_soft_delete(self, service: ServicesKitSchema) -> bool:
return False

View File

@ -17,6 +17,19 @@ dependencies = [
"uvicorn[standard]>=0.35.0", "uvicorn[standard]>=0.35.0",
"fastapi-endpoints @ git+https://github.com/vladNed/fastapi-endpoints.git@main", "fastapi-endpoints @ git+https://github.com/vladNed/fastapi-endpoints.git@main",
"uvicorn-worker>=0.3.0", "uvicorn-worker>=0.3.0",
"aioboto3>=15.4.0",
"pymupdf>=1.26.5",
"pdfrw>=0.4",
"fpdf>=1.7.2",
"reportlab>=4.4.4",
"pathlib>=1.0.1",
"starlette>=0.47.2",
"python-multipart>=0.0.20",
"lexorank-py==0.1.1",
"taskiq>=0.11.19",
"taskiq-aio-pika>=0.4.4",
"taskiq-fastapi>=0.3.5",
"taskiq-postgresql>=0.4.0",
] ]
[dependency-groups] [dependency-groups]

View File

@ -1,4 +1,10 @@
from .attr_select import AttrSelectRepository as AttrSelectRepository
from .attribute import AttributeRepository as AttributeRepository
from .board import BoardRepository as BoardRepository from .board import BoardRepository as BoardRepository
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 .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
from .attr_option import AttrOptionRepository as AttrOptionRepository

View File

@ -0,0 +1,21 @@
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeOption
from repositories.mixins import RepCrudMixin
from schemas.attr_option import CreateAttrOptionSchema, UpdateAttrOptionSchema
class AttrOptionRepository(
RepCrudMixin[AttributeOption, CreateAttrOptionSchema, UpdateAttrOptionSchema],
):
session: AsyncSession
entity_class = AttributeOption
entity_not_found_msg = "Опция не найдена"
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
select_id = args[0]
return stmt.where(
AttributeOption.select_id == select_id,
AttributeOption.is_deleted.is_(False),
).order_by(AttributeOption.id)

View File

@ -0,0 +1,16 @@
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeSelect
from repositories.mixins import RepCrudMixin
from schemas.attr_select import UpdateAttrSelectSchema, CreateAttrSelectSchema
class AttrSelectRepository(
RepCrudMixin[AttributeSelect, CreateAttrSelectSchema, UpdateAttrSelectSchema],
):
session: AsyncSession
entity_class = AttributeSelect
def _process_get_all_stmt(self, stmt: Select) -> Select:
return stmt.where(AttributeSelect.is_deleted.is_(False))

212
repositories/attribute.py Normal file
View File

@ -0,0 +1,212 @@
from collections import defaultdict
from sqlalchemy import and_
from sqlalchemy.orm import joinedload
from models import (
Attribute,
AttributeLabel,
AttributeType,
AttributeValue,
module_attribute,
Module,
Project,
)
from repositories.mixins import *
from schemas.attribute import (
CreateAttributeSchema,
UpdateAttributeSchema,
UpdateDealModuleAttributeSchema,
)
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.is_built_in.desc(), Attribute.id)
)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(joinedload(Attribute.type))
async def update(self, attr: Attribute, data: UpdateAttributeSchema) -> Attribute:
return await self._apply_update_data_to_model(
attr, data, with_commit=True, set_if_value_is_not_none=False
)
async def _before_delete(self, attribute: Attribute) -> None:
if attribute.is_built_in:
raise ForbiddenException("Нельзя менять встроенный атрибут")
async def _get_all_attributes_for_deal(
self, project_id
) -> list[tuple[Attribute, int]]:
stmt = (
select(Attribute, Module.id)
.join(Attribute.modules)
.join(Module.projects)
.where(
Module.is_deleted.is_(False),
Project.is_deleted.is_(False),
Project.id == project_id,
)
.distinct(Attribute.id, Module.id)
)
result = await self.session.execute(stmt)
return list(result.all())
async def create_attributes_for_new_deal(
self, deal_id: int, project_id: int
) -> None:
attributes = await self._get_all_attributes_for_deal(project_id)
for attribute, module_id in attributes:
def_val = (
attribute.default_option_id
if attribute.default_option_id is not None
else attribute.default_value
)
if def_val is None:
continue
value = AttributeValue(
attribute_id=attribute.id,
deal_id=deal_id,
module_id=module_id,
value=def_val,
)
self.session.add(value)
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())
async def get_deal_module_attributes(
self, deal_id: int, module_id: int
) -> list[tuple[Attribute, AttributeValue, AttributeLabel]]:
stmt = (
select(
Attribute,
AttributeValue,
AttributeLabel,
)
.join(
module_attribute,
and_(
module_attribute.c.attribute_id == Attribute.id,
module_attribute.c.module_id == module_id,
),
)
.outerjoin(
AttributeValue,
and_(
AttributeValue.attribute_id == Attribute.id,
AttributeValue.module_id == module_id,
AttributeValue.deal_id == deal_id,
),
)
.outerjoin(
AttributeLabel,
and_(
AttributeLabel.attribute_id == Attribute.id,
AttributeLabel.module_id == module_id,
),
)
.where(Attribute.is_deleted.is_(False))
.options(joinedload(Attribute.select))
)
result = await self.session.execute(stmt)
return list(result.all())
async def _get_deals_attribute_values(
self, deal_ids: list[int], module_id: int
) -> list[AttributeValue]:
stmt = (
select(AttributeValue)
.join(Attribute, AttributeValue.attribute_id == Attribute.id)
.where(
AttributeValue.deal_id.in_(deal_ids),
AttributeValue.module_id == module_id,
Attribute.is_deleted.is_(False),
)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def update_or_create_deals_attribute_values(
self,
main_deal_id: int,
group_deal_ids: list[int],
module_id: int,
attributes: list[UpdateDealModuleAttributeSchema],
):
old_deal_attribute_values: list[
AttributeValue
] = await self._get_deals_attribute_values(group_deal_ids, module_id)
dict_old_attrs: dict[int, dict[int, AttributeValue]] = defaultdict(dict)
for deal_attribute in old_deal_attribute_values:
dict_old_attrs[deal_attribute.deal_id][deal_attribute.attribute_id] = (
deal_attribute
)
for attribute in attributes:
if attribute.is_applicable_to_group:
deal_ids_to_apply = group_deal_ids
else:
deal_ids_to_apply = [main_deal_id]
for deal_id in deal_ids_to_apply:
if attribute.attribute_id in dict_old_attrs[deal_id]:
attribute_value = dict_old_attrs[deal_id][attribute.attribute_id]
attribute_value.value = attribute.value
else:
if attribute.value is None:
continue
attribute_value = AttributeValue(
attribute_id=attribute.attribute_id,
deal_id=deal_id,
module_id=module_id,
value=attribute.value,
)
self.session.add(attribute_value)
await self.session.commit()

View File

@ -1,6 +1,15 @@
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
class BaseRepository: class BaseRepository:
session: AsyncSession
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
@staticmethod
def _apply_pagination(query: Select, page: int, items_per_page: int) -> Select:
offset = (page - 1) * items_per_page
query = query.offset(offset).limit(items_per_page)
return query

View File

@ -1,30 +1,20 @@
from typing import Optional from sqlalchemy.orm import selectinload
from sqlalchemy import select
from models import Board from models import Board
from repositories.base import BaseRepository from repositories.mixins import *
from schemas.board import UpdateBoardSchema from schemas.board import UpdateBoardSchema, CreateBoardSchema
class BoardRepository(BaseRepository): class BoardRepository(RepCrudMixin[Board, CreateBoardSchema, UpdateBoardSchema]):
async def get_all(self, project_id: int) -> list[Board]: entity_class = Board
stmt = select(Board).where( entity_not_found_msg = "Доска не найдена"
Board.is_deleted.is_(False), Board.project_id == project_id
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_by_id(self, board_id: int) -> Optional[Board]: def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
stmt = select(Board).where(Board.id == board_id, Board.is_deleted.is_(False)) project_id = args[0]
result = await self.session.execute(stmt) return stmt.where(Board.project_id == project_id).order_by(Board.lexorank)
return result.scalar_one_or_none()
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(selectinload(Board.deals))
async def update(self, board: Board, data: UpdateBoardSchema) -> Board: async def update(self, board: Board, data: UpdateBoardSchema) -> Board:
board.lexorank = data.lexorank if data.lexorank else board.lexorank return await self._apply_update_data_to_model(board, data, True)
board.name = data.name if data.name else board.name
self.session.add(board)
await self.session.commit()
await self.session.refresh(board)
return board

View File

@ -1,27 +1,182 @@
from typing import Optional from sqlalchemy import func
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy import select from models import Deal, Board, DealStatusHistory
from modules.fulfillment_base.models import (
from models import Deal DealService,
from repositories.base import BaseRepository Service,
from schemas.deal import UpdateDealSchema DealProductService,
DealProduct,
)
from repositories import AttributeRepository
from repositories.mixins import *
from schemas.base import SortDir
from schemas.deal import UpdateDealSchema, CreateDealSchema
from utils.sorting import apply_sorting
class DealRepository(BaseRepository): class DealRepository(
async def get_all(self, board_id: int) -> list[Deal]: BaseRepository,
stmt = select(Deal).where(Deal.is_deleted.is_(False), Deal.board_id == board_id) RepDeleteMixin[Deal],
RepCreateMixin[Deal, CreateDealSchema],
RepUpdateMixin[Deal, UpdateDealSchema],
RepGetByIdMixin[Deal],
):
entity_class = Deal
entity_not_found_msg = "Сделка не найдена"
def _get_price_subquery(self):
deal_services_subquery = (
select(
DealService.deal_id,
func.sum(DealService.quantity * DealService.price).label("total_price"),
)
.join(Service)
.group_by(DealService.deal_id)
)
product_services_subquery = select(
select(
DealProductService.deal_id,
func.sum(DealProduct.quantity * DealProductService.price).label(
"total_price"
),
)
.join(DealProduct)
.group_by(DealProductService.deal_id)
.subquery()
)
union_subqueries = deal_services_subquery.union_all(
product_services_subquery
).subquery()
final_subquery = (
select(
union_subqueries.c.deal_id,
func.sum(union_subqueries.c.total_price).label("total_price"),
)
.group_by(union_subqueries.c.deal_id)
.subquery()
)
return final_subquery
def _get_products_quantity_subquery(self):
return (
select(
DealProduct.deal_id,
func.sum(DealProduct.quantity).label("products_quantity"),
)
.group_by(DealProduct.deal_id)
.subquery()
)
async def get_all(
self,
page: Optional[int],
items_per_page: Optional[int],
field: Optional[str],
direction: Optional[SortDir],
project_id: Optional[int],
board_id: Optional[int],
status_id: Optional[int],
id: Optional[int],
name: Optional[str],
) -> tuple[list[tuple[Deal, int, int]], int]:
price_subquery = self._get_price_subquery()
products_quantity_subquery = self._get_products_quantity_subquery()
stmt = (
select(
Deal,
func.coalesce(price_subquery.c.total_price, 0),
func.coalesce(products_quantity_subquery.c.products_quantity, 0),
)
.outerjoin(
price_subquery,
Deal.id == price_subquery.c.deal_id,
)
.outerjoin(
products_quantity_subquery,
Deal.id == products_quantity_subquery.c.deal_id,
)
.options(
joinedload(Deal.status),
joinedload(Deal.board),
selectinload(Deal.group),
selectinload(Deal.tags),
)
.where(Deal.is_deleted.is_(False))
)
if id:
stmt = stmt.where(Deal.id == id)
if project_id:
stmt = stmt.join(Board).where(Board.project_id == project_id)
if board_id:
stmt = stmt.where(Deal.board_id == board_id)
if status_id:
stmt = stmt.where(Deal.status_id == status_id)
if name:
stmt = stmt.where(Deal.name.ilike(f"%{name}%"))
total_items = len((await self.session.execute(stmt)).all())
if field and direction is not None:
stmt = apply_sorting(stmt, Deal, field, direction)
else:
stmt = stmt.order_by(Deal.lexorank)
if page and items_per_page:
stmt = self._apply_pagination(stmt, page, items_per_page)
rows: list[tuple[Deal, int, int]] = (await self.session.execute(stmt)).all()
return rows, total_items
async def get_by_group_id(self, group_id: int) -> list[Deal]:
stmt = (
select(Deal)
.where(Deal.group_id == group_id, Deal.is_deleted.is_(False))
.options(joinedload(Deal.status), joinedload(Deal.board))
)
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_by_id(self, deal_id: int) -> Optional[Deal]: async def get_by_ids(self, deal_ids: list[int]) -> list[Deal]:
stmt = select(Deal).where(Deal.id == deal_id, Deal.is_deleted.is_(False)) stmt = (
select(Deal)
.where(Deal.id.in_(deal_ids), Deal.is_deleted.is_(False))
.options(joinedload(Deal.status), joinedload(Deal.board))
)
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return result.scalar_one_or_none() return list(result.scalars().all())
async def _prepare_create(self, data: CreateDealSchema) -> dict:
dumped = data.model_dump()
del dumped["project_id"]
return dumped
async def _after_create(self, obj: Deal, data: CreateDealSchema) -> None:
attr_repo = AttributeRepository(self.session)
await attr_repo.create_attributes_for_new_deal(obj.id, data.project_id)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(joinedload(Deal.status), joinedload(Deal.board))
async def update_status(self, deal: Deal, status_id: int):
if deal.status_id == status_id:
return
deal.status_history.append(
DealStatusHistory(
from_status_id=deal.status_id,
to_status_id=status_id,
)
)
deal.status_id = status_id
async def update(self, deal: Deal, data: UpdateDealSchema) -> Deal: async def update(self, deal: Deal, data: UpdateDealSchema) -> Deal:
deal.lexorank = data.lexorank if data.lexorank else deal.lexorank fields = ["lexorank", "name", "board_id"]
deal.name = data.name if data.name else deal.name deal = await self._apply_update_data_to_model(deal, data, False, fields)
deal.status_id = data.status_id if data.status_id else deal.status_id
if data.status_id:
await self.update_status(deal, data.status_id)
self.session.add(deal) self.session.add(deal)
await self.session.commit() await self.session.commit()

View File

@ -0,0 +1,54 @@
from models import DealGroup, Deal
from repositories import DealRepository
from repositories.mixins import *
from schemas.deal_group import UpdateDealGroupSchema
class DealGroupRepository(
BaseRepository,
RepGetByIdMixin[DealGroup],
RepUpdateMixin[DealGroup, UpdateDealGroupSchema],
):
entity_class = DealGroup
async def create(self, deals: list[Deal], lexorank: str) -> DealGroup:
group = DealGroup(deals=deals, lexorank=lexorank)
self.session.add(group)
await self.session.flush()
await self.session.refresh(group)
group.name = "Группа ID: " + str(group.id)
await self.session.commit()
return group
async def update(self, entity: DealGroup, data: UpdateDealGroupSchema) -> DealGroup:
if data.status_id:
deal_repo = DealRepository(self.session)
deals = await deal_repo.get_by_group_id(entity.id)
for deal in deals:
await deal_repo.update_status(deal, data.status_id)
del data.status_id
return await self._apply_update_data_to_model(entity, data, True)
async def update_group_deals(
self, group_id: int, old_deals: list[Deal], new_deals: list[Deal]
):
old_set = set(old_deals)
new_set = set(new_deals)
deals_to_remove = old_set - new_set
deals_to_add = new_set - old_set
for deal in deals_to_remove:
deal.group_id = None
for deal in deals_to_add:
deal.group_id = group_id
self.session.add_all([*deals_to_remove, *deals_to_add])
await self.session.commit()
async def delete(self, group_id: int) -> None:
deal_repo = DealRepository(self.session)
deals = await deal_repo.get_by_group_id(group_id)
for deal in deals:
deal.is_deleted = True
self.session.add(deal)
await self.session.commit()

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

141
repositories/mixins.py Normal file
View File

@ -0,0 +1,141 @@
from typing import Type, Optional, TypeVar, Generic
from sqlalchemy import select, Select
from sqlalchemy.ext.asyncio import AsyncSession
from repositories.base import BaseRepository
from schemas.base import BaseSchema
from utils.exceptions import ObjectNotFoundException
EntityType = TypeVar("EntityType")
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseSchema)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseSchema)
class RepBaseMixin(Generic[EntityType]):
session: AsyncSession
class RepDeleteMixin(Generic[EntityType], RepBaseMixin[EntityType]):
async def _before_delete(self, obj: EntityType) -> None:
pass
async def delete(self, obj: EntityType, is_soft: bool) -> None:
await self._before_delete(obj)
if not is_soft:
await self.session.delete(obj)
await self.session.commit()
return
if not hasattr(obj, "is_deleted"):
raise AttributeError(
f"{obj.__class__.__name__} does not support soft delete (missing is_deleted field)"
)
obj.is_deleted = True
self.session.add(obj)
await self.session.commit()
class RepCreateMixin(Generic[EntityType, CreateSchemaType], RepBaseMixin[EntityType]):
entity_class: Type[EntityType]
async def _prepare_create(self, data: CreateSchemaType) -> dict:
return data.model_dump()
async def _after_create(self, obj: EntityType, data: CreateSchemaType) -> None:
pass
async def create(self, data: CreateSchemaType) -> int:
prepared_data = await self._prepare_create(data)
obj = self.entity_class(**prepared_data)
self.session.add(obj)
await self.session.flush()
await self.session.refresh(obj)
await self._after_create(obj, data)
await self.session.commit()
await self.session.refresh(obj)
return obj.id
class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityType]):
async def _apply_update_data_to_model(
self,
model: EntityType,
data: UpdateSchemaType,
with_commit: Optional[bool] = False,
fields: Optional[list[str]] = None,
set_if_value_is_not_none: Optional[bool] = True,
) -> EntityType:
if fields is None:
fields = data.model_dump().keys()
for field in fields:
value = getattr(data, field)
if not set_if_value_is_not_none or value is not None:
setattr(model, field, value)
if with_commit:
self.session.add(model)
await self.session.commit()
await self.session.refresh(model)
return model
async def update(self, entity: EntityType, data: UpdateSchemaType) -> EntityType:
return await self._apply_update_data_to_model(entity, data, True)
class RepGetByIdMixin(Generic[EntityType], RepBaseMixin[EntityType]):
entity_class: Type[EntityType]
entity_not_found_msg = "Entity not found"
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt
async def get_by_id(
self, item_id: int, raise_if_not_found: Optional[bool] = True
) -> Optional[EntityType]:
stmt = select(self.entity_class).where(self.entity_class.id == item_id)
if hasattr(self, "is_deleted"):
stmt = stmt.where(self.entity_class.is_deleted.is_(False))
stmt = self._process_get_by_id_stmt(stmt)
result = (await self.session.execute(stmt)).scalar_one_or_none()
if result is None and raise_if_not_found:
raise ObjectNotFoundException(self.entity_not_found_msg)
return result
class RepGetAllMixin(Generic[EntityType], RepBaseMixin[EntityType]):
entity_class: Type[EntityType]
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
return stmt
def _process_get_all_stmt(self, stmt: Select) -> Select:
return stmt
async def get_all(self, *args) -> list[EntityType]:
stmt = select(self.entity_class)
if hasattr(self, "is_deleted"):
stmt = stmt.where(self.entity_class.is_deleted.is_(False))
if args:
stmt = self._process_get_all_stmt_with_args(stmt, *args)
else:
stmt = self._process_get_all_stmt(stmt)
result = await self.session.execute(stmt)
return list(result.scalars().all())
class RepCrudMixin(
Generic[EntityType, CreateSchemaType, UpdateSchemaType],
BaseRepository,
RepGetAllMixin[EntityType],
RepCreateMixin[EntityType, CreateSchemaType],
RepUpdateMixin[EntityType, UpdateSchemaType],
RepGetByIdMixin[EntityType],
RepDeleteMixin[EntityType],
):
pass

101
repositories/module.py Normal file
View File

@ -0,0 +1,101 @@
import uuid
from sqlalchemy import and_, or_
from sqlalchemy.orm import selectinload
from models import Module, Attribute, AttributeLabel, module_attribute, ModuleTab
from models.module import DeviceType
from repositories.mixins import *
from schemas.module import UpdateModuleCommonInfoSchema, CreateModuleSchema
class ModuleRepository(
RepCrudMixin[Module, CreateModuleSchema, UpdateModuleCommonInfoSchema]
):
entity_class = Module
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(selectinload(Module.attributes).joinedload(Attribute.type))
async def get_by_ids(self, ids: list[int]) -> list[Module]:
stmt = select(Module).where(Module.id.in_(ids))
modules = await self.session.scalars(stmt)
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),
or_(Attribute.id.is_(None), Attribute.is_deleted.is_(False)),
)
.order_by(Module.id, 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 _prepare_create(self, data: CreateSchemaType) -> dict:
dump = data.model_dump()
dump["key"] = str(uuid.uuid4())
return dump
async def _after_create(self, module: Module, _) -> None:
tab = ModuleTab(
key=module.key,
label=module.label,
icon_name=None,
module_id=module.id,
device=DeviceType.BOTH,
)
self.session.add(tab)
async def get_module_tabs_by_module_id(self, module_id: int) -> list[ModuleTab]:
stmt = select(ModuleTab).where(ModuleTab.module_id == module_id)
result = await self.session.scalars(stmt)
return list(result.all())
async def update(
self, module: Module, data: UpdateModuleCommonInfoSchema
) -> Module:
tabs = await self.get_module_tabs_by_module_id(module.id)
for tab in tabs:
tab.label = data.label
self.session.add(tab)
return await self._apply_update_data_to_model(module, data, True)
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()

View File

@ -1,11 +1,36 @@
from sqlalchemy import select from sqlalchemy.orm import selectinload
from models import DealTag
from models.project import Project from models.project import Project
from repositories.base import BaseRepository from repositories.module import ModuleRepository
from repositories.mixins import *
from schemas.project import CreateProjectSchema, UpdateProjectSchema
class ProjectRepository(BaseRepository): class ProjectRepository(
async def get_all(self) -> list[Project]: RepCrudMixin[Project, CreateProjectSchema, UpdateProjectSchema]
stmt = select(Project).where(Project.is_deleted.is_(False)) ):
result = await self.session.execute(stmt) entity_class = Project
return list(result.scalars().all()) 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 self._apply_options(stmt).order_by(Project.id)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return self._apply_options(stmt)
async def update(self, project: Project, data: UpdateProjectSchema) -> Project:
if data.modules is not None:
modules = data.modules
module_ids = [module.id for module in modules]
data.modules = await ModuleRepository(
self.session
).get_by_ids(module_ids)
return await self._apply_update_data_to_model(project, data, True)

View File

@ -1,32 +1,40 @@
from typing import Optional from sqlalchemy import func
from sqlalchemy import select from models import Status, Deal, DealStatusHistory
from repositories.mixins import *
from models import Status from schemas.status import UpdateStatusSchema, CreateStatusSchema
from repositories.base import BaseRepository
from schemas.status import UpdateStatusSchema
class StatusRepository(BaseRepository): class StatusRepository(RepCrudMixin[Status, CreateStatusSchema, UpdateStatusSchema]):
entity_class = Status
entity_not_found_msg = "Статус не найден"
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
board_id = args[0]
return stmt.where(Status.board_id == board_id).order_by(Status.lexorank)
async def get_all(self, board_id: int) -> list[Status]: async def get_all(self, board_id: int) -> list[Status]:
stmt = select(Status).where( stmt = (
Status.is_deleted.is_(False), Status.board_id == board_id select(Status)
.where(Status.is_deleted.is_(False), Status.board_id == board_id)
.order_by(Status.lexorank)
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_by_id(self, status_id: int) -> Optional[Status]: async def get_deals_count(self, status_id: int) -> int:
stmt = select(Status).where( stmt = select(func.count(Deal.id)).where(Deal.status_id == status_id)
Status.id == status_id, Status.is_deleted.is_(False)
)
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return result.scalar_one_or_none() return result.scalar()
async def update(self, status: Status, data: UpdateStatusSchema) -> Status: async def update(self, status: Status, data: UpdateStatusSchema) -> Status:
status.lexorank = data.lexorank if data.lexorank else status.lexorank return await self._apply_update_data_to_model(status, data, True)
status.name = data.name if data.name else status.name
self.session.add(status) async def get_status_history(self, deal_id: int) -> list[DealStatusHistory]:
await self.session.commit() stmt = (
await self.session.refresh(status) select(DealStatusHistory)
return status .where(DealStatusHistory.deal_id == deal_id)
.order_by(DealStatusHistory.created_at)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@ -1,34 +0,0 @@
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.board import GetBoardsResponse, UpdateBoardRequest, UpdateBoardResponse
from services import BoardService
board_router = APIRouter(
tags=["board"],
)
@board_router.get(
"/{projectId}",
response_model=GetBoardsResponse,
operation_id="get_boards",
)
async def get_boards(
session: SessionDependency,
project_id: int = Path(alias="projectId"),
):
return await BoardService(session).get_boards(project_id)
@board_router.patch(
"/{boardId}",
response_model=UpdateBoardResponse,
operation_id="update_board",
)
async def update_board(
session: SessionDependency,
request: UpdateBoardRequest,
board_id: int = Path(alias="boardId"),
):
return await BoardService(session).update_board(board_id, request)

0
routers/crm/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,56 @@
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.attr_option import *
from services import AttrOptionService
router = APIRouter(tags=["attr_option"])
@router.get(
"/{selectId}",
response_model=GetAllAttrSelectOptionsResponse,
operation_id="get_attr_options",
)
async def get_attr_options(
session: SessionDependency,
select_id: int = Path(alias="selectId"),
):
return await AttrOptionService(session).get_all(select_id)
@router.post(
"/",
response_model=CreateAttrOptionResponse,
operation_id="create_attr_option",
)
async def create_attr_select(
session: SessionDependency,
request: CreateAttrOptionRequest,
):
return await AttrOptionService(session).create(request)
@router.patch(
"/{pk}",
response_model=UpdateAttrOptionResponse,
operation_id="update_attr_option",
)
async def update_attr_option(
session: SessionDependency,
request: UpdateAttrOptionRequest,
pk: int = Path(),
):
return await AttrOptionService(session).update(pk, request)
@router.delete(
"/{pk}",
response_model=DeleteAttrOptionResponse,
operation_id="delete_attr_option",
)
async def delete_attr_option(
session: SessionDependency,
pk: int = Path(),
):
return await AttrOptionService(session).delete(pk)

View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.attr_select import *
from services import AttrSelectService
router = APIRouter(tags=["attr_select"])
@router.get(
"/",
response_model=GetAllAttrSelectsResponse,
operation_id="get_attr_selects",
)
async def get_attr_selects(
session: SessionDependency,
):
return await AttrSelectService(session).get_all()
@router.post(
"/",
response_model=CreateAttrSelectResponse,
operation_id="create_attr_select",
)
async def create_attr_select(
session: SessionDependency,
request: CreateAttrSelectRequest,
):
return await AttrSelectService(session).create(request)
@router.patch(
"/{pk}",
response_model=UpdateAttrSelectResponse,
operation_id="update_attr_select",
)
async def update_attr_select(
session: SessionDependency,
request: UpdateAttrSelectRequest,
pk: int = Path(),
):
return await AttrSelectService(session).update(pk, request)
@router.delete(
"/{pk}",
response_model=DeleteAttrSelectResponse,
operation_id="delete_attr_select",
)
async def delete_attr_select(
session: SessionDependency,
pk: int = Path(),
):
return await AttrSelectService(session).delete(pk)

109
routers/crm/v1/attribute.py Normal file
View File

@ -0,0 +1,109 @@
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()
@router.get(
"/deal/{dealId}/module/{moduleId}",
response_model=GetDealModuleAttributesResponse,
operation_id="get_deal_module_attributes",
)
async def get_deal_module_attributes(
session: SessionDependency,
deal_id: int = Path(alias="dealId"),
module_id: int = Path(alias="moduleId"),
):
return await AttributeService(session).get_deal_module_attributes(
deal_id, module_id
)
@router.post(
"/deal/{dealId}/module/{moduleId}",
response_model=UpdateDealModuleAttributesResponse,
operation_id="update_deal_module_attributes",
)
async def update_deal_module_attributes(
session: SessionDependency,
request: UpdateDealModuleAttributesRequest,
deal_id: int = Path(alias="dealId"),
module_id: int = Path(alias="moduleId"),
):
return await AttributeService(session).update_deal_module_attributes(
deal_id, module_id, request
)

Some files were not shown because too many files have changed in this diff Show More