Compare commits
77 Commits
812e30a2f8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eab801e41b | |||
| 7defcbdbd4 | |||
| ee18f16250 | |||
| c266814c96 | |||
| a7bda3d9f6 | |||
| be878717e5 | |||
| 2700538945 | |||
| 80a74ac8e6 | |||
| ef657c4939 | |||
| 36b3e056dc | |||
| 307e6573e3 | |||
| 82fcd6e8cb | |||
| 0e8c9077c9 | |||
| 9b109a7270 | |||
| c1196497d4 | |||
| 759a8d6478 | |||
| a579ae4145 | |||
| fcaa7fe177 | |||
| 281600c72d | |||
| 62aeebf079 | |||
| 83f3b55f49 | |||
| 90c0bae8f1 | |||
| 34ac2a0a69 | |||
| 79a1dff720 | |||
| 44f00b1057 | |||
| ffee658349 | |||
| d7c7d1775f | |||
| 6b1b4109c6 | |||
| 35869e2ea5 | |||
| d8eba188c9 | |||
| 636821e74a | |||
| fbb0c72bce | |||
| bd4f4138be | |||
| 4c871e1e1b | |||
| 6b0f8a1aa5 | |||
| 7d6155ff6c | |||
| 986712d5b7 | |||
| 66b50fb951 | |||
| 9c9b3f4706 | |||
| c2594f9d55 | |||
| fbadddeada | |||
| 8cf589c54e | |||
| 22b8428035 | |||
| 6b3d124adf | |||
| 44f315b4a0 | |||
| 1df57c69c1 | |||
| 7eeb24f8ff | |||
| 8794241541 | |||
| 1a9dbd857a | |||
| 98d3026e0d | |||
| 276626d6f7 | |||
| be8052848c | |||
| d73748deab | |||
| 67634836dc | |||
| 7a76da4058 | |||
| 7990e7d460 | |||
| c1d3ac98f0 | |||
| e5be35be35 | |||
| c632fb8037 | |||
| fbab70d6c1 | |||
| 404a58735d | |||
| de5ffed7de | |||
| b9ae3bc18a | |||
| 57c3ada2fa | |||
| 93141da22c | |||
| 5fbd6d6185 | |||
| 4c7a997be6 | |||
| b776ad6758 | |||
| b4b29d448b | |||
| dd1f4145ae | |||
| c862544ae0 | |||
| c5e4dea52c | |||
| 5e20da8356 | |||
| 71c0901909 | |||
| 3b1b6f0523 | |||
| 2fed828768 | |||
| 734099165b |
11
.env.example
11
.env.example
@ -5,3 +5,14 @@ PG_DATABASE=
|
||||
PG_HOST=
|
||||
|
||||
SECRET_KEY=
|
||||
|
||||
S3_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=
|
||||
S3_BUCKET=
|
||||
|
||||
REDIS_PASSWORD=
|
||||
REDIS_HOST=
|
||||
REDIS_DB=
|
||||
REDIS_URL=
|
||||
|
||||
BIN
assets/fonts/Arial Nova Cond.ttf
Normal file
BIN
assets/fonts/Arial Nova Cond.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DejaVuSans.ttf
Normal file
BIN
assets/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
@ -12,3 +12,11 @@ PG_DATABASE = os.environ.get("PG_DATABASE")
|
||||
PG_HOST = os.environ.get("PG_HOST")
|
||||
|
||||
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")
|
||||
|
||||
@ -4,8 +4,10 @@ from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.sorting import sorting_parameters
|
||||
|
||||
SessionDependency = Annotated[AsyncSession, Depends(get_session)]
|
||||
PaginationDependency = Annotated[PaginationSchema, Depends(pagination_parameters)]
|
||||
SortingDependency = Annotated[SortingSchema, Depends(sorting_parameters)]
|
||||
|
||||
4
core/__init__.py
Normal file
4
core/__init__.py
Normal 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
11
core/app_settings.py
Normal 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
17
core/exceptions.py
Normal 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
16
core/lifespan.py
Normal 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
19
core/middlewares.py
Normal 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
0
external/__init__.py
vendored
Normal file
1
external/s3_uploader/__init__.py
vendored
Normal file
1
external/s3_uploader/__init__.py
vendored
Normal file
@ -0,0 +1 @@
|
||||
from .uploader import S3Uploader as S3Uploader
|
||||
81
external/s3_uploader/uploader.py
vendored
Normal file
81
external/s3_uploader/uploader.py
vendored
Normal 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}")
|
||||
@ -10,7 +10,7 @@ from logger.constants import (
|
||||
from logger.formatter import JsonFormatter
|
||||
from logger.gunzip_rotating_file_handler import GunZipRotatingFileHandler
|
||||
from logger.filters import LevelFilter, RequestIdFilter
|
||||
from core.singleton import Singleton
|
||||
from utils.singleton import Singleton
|
||||
|
||||
|
||||
class LoggerBuilder(metaclass=Singleton):
|
||||
|
||||
40
main.py
40
main.py
@ -1,32 +1,28 @@
|
||||
import taskiq_fastapi
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi_endpoints import auto_include_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:
|
||||
app = FastAPI(
|
||||
separate_input_output_schemas=True,
|
||||
default_response_class=ORJSONResponse,
|
||||
root_path="/api"
|
||||
)
|
||||
default_response_class=settings.DEFAULT_RESPONSE_CLASS,
|
||||
root_path=settings.ROOT_PATH,
|
||||
# lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(
|
||||
GZipMiddleware,
|
||||
minimum_size=1_000,
|
||||
)
|
||||
register_middlewares(app)
|
||||
register_exception_handlers(app)
|
||||
auto_include_routers(app, routers, full_path=True)
|
||||
app.mount("/static", StaticFiles(directory=settings.STATIC_DIR), name="static")
|
||||
|
||||
auto_include_routers(app, routers)
|
||||
taskiq_fastapi.init(broker, "main:app")
|
||||
return app
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
app = create_app()
|
||||
|
||||
@ -1,9 +1,34 @@
|
||||
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 .board import Board as Board
|
||||
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 .status import Status as Status
|
||||
from .status import Status as Status, DealStatusHistory as DealStatusHistory
|
||||
|
||||
configure_mappers()
|
||||
|
||||
43
models/attr_select.py
Normal file
43
models/attr_select.py
Normal 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
139
models/attribute.py
Normal 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
6
models/auth.py
Normal file
@ -0,0 +1,6 @@
|
||||
from models.base import BaseModel
|
||||
from models.mixins import IdMixin, SoftDeleteMixin
|
||||
|
||||
|
||||
class User(BaseModel, IdMixin, SoftDeleteMixin):
|
||||
__tablename__ = "users"
|
||||
@ -13,10 +13,10 @@ if TYPE_CHECKING:
|
||||
class Board(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
|
||||
__tablename__ = "boards"
|
||||
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
lexorank: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column()
|
||||
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")
|
||||
|
||||
statuses: Mapped[list["Status"]] = relationship(back_populates="board")
|
||||
|
||||
@ -1,29 +1,69 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
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.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
|
||||
|
||||
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):
|
||||
__tablename__ = "deals"
|
||||
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
lexorank: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column()
|
||||
lexorank: Mapped[str] = mapped_column()
|
||||
|
||||
status_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("statuses.id"),
|
||||
nullable=False,
|
||||
comment="Текущий статус",
|
||||
)
|
||||
status: Mapped["Status"] = relationship(lazy="noload")
|
||||
status: Mapped["Status"] = relationship()
|
||||
|
||||
board_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("boards.id"), nullable=True, server_default="1"
|
||||
board_id: Mapped[Optional[int]] = mapped_column(
|
||||
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
17
models/deal_group.py
Normal 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
51
models/deal_tag.py
Normal 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"),
|
||||
)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -8,11 +9,26 @@ class IdMixin:
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
|
||||
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
89
models/module.py
Normal 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)
|
||||
@ -6,15 +6,29 @@ from models.base import BaseModel
|
||||
from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models import Board
|
||||
from models import Board, Module, DealTag
|
||||
|
||||
|
||||
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column()
|
||||
|
||||
boards: Mapped[list["Board"]] = relationship(
|
||||
back_populates="project",
|
||||
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",
|
||||
)
|
||||
|
||||
@ -4,17 +4,43 @@ from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from models.base import BaseModel
|
||||
from models.mixins import SoftDeleteMixin, IdMixin
|
||||
from models.mixins import SoftDeleteMixin, IdMixin, CreatedAtMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models import Board
|
||||
from models import Board, Deal
|
||||
|
||||
|
||||
class Status(BaseModel, IdMixin, SoftDeleteMixin):
|
||||
__tablename__ = "statuses"
|
||||
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
lexorank: Mapped[str] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column()
|
||||
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")
|
||||
|
||||
|
||||
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
0
modules/__init__.py
Normal file
0
modules/clients/__init__.py
Normal file
0
modules/clients/__init__.py
Normal file
1
modules/clients/models/__init__.py
Normal file
1
modules/clients/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .client import Client as Client, ClientDetails as ClientDetails
|
||||
47
modules/clients/models/client.py
Normal file
47
modules/clients/models/client.py
Normal 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()
|
||||
1
modules/clients/repositories/__init__.py
Normal file
1
modules/clients/repositories/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .client import ClientRepository as ClientRepository
|
||||
51
modules/clients/repositories/client.py
Normal file
51
modules/clients/repositories/client.py
Normal 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)
|
||||
75
modules/clients/schemas/client.py
Normal file
75
modules/clients/schemas/client.py
Normal 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
|
||||
1
modules/clients/services/__init__.py
Normal file
1
modules/clients/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .client import ClientService as ClientService
|
||||
26
modules/clients/services/client.py
Normal file
26
modules/clients/services/client.py
Normal 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
|
||||
0
modules/fulfillment_base/__init__.py
Normal file
0
modules/fulfillment_base/__init__.py
Normal file
2
modules/fulfillment_base/barcodes_pdf_gen/__init__.py
Normal file
2
modules/fulfillment_base/barcodes_pdf_gen/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .barcode_pdf_generator import BarcodePdfGenerator as BarcodePdfGenerator
|
||||
from .types import BarcodeData as BarcodeData
|
||||
@ -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
|
||||
21
modules/fulfillment_base/barcodes_pdf_gen/types.py
Normal file
21
modules/fulfillment_base/barcodes_pdf_gen/types.py
Normal 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
|
||||
1
modules/fulfillment_base/enums/__init__.py
Normal file
1
modules/fulfillment_base/enums/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .service import *
|
||||
7
modules/fulfillment_base/enums/service.py
Normal file
7
modules/fulfillment_base/enums/service.py
Normal file
@ -0,0 +1,7 @@
|
||||
from enum import IntEnum, unique
|
||||
|
||||
|
||||
@unique
|
||||
class ServiceType(IntEnum):
|
||||
DEAL_SERVICE = 0
|
||||
PRODUCT_SERVICE = 1
|
||||
17
modules/fulfillment_base/models/__init__.py
Normal file
17
modules/fulfillment_base/models/__init__.py
Normal 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
|
||||
36
modules/fulfillment_base/models/barcode.py
Normal file
36
modules/fulfillment_base/models/barcode.py
Normal 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()
|
||||
42
modules/fulfillment_base/models/barcode_template.py
Normal file
42
modules/fulfillment_base/models/barcode_template.py
Normal 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")
|
||||
62
modules/fulfillment_base/models/deal_product.py
Normal file
62
modules/fulfillment_base/models/deal_product.py
Normal 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",
|
||||
),
|
||||
)
|
||||
28
modules/fulfillment_base/models/deal_service.py
Normal file
28
modules/fulfillment_base/models/deal_service.py
Normal 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="Фиксированная цена"
|
||||
)
|
||||
32
modules/fulfillment_base/models/marketplace.py
Normal file
32
modules/fulfillment_base/models/marketplace.py
Normal 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)
|
||||
64
modules/fulfillment_base/models/product.py
Normal file
64
modules/fulfillment_base/models/product.py
Normal 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()
|
||||
69
modules/fulfillment_base/models/service.py
Normal file
69
modules/fulfillment_base/models/service.py
Normal 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="До количества")
|
||||
9
modules/fulfillment_base/repositories/__init__.py
Normal file
9
modules/fulfillment_base/repositories/__init__.py
Normal 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
|
||||
119
modules/fulfillment_base/repositories/barcode_template.py
Normal file
119
modules/fulfillment_base/repositories/barcode_template.py
Normal 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())
|
||||
63
modules/fulfillment_base/repositories/deal_product.py
Normal file
63
modules/fulfillment_base/repositories/deal_product.py
Normal 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()
|
||||
71
modules/fulfillment_base/repositories/deal_service.py
Normal file
71
modules/fulfillment_base/repositories/deal_service.py
Normal 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()
|
||||
62
modules/fulfillment_base/repositories/marketplace.py
Normal file
62
modules/fulfillment_base/repositories/marketplace.py
Normal 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)
|
||||
132
modules/fulfillment_base/repositories/product.py
Normal file
132
modules/fulfillment_base/repositories/product.py
Normal 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
|
||||
105
modules/fulfillment_base/repositories/product_service.py
Normal file
105
modules/fulfillment_base/repositories/product_service.py
Normal 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()
|
||||
78
modules/fulfillment_base/repositories/service.py
Normal file
78
modules/fulfillment_base/repositories/service.py
Normal 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()
|
||||
18
modules/fulfillment_base/repositories/service_category.py
Normal file
18
modules/fulfillment_base/repositories/service_category.py
Normal 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)
|
||||
43
modules/fulfillment_base/repositories/services_kit.py
Normal file
43
modules/fulfillment_base/repositories/services_kit.py
Normal 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)
|
||||
82
modules/fulfillment_base/schemas/barcode_template.py
Normal file
82
modules/fulfillment_base/schemas/barcode_template.py
Normal 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
|
||||
64
modules/fulfillment_base/schemas/deal_product.py
Normal file
64
modules/fulfillment_base/schemas/deal_product.py
Normal 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
|
||||
72
modules/fulfillment_base/schemas/deal_service.py
Normal file
72
modules/fulfillment_base/schemas/deal_service.py
Normal 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
|
||||
77
modules/fulfillment_base/schemas/marketplace.py
Normal file
77
modules/fulfillment_base/schemas/marketplace.py
Normal 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
|
||||
146
modules/fulfillment_base/schemas/product.py
Normal file
146
modules/fulfillment_base/schemas/product.py
Normal 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
|
||||
79
modules/fulfillment_base/schemas/product_service.py
Normal file
79
modules/fulfillment_base/schemas/product_service.py
Normal 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
|
||||
77
modules/fulfillment_base/schemas/service.py
Normal file
77
modules/fulfillment_base/schemas/service.py
Normal 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
|
||||
59
modules/fulfillment_base/schemas/service_category.py
Normal file
59
modules/fulfillment_base/schemas/service_category.py
Normal 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
|
||||
60
modules/fulfillment_base/schemas/services_kit.py
Normal file
60
modules/fulfillment_base/schemas/services_kit.py
Normal 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
|
||||
10
modules/fulfillment_base/services/__init__.py
Normal file
10
modules/fulfillment_base/services/__init__.py
Normal 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
|
||||
96
modules/fulfillment_base/services/barcode_printer_service.py
Normal file
96
modules/fulfillment_base/services/barcode_printer_service.py
Normal 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
|
||||
36
modules/fulfillment_base/services/barcode_template.py
Normal file
36
modules/fulfillment_base/services/barcode_template.py
Normal 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]
|
||||
)
|
||||
37
modules/fulfillment_base/services/deal_product.py
Normal file
37
modules/fulfillment_base/services/deal_product.py
Normal 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="Товар удален из сделки")
|
||||
53
modules/fulfillment_base/services/deal_service.py
Normal file
53
modules/fulfillment_base/services/deal_service.py
Normal 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="Комплект добавлен в сделку")
|
||||
27
modules/fulfillment_base/services/marketplace.py
Normal file
27
modules/fulfillment_base/services/marketplace.py
Normal 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]
|
||||
)
|
||||
98
modules/fulfillment_base/services/product.py
Normal file
98
modules/fulfillment_base/services/product.py
Normal 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="Изображение штрихкода успешно удалено"
|
||||
)
|
||||
81
modules/fulfillment_base/services/product_service.py
Normal file
81
modules/fulfillment_base/services/product_service.py
Normal 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="Комплект добавлен в товар")
|
||||
26
modules/fulfillment_base/services/service.py
Normal file
26
modules/fulfillment_base/services/service.py
Normal 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
|
||||
28
modules/fulfillment_base/services/service_category.py
Normal file
28
modules/fulfillment_base/services/service_category.py
Normal 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
|
||||
23
modules/fulfillment_base/services/services_kit.py
Normal file
23
modules/fulfillment_base/services/services_kit.py
Normal 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
|
||||
@ -17,6 +17,19 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.35.0",
|
||||
"fastapi-endpoints @ git+https://github.com/vladNed/fastapi-endpoints.git@main",
|
||||
"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]
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
from .attr_select import AttrSelectRepository as AttrSelectRepository
|
||||
from .attribute import AttributeRepository as AttributeRepository
|
||||
from .board import BoardRepository as BoardRepository
|
||||
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 .status import StatusRepository as StatusRepository
|
||||
from .attr_option import AttrOptionRepository as AttrOptionRepository
|
||||
|
||||
21
repositories/attr_option.py
Normal file
21
repositories/attr_option.py
Normal 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)
|
||||
16
repositories/attr_select.py
Normal file
16
repositories/attr_select.py
Normal 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
212
repositories/attribute.py
Normal 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()
|
||||
@ -1,6 +1,15 @@
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class BaseRepository:
|
||||
session: AsyncSession
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
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
|
||||
|
||||
@ -1,30 +1,20 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models import Board
|
||||
from repositories.base import BaseRepository
|
||||
from schemas.board import UpdateBoardSchema
|
||||
from repositories.mixins import *
|
||||
from schemas.board import UpdateBoardSchema, CreateBoardSchema
|
||||
|
||||
|
||||
class BoardRepository(BaseRepository):
|
||||
async def get_all(self, project_id: int) -> list[Board]:
|
||||
stmt = select(Board).where(
|
||||
Board.is_deleted.is_(False), Board.project_id == project_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
class BoardRepository(RepCrudMixin[Board, CreateBoardSchema, UpdateBoardSchema]):
|
||||
entity_class = Board
|
||||
entity_not_found_msg = "Доска не найдена"
|
||||
|
||||
async def get_by_id(self, board_id: int) -> Optional[Board]:
|
||||
stmt = select(Board).where(Board.id == board_id, Board.is_deleted.is_(False))
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
|
||||
project_id = args[0]
|
||||
return stmt.where(Board.project_id == project_id).order_by(Board.lexorank)
|
||||
|
||||
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:
|
||||
board.lexorank = data.lexorank if data.lexorank else board.lexorank
|
||||
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
|
||||
return await self._apply_update_data_to_model(board, data, True)
|
||||
|
||||
@ -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
|
||||
from repositories.base import BaseRepository
|
||||
from schemas.deal import UpdateDealSchema
|
||||
from models import Deal, Board, DealStatusHistory
|
||||
from modules.fulfillment_base.models import (
|
||||
DealService,
|
||||
Service,
|
||||
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):
|
||||
async def get_all(self, board_id: int) -> list[Deal]:
|
||||
stmt = select(Deal).where(Deal.is_deleted.is_(False), Deal.board_id == board_id)
|
||||
class DealRepository(
|
||||
BaseRepository,
|
||||
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)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, deal_id: int) -> Optional[Deal]:
|
||||
stmt = select(Deal).where(Deal.id == deal_id, Deal.is_deleted.is_(False))
|
||||
async def get_by_ids(self, deal_ids: list[int]) -> list[Deal]:
|
||||
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)
|
||||
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:
|
||||
deal.lexorank = data.lexorank if data.lexorank else deal.lexorank
|
||||
deal.name = data.name if data.name else deal.name
|
||||
deal.status_id = data.status_id if data.status_id else deal.status_id
|
||||
fields = ["lexorank", "name", "board_id"]
|
||||
deal = await self._apply_update_data_to_model(deal, data, False, fields)
|
||||
|
||||
if data.status_id:
|
||||
await self.update_status(deal, data.status_id)
|
||||
|
||||
self.session.add(deal)
|
||||
await self.session.commit()
|
||||
|
||||
54
repositories/deal_group.py
Normal file
54
repositories/deal_group.py
Normal 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
62
repositories/deal_tag.py
Normal 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
141
repositories/mixins.py
Normal 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
101
repositories/module.py
Normal 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()
|
||||
@ -1,11 +1,36 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models import DealTag
|
||||
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):
|
||||
async def get_all(self) -> list[Project]:
|
||||
stmt = select(Project).where(Project.is_deleted.is_(False))
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
class ProjectRepository(
|
||||
RepCrudMixin[Project, CreateProjectSchema, UpdateProjectSchema]
|
||||
):
|
||||
entity_class = Project
|
||||
entity_not_found_msg = "Проект не найден"
|
||||
|
||||
def _apply_options(self, stmt: Select) -> Select:
|
||||
return stmt.options(
|
||||
selectinload(Project.boards),
|
||||
selectinload(Project.tags).joinedload(DealTag.tag_color),
|
||||
)
|
||||
|
||||
def _process_get_all_stmt(self, stmt: Select) -> Select:
|
||||
return 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)
|
||||
|
||||
@ -1,32 +1,40 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy import func
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from models import Status
|
||||
from repositories.base import BaseRepository
|
||||
from schemas.status import UpdateStatusSchema
|
||||
from models import Status, Deal, DealStatusHistory
|
||||
from repositories.mixins import *
|
||||
from schemas.status import UpdateStatusSchema, CreateStatusSchema
|
||||
|
||||
|
||||
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]:
|
||||
stmt = select(Status).where(
|
||||
Status.is_deleted.is_(False), Status.board_id == board_id
|
||||
stmt = (
|
||||
select(Status)
|
||||
.where(Status.is_deleted.is_(False), Status.board_id == board_id)
|
||||
.order_by(Status.lexorank)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, status_id: int) -> Optional[Status]:
|
||||
stmt = select(Status).where(
|
||||
Status.id == status_id, Status.is_deleted.is_(False)
|
||||
)
|
||||
async def get_deals_count(self, status_id: int) -> int:
|
||||
stmt = select(func.count(Deal.id)).where(Deal.status_id == status_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalar()
|
||||
|
||||
async def update(self, status: Status, data: UpdateStatusSchema) -> Status:
|
||||
status.lexorank = data.lexorank if data.lexorank else status.lexorank
|
||||
status.name = data.name if data.name else status.name
|
||||
return await self._apply_update_data_to_model(status, data, True)
|
||||
|
||||
self.session.add(status)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(status)
|
||||
return status
|
||||
async def get_status_history(self, deal_id: int) -> list[DealStatusHistory]:
|
||||
stmt = (
|
||||
select(DealStatusHistory)
|
||||
.where(DealStatusHistory.deal_id == deal_id)
|
||||
.order_by(DealStatusHistory.created_at)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@ -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
0
routers/crm/__init__.py
Normal file
0
routers/crm/v1/__init__.py
Normal file
0
routers/crm/v1/__init__.py
Normal file
56
routers/crm/v1/attr_option.py
Normal file
56
routers/crm/v1/attr_option.py
Normal 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)
|
||||
55
routers/crm/v1/attr_select.py
Normal file
55
routers/crm/v1/attr_select.py
Normal 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
109
routers/crm/v1/attribute.py
Normal 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
Reference in New Issue
Block a user