Compare commits

...

26 Commits

Author SHA1 Message Date
eab801e41b feat: lexoranks for attr options 2025-11-05 20:52:50 +04:00
7defcbdbd4 fix: removed celery tasks tables 2025-11-04 15:05:48 +04:00
ee18f16250 fix: fixed deleted attr selects showing 2025-11-04 12:48:25 +04:00
c266814c96 feat: attr options and selects editing 2025-11-04 12:18:53 +04:00
a7bda3d9f6 fix: default option in attributes 2025-11-03 10:43:10 +04:00
be878717e5 fix: fixed attribute removing from module 2025-11-02 12:29:18 +04:00
2700538945 fix: fixed attribute creating 2025-11-01 23:18:11 +04:00
80a74ac8e6 feat: get deal barcodes pdf 2025-11-01 14:25:22 +04:00
ef657c4939 refactor: cleaned main file 2025-10-31 11:29:24 +04:00
36b3e056dc feat: blank taskiq setup 2025-10-30 17:06:30 +04:00
307e6573e3 feat: printing uploaded product barcode images 2025-10-30 15:41:32 +04:00
82fcd6e8cb feat: deal attributes with select and options 2025-10-29 19:37:27 +04:00
0e8c9077c9 feat: setting default attributes after deal creating 2025-10-28 17:20:48 +04:00
9b109a7270 fix: applied timezone to default values, removed value nesting 2025-10-28 11:43:42 +04:00
c1196497d4 fix: removed attr is_shown_on_dashboard 2025-10-27 17:32:02 +04:00
759a8d6478 feat: deal attributes editing 2025-10-27 10:02:02 +04:00
a579ae4145 fix: fixed module updating 2025-10-25 18:04:58 +04:00
fcaa7fe177 feat: modules creation 2025-10-25 18:00:05 +04:00
281600c72d feat: modules and module-editor pages 2025-10-25 12:11:48 +04:00
62aeebf079 refactor: renamed built_in_modules into modules 2025-10-21 12:35:22 +04:00
83f3b55f49 feat: product barcode images 2025-10-21 11:10:08 +04:00
90c0bae8f1 feat: images uploader, endpoint for product images uploading 2025-10-20 16:09:29 +04:00
34ac2a0a69 fix: using uv 2025-10-19 21:44:02 +04:00
79a1dff720 refactor: refactored Board model 2025-10-19 12:55:29 +04:00
44f00b1057 feat: default name for groups 2025-10-19 12:22:47 +04:00
ffee658349 feat: deal tags 2025-10-19 12:12:08 +04:00
69 changed files with 3451 additions and 779 deletions

View File

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

View File

@ -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
core/__init__.py Normal file
View File

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

11
core/app_settings.py Normal file
View File

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

17
core/exceptions.py Normal file
View File

@ -0,0 +1,17 @@
from fastapi import Request
from fastapi.applications import AppType
from starlette.responses import JSONResponse
from utils.exceptions import ObjectNotFoundException, ForbiddenException
def register_exception_handlers(app: AppType):
@app.exception_handler(ObjectNotFoundException)
async def not_found_exception_handler(
request: Request, exc: ObjectNotFoundException
):
return JSONResponse(status_code=404, content={"detail": exc.name})
@app.exception_handler(ForbiddenException)
async def forbidden_exception_handler(request: Request, exc: ForbiddenException):
return JSONResponse(status_code=403, content={"detail": exc.name})

16
core/lifespan.py Normal file
View File

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

19
core/middlewares.py Normal file
View File

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

0
external/__init__.py vendored Normal file
View File

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

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

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

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

View File

@ -10,7 +10,7 @@ from logger.constants import (
from logger.formatter import JsonFormatter
from logger.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):

49
main.py
View File

@ -1,41 +1,28 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import ORJSONResponse
import taskiq_fastapi
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
import modules
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
from utils.exceptions import ObjectNotFoundException
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")
taskiq_fastapi.init(broker, "main:app")
return app
@app.exception_handler(ObjectNotFoundException)
async def unicorn_exception_handler(request: Request, exc: ObjectNotFoundException):
return JSONResponse(status_code=404, content={"detail": exc.name})
auto_include_routers(app, routers, True)
app.mount("/static", StaticFiles(directory="static"), name="static")
app = create_app()

View File

@ -1,15 +1,33 @@
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 .built_in_module import ( # noqa: F401
BuiltInModule as BuiltInModule,
project_built_in_module as project_built_in_module,
built_in_module_dependencies as built_in_module_dependencies,
)
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, DealStatusHistory as DealStatusHistory

43
models/attr_select.py Normal file
View File

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

139
models/attribute.py Normal file
View File

@ -0,0 +1,139 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, Table, Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
if TYPE_CHECKING:
from models import Module, Deal, AttributeSelect, AttributeOption
module_attribute = Table(
"module_attribute",
BaseModel.metadata,
Column("module_id", ForeignKey("modules.id"), primary_key=True),
Column("attribute_id", ForeignKey("attributes.id"), primary_key=True),
)
class AttributeType(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attribute_types"
type: Mapped[str] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(unique=True)
attributes: Mapped["Attribute"] = relationship(
back_populates="type",
lazy="noload",
)
class Attribute(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "attributes"
label: Mapped[str] = mapped_column()
is_applicable_to_group: Mapped[bool] = mapped_column(
default=False,
comment="Применять ли изменения атрибута карточки ко всем карточкам в группе",
)
is_nullable: Mapped[bool] = mapped_column(default=False)
default_value: Mapped[Optional[dict[str, any]]] = mapped_column(JSONB)
default_option_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("attribute_options.id")
)
default_option: Mapped[Optional["AttributeOption"]] = relationship(
backref="attributes", lazy="joined"
)
description: Mapped[str] = mapped_column(default="")
is_built_in: Mapped[bool] = mapped_column(default=False)
type_id: Mapped[int] = mapped_column(ForeignKey("attribute_types.id"))
type: Mapped[AttributeType] = relationship(
back_populates="attributes",
lazy="joined",
)
select_id: Mapped[Optional[int]] = mapped_column(ForeignKey("attribute_selects.id"))
select: Mapped[Optional["AttributeSelect"]] = relationship(
back_populates="attributes",
lazy="joined",
)
modules: Mapped[list["Module"]] = relationship(
secondary=module_attribute,
back_populates="attributes",
lazy="noload",
)
values: Mapped[list["AttributeValue"]] = relationship(
uselist=True,
back_populates="attribute",
lazy="noload",
)
class AttributeValue(BaseModel):
__tablename__ = "attribute_values"
id: Mapped[int] = mapped_column(
primary_key=True,
autoincrement=True,
)
value: Mapped[Optional[dict[str, any]]] = mapped_column(JSONB)
deal_id: Mapped[int] = mapped_column(
ForeignKey("deals.id"),
primary_key=True,
)
deal: Mapped["Deal"] = relationship(
back_populates="attributes_values",
lazy="noload",
)
module_id: Mapped[int] = mapped_column(
ForeignKey("modules.id"),
primary_key=True,
)
module: Mapped["Module"] = relationship(
back_populates="attribute_values",
lazy="noload",
)
attribute_id: Mapped[int] = mapped_column(
ForeignKey("attributes.id"),
primary_key=True,
)
attribute: Mapped[Attribute] = relationship(
back_populates="values",
lazy="noload",
)
class AttributeLabel(BaseModel):
__tablename__ = "attribute_labels"
label: Mapped[str] = mapped_column()
module_id: Mapped[int] = mapped_column(
ForeignKey("modules.id"),
primary_key=True,
)
module: Mapped["Module"] = relationship(
backref="attribute_labels",
lazy="noload",
)
attribute_id: Mapped[int] = mapped_column(
ForeignKey("attributes.id"),
primary_key=True,
)
attribute: Mapped[Attribute] = relationship(
backref="attribute_labels",
lazy="noload",
)

6
models/auth.py Normal file
View File

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

View File

@ -13,10 +13,10 @@ if TYPE_CHECKING:
class Board(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
__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")

View File

@ -1,79 +0,0 @@
import enum
from typing import TYPE_CHECKING
from sqlalchemy import Table, Column, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
if TYPE_CHECKING:
from models import Project
project_built_in_module = Table(
"project_built_in_module",
BaseModel.metadata,
Column("project_id", ForeignKey("projects.id"), primary_key=True),
Column("module_id", ForeignKey("built_in_modules.id"), primary_key=True),
)
built_in_module_dependencies = Table(
"built_in_module_dependencies",
BaseModel.metadata,
Column("module_id", ForeignKey("built_in_modules.id"), primary_key=True),
Column("depends_on_id", ForeignKey("built_in_modules.id"), primary_key=True),
)
class BuiltInModule(BaseModel):
__tablename__ = "built_in_modules"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(unique=True)
label: Mapped[str] = mapped_column()
description: Mapped[str] = mapped_column()
is_deleted: Mapped[bool] = mapped_column(default=False)
depends_on: Mapped[list["BuiltInModule"]] = relationship(
secondary=built_in_module_dependencies,
primaryjoin="BuiltInModule.id == built_in_module_dependencies.c.module_id",
secondaryjoin="BuiltInModule.id == built_in_module_dependencies.c.depends_on_id",
back_populates="depended_on_by",
lazy="immediate",
)
depended_on_by: Mapped[list["BuiltInModule"]] = relationship(
secondary="built_in_module_dependencies",
primaryjoin="BuiltInModule.id == built_in_module_dependencies.c.depends_on_id",
secondaryjoin="BuiltInModule.id == built_in_module_dependencies.c.module_id",
back_populates="depends_on",
lazy="noload",
)
projects: Mapped[list["Project"]] = relationship(
uselist=True,
secondary="project_built_in_module",
back_populates="built_in_modules",
lazy="noload",
)
tabs: Mapped[list["BuiltInModuleTab"]] = relationship(
lazy="immediate", backref="module", cascade="all, delete-orphan"
)
class DeviceType(enum.StrEnum):
MOBILE = "mobile"
DESKTOP = "desktop"
BOTH = "both"
class BuiltInModuleTab(BaseModel):
__tablename__ = "built_in_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[str] = mapped_column()
module_id: Mapped[int] = mapped_column(ForeignKey("built_in_modules.id"))
device: Mapped[DeviceType] = mapped_column(default=DeviceType.BOTH)

View File

@ -7,7 +7,14 @@ from models.base import BaseModel
from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
if TYPE_CHECKING:
from models import Status, Board, DealStatusHistory, DealGroup
from models import (
Status,
Board,
DealStatusHistory,
DealGroup,
DealTag,
AttributeValue,
)
from modules.clients.models import Client
@ -41,6 +48,18 @@ class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
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"),

51
models/deal_tag.py Normal file
View File

@ -0,0 +1,51 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Column, Table, Index
from sqlalchemy.orm import mapped_column, Mapped, relationship
from models import BaseModel
from models.mixins import IdMixin, SoftDeleteMixin
if TYPE_CHECKING:
from models import Project, Deal
deals_deal_tags = Table(
"deals_deal_tags",
BaseModel.metadata,
Column("deal_id", ForeignKey("deals.id"), primary_key=True),
Column("deal_tag_id", ForeignKey("deal_tags.id"), primary_key=True),
)
class DealTagColor(BaseModel, IdMixin):
__tablename__ = "deal_tag_colors"
label: Mapped[str] = mapped_column(unique=True)
color: Mapped[str] = mapped_column(unique=True)
background_color: Mapped[str] = mapped_column(unique=True)
is_deleted: Mapped[bool] = mapped_column(default=False)
class DealTag(BaseModel, IdMixin, SoftDeleteMixin):
__tablename__ = "deal_tags"
name: Mapped[str] = mapped_column()
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
project: Mapped["Project"] = relationship(
back_populates="tags",
lazy="noload",
)
deals: Mapped[list["Deal"]] = relationship(
secondary="deals_deal_tags",
lazy="noload",
back_populates="tags",
)
tag_color_id: Mapped[int] = mapped_column(ForeignKey("deal_tag_colors.id"))
tag_color: Mapped[DealTagColor] = relationship(lazy="immediate")
__table_args__ = (
Index("idx_deal_name_project_id", "name", "project_id", "is_deleted"),
)

89
models/module.py Normal file
View File

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

View File

@ -6,7 +6,7 @@ from models.base import BaseModel
from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
if TYPE_CHECKING:
from models import Board, BuiltInModule
from models import Board, Module, DealTag
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
@ -19,9 +19,16 @@ class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
lazy="noload",
)
built_in_modules: Mapped[list["BuiltInModule"]] = relationship(
secondary="project_built_in_module",
modules: Mapped[list["Module"]] = relationship(
secondary="project_module",
back_populates="projects",
lazy="selectin",
order_by="asc(BuiltInModule.id)",
order_by="asc(Module.id)",
)
tags: Mapped[list["DealTag"]] = relationship(
back_populates="project",
primaryjoin="and_(Project.id == DealTag.project_id, DealTag.is_deleted == False)",
order_by="asc(DealTag.id)",
lazy="selectin",
)

View File

@ -1,6 +1,7 @@
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
@ -24,7 +25,7 @@ class BarcodePdfGenerator(PDFGenerator):
return None
return value
def generate(
async def generate(
self, barcodes_data: list[BarcodeData | PdfBarcodeImageGenData]
) -> BytesIO:
pdf_barcodes_gen_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData] = []
@ -52,9 +53,9 @@ class BarcodePdfGenerator(PDFGenerator):
}
)
return self._generate(pdf_barcodes_gen_data)
return await self._generate(pdf_barcodes_gen_data)
def _generate(
async def _generate(
self, barcodes_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData]
) -> BytesIO:
pdf_maker = PdfMaker((self.page_width, self.page_height))
@ -63,9 +64,10 @@ class BarcodePdfGenerator(PDFGenerator):
for barcode_data in barcodes_data:
if "barcode_value" in barcode_data:
pdf_files.append(self._generate_for_one_product(barcode_data))
result = self._generate_for_one_product(barcode_data)
else:
pdf_files.append(self._generate_for_one_product_using_img(barcode_data))
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]:
@ -138,11 +140,18 @@ class BarcodePdfGenerator(PDFGenerator):
buffer.seek(0)
return buffer
def _generate_for_one_product_using_img(
async def _generate_for_one_product_using_img(
self, barcode_data: PdfBarcodeImageGenData
) -> BytesIO:
with open(barcode_data["barcode_image_url"], "rb") as pdf_file:
pdf_bytes = pdf_file.read()
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"]):

View File

@ -33,4 +33,4 @@ class ProductBarcodeImage(BaseModel):
)
product: Mapped["Product"] = relationship(back_populates="barcode_image")
filename: Mapped[str] = mapped_column()
image_url: Mapped[str] = mapped_column()

View File

@ -52,7 +52,6 @@ class Product(BaseModel, IdMixin, SoftDeleteMixin):
barcode_image: Mapped["ProductBarcodeImage"] = relationship(
back_populates="product",
lazy="joined",
uselist=False,
)

View File

@ -26,6 +26,7 @@ class DealProductRepository(
return (
stmt.options(
joinedload(DealProduct.product).selectinload(Product.barcodes),
joinedload(DealProduct.product).joinedload(Product.client),
selectinload(DealProduct.product_services).joinedload(
DealProductService.service
),

View File

@ -1,7 +1,8 @@
from sqlalchemy import or_, delete
from sqlalchemy.orm import selectinload, joinedload
from modules.fulfillment_base.models import Product, ProductBarcode, BarcodeTemplate
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,
@ -16,6 +17,7 @@ class ProductRepository(
RepUpdateMixin[Product, UpdateProductSchema],
RepGetByIdMixin[Product],
):
session: AsyncSession
entity_class = Product
entity_not_found_msg = "Товар не найден"
@ -95,3 +97,36 @@ class ProductRepository(
await self._update_barcodes(product, data.barcodes)
del data.barcodes
return await self._apply_update_data_to_model(product, data, True)
async def delete_images(self, product_images: list[ProductImage], with_commit: bool = False):
for img in product_images:
await self.session.delete(img)
if with_commit:
await self.session.commit()
else:
await self.session.flush()
async def delete_barcode_image(self, barcode_image: ProductBarcodeImage, with_commit: bool = False):
await self.session.delete(barcode_image)
if with_commit:
await self.session.commit()
else:
await self.session.flush()
async def create_image(self, product_id: int, image_url: str) -> ProductImage:
product_image = ProductImage(
product_id=product_id,
image_url=image_url,
)
self.session.add(product_image)
await self.session.commit()
return product_image
async def create_barcode_image(self, product_id: int, image_url: str) -> ProductBarcodeImage:
product_barcode_image = ProductBarcodeImage(
product_id=product_id,
image_url=image_url,
)
self.session.add(product_barcode_image)
await self.session.commit()
return product_barcode_image

View File

@ -1,6 +1,6 @@
from typing import Optional
from pydantic import field_validator
from pydantic import field_validator, model_validator
from modules.fulfillment_base.models import ProductBarcode
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema
@ -16,6 +16,11 @@ class ProductImageSchema(BaseSchema):
image_url: str
class ProductBarcodeImageSchema(BaseSchema):
product_id: int
image_url: str
class CreateProductSchema(BaseSchema):
name: str
article: str
@ -27,10 +32,27 @@ class CreateProductSchema(BaseSchema):
composition: Optional[str]
size: Optional[str]
additional_info: Optional[str]
barcodes: list[str]
barcodes: list[str] = []
class ProductSchema(CreateProductSchema):
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
@ -75,6 +97,10 @@ class GetProductBarcodePdfRequest(BaseSchema):
barcode: str
class GetDealBarcodesPdfRequest(BaseSchema):
deal_id: int
# endregion
# region Response
@ -93,6 +119,10 @@ class UpdateProductResponse(BaseResponse):
pass
class ProductUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteProductResponse(BaseResponse):
pass
@ -101,4 +131,16 @@ class GetProductBarcodePdfResponse(BasePdfResponse):
pass
class GetDealBarcodesPdfResponse(BasePdfResponse):
pass
class BarcodeUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteBarcodeImageResponse(BaseResponse):
pass
# endregion

View File

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

View File

@ -1,12 +1,11 @@
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 (
CreateProductRequest,
ProductSchema,
UpdateProductRequest, GetProductsResponse,
)
from modules.fulfillment_base.schemas.product import *
from schemas.base import PaginationSchema, PaginationInfoSchema
from services.mixins import *
@ -46,5 +45,54 @@ class ProductService(
),
)
async def is_soft_delete(self, product: ProductSchema) -> bool:
return True
async def upload_image(
self, product_id: int, upload_file: UploadFile
) -> ProductUploadImageResponse:
product: Product = await self.repository.get_by_id(product_id)
s3_uploader = S3Uploader()
if len(product.images) > 0:
for image in product.images:
s3_key = image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_images(product.images)
image_url = await s3_uploader.upload_from_upload_file_obj(upload_file)
await self.repository.create_image(product_id, image_url)
return ProductUploadImageResponse(
message="Изображение успешно загружено", image_url=image_url
)
async def upload_barcode_image(
self, product_id: int, upload_file: UploadFile
) -> BarcodeUploadImageResponse:
product: Product = await self.repository.get_by_id(product_id)
s3_uploader = S3Uploader()
if product.barcode_image:
s3_key = product.barcode_image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_barcode_image(product.barcode_image)
image_url = await s3_uploader.upload_from_upload_file_obj(upload_file)
await self.repository.create_barcode_image(product_id, image_url)
return BarcodeUploadImageResponse(
message="Изображение штрихкода успешно загружено", image_url=image_url
)
async def delete_barcode_image(self, product_id: int) -> DeleteBarcodeImageResponse:
product: Product = await self.repository.get_by_id(product_id)
if not product.barcode_image:
return DeleteBarcodeImageResponse(
message="У товара нет изображения штрихкода"
)
s3_uploader = S3Uploader()
s3_key = product.barcode_image.image_url.split("/")[-1]
await s3_uploader.delete_image(s3_key)
await self.repository.delete_barcode_image(product.barcode_image, True)
return DeleteBarcodeImageResponse(
message="Изображение штрихкода успешно удалено"
)

View File

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

View File

@ -1,6 +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 .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository
from .deal_group import DealGroupRepository as DealGroupRepository
from .attr_option import AttrOptionRepository as AttrOptionRepository

View File

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

View File

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

212
repositories/attribute.py Normal file
View File

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

View File

@ -1,18 +0,0 @@
from models import Board, BuiltInModule
from repositories.mixins import *
class BuiltInModuleRepository(
BaseRepository,
RepGetAllMixin[BuiltInModule],
):
entity_class = BuiltInModule
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)
async def get_by_ids(self, ids: list[int]) -> list[BuiltInModule]:
stmt = select(BuiltInModule).where(BuiltInModule.id.in_(ids))
built_in_modules = await self.session.scalars(stmt)
return built_in_modules.all()

View File

@ -8,6 +8,7 @@ from modules.fulfillment_base.models import (
DealProductService,
DealProduct,
)
from repositories import AttributeRepository
from repositories.mixins import *
from schemas.base import SortDir
from schemas.deal import UpdateDealSchema, CreateDealSchema
@ -99,6 +100,7 @@ class DealRepository(
joinedload(Deal.status),
joinedload(Deal.board),
selectinload(Deal.group),
selectinload(Deal.tags),
)
.where(Deal.is_deleted.is_(False))
)
@ -145,6 +147,15 @@ class DealRepository(
result = await self.session.execute(stmt)
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))

View File

@ -14,8 +14,10 @@ class DealGroupRepository(
async def create(self, deals: list[Deal], lexorank: str) -> DealGroup:
group = DealGroup(deals=deals, lexorank=lexorank)
self.session.add(group)
await self.session.commit()
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:

62
repositories/deal_tag.py Normal file
View File

@ -0,0 +1,62 @@
from models import DealTag, DealTagColor, Deal
from models.project import Project
from repositories.mixins import *
from schemas.deal_tag import *
class DealTagRepository(
RepCrudMixin[DealTag, CreateDealTagSchema, UpdateDealTagSchema]
):
session: AsyncSession
entity_class = DealTag
entity_not_found_msg = "Тег не найден"
def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select:
project_id = args[0]
return stmt.where(
DealTag.project_id == project_id, DealTag.is_deleted.is_(False)
).order_by(DealTag.id)
async def _get_tag_color(self, tag_id: int) -> DealTagColor:
stmt = select(DealTagColor).where(DealTagColor.id == tag_id)
result = await self.session.execute(stmt)
tag = result.one_or_none()
if not tag:
raise ObjectNotFoundException("Цвет тега не найден")
return tag[0]
async def update(self, deal_tag: DealTag, data: UpdateDealTagSchema) -> Project:
if data.tag_color is not None:
data.tag_color = await self._get_tag_color(data.tag_color.id)
return await self._apply_update_data_to_model(deal_tag, data, True)
async def switch_tag_in_deal(self, tag: DealTag, deal: Deal):
if tag in deal.tags:
deal.tags.remove(tag)
else:
deal.tags.append(tag)
await self.session.commit()
async def switch_tag_in_deals(self, tag: DealTag, deals: list[Deal]):
for deal in deals:
if tag in deal.tags:
deal.tags.remove(tag)
else:
deal.tags.append(tag)
await self.session.commit()
async def get_tag_colors(self) -> list[DealTagColor]:
stmt = select(DealTagColor)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def sync_deals_tags(self, deals: list[Deal]):
tags_set: set[DealTag] = set()
for deal in deals:
for tag in deal.tags:
tags_set.add(tag)
tags: list[DealTag] = list(tags_set)
for deal in deals:
deal.tags = tags

View File

@ -65,13 +65,14 @@ class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityT
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 value is not None:
if not set_if_value_is_not_none or value is not None:
setattr(model, field, value)
if with_commit:

101
repositories/module.py Normal file
View File

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

View File

@ -1,7 +1,8 @@
from sqlalchemy.orm import selectinload
from models import DealTag
from models.project import Project
from repositories.built_in_module import BuiltInModuleRepository
from repositories.module import ModuleRepository
from repositories.mixins import *
from schemas.project import CreateProjectSchema, UpdateProjectSchema
@ -12,17 +13,23 @@ class ProjectRepository(
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 stmt.order_by(Project.id)
return self._apply_options(stmt).order_by(Project.id)
def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(selectinload(Project.boards))
return self._apply_options(stmt)
async def update(self, project: Project, data: UpdateProjectSchema) -> Project:
if data.built_in_modules is not None:
built_in_modules = data.built_in_modules
module_ids = [module.id for module in built_in_modules]
data.built_in_modules = await BuiltInModuleRepository(
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)

View File

@ -1,11 +0,0 @@
PyMuPDF
pdfrw
fpdf
reportlab
fastapi
SQLAlchemy
pathlib
python-dotenv
starlette
pydantic
alembic

View File

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

View File

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

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

@ -0,0 +1,109 @@
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.attribute import *
from services import AttributeService
router = APIRouter(tags=["attribute"])
@router.get(
"/",
response_model=GetAllAttributesResponse,
operation_id="get_attributes",
)
async def get_attributes(
session: SessionDependency,
):
return await AttributeService(session).get_all()
@router.post(
"/",
response_model=CreateAttributeResponse,
operation_id="create_attribute",
)
async def create_attribute(
session: SessionDependency,
request: CreateAttributeRequest,
):
return await AttributeService(session).create(request)
@router.patch(
"/{pk}",
response_model=UpdateAttributeResponse,
operation_id="update_attribute",
)
async def update_attribute(
session: SessionDependency,
request: UpdateAttributeRequest,
pk: int = Path(),
):
return await AttributeService(session).update(pk, request)
@router.delete(
"/{pk}",
response_model=DeleteAttributeResponse,
operation_id="delete_attribute",
)
async def delete_attribute(
session: SessionDependency,
pk: int = Path(),
):
return await AttributeService(session).delete(pk)
@router.post(
"/label",
response_model=UpdateAttributeLabelResponse,
operation_id="update_attribute_label",
)
async def update_attribute_label(
session: SessionDependency,
request: UpdateAttributeLabelRequest,
):
return await AttributeService(session).update_attribute_label(request)
@router.get(
"/type",
response_model=GetAllAttributeTypesResponse,
operation_id="get_attribute_types",
)
async def get_attribute_types(
session: SessionDependency,
):
return await AttributeService(session).get_attribute_types()
@router.get(
"/deal/{dealId}/module/{moduleId}",
response_model=GetDealModuleAttributesResponse,
operation_id="get_deal_module_attributes",
)
async def get_deal_module_attributes(
session: SessionDependency,
deal_id: int = Path(alias="dealId"),
module_id: int = Path(alias="moduleId"),
):
return await AttributeService(session).get_deal_module_attributes(
deal_id, module_id
)
@router.post(
"/deal/{dealId}/module/{moduleId}",
response_model=UpdateDealModuleAttributesResponse,
operation_id="update_deal_module_attributes",
)
async def update_deal_module_attributes(
session: SessionDependency,
request: UpdateDealModuleAttributesRequest,
deal_id: int = Path(alias="dealId"),
module_id: int = Path(alias="moduleId"),
):
return await AttributeService(session).update_deal_module_attributes(
deal_id, module_id, request
)

View File

@ -0,0 +1,81 @@
from pathlib import Path
from fastapi import APIRouter
from backend.dependecies import SessionDependency
from schemas.deal_tag import *
from services import DealTagService
router = APIRouter(tags=["deal-tag"])
@router.get(
"/{projectId}",
response_model=GetDealTagsResponse,
operation_id="get_deal_tags",
)
async def get_deal_tags(
session: SessionDependency,
projectId: int = Path(alias="projectId"),
):
return await DealTagService(session).get_all(projectId)
@router.post(
"/",
operation_id="create_deal_tag",
response_model=CreateDealTagResponse,
)
async def create_deal_tag(
request: CreateDealTagRequest,
session: SessionDependency,
):
return await DealTagService(session).create(request)
@router.patch(
"/{pk}",
operation_id="update_deal_tag",
response_model=UpdateDealTagResponse,
)
async def update_deal_tag(
request: UpdateDealTagRequest,
session: SessionDependency,
pk: int = Path(),
):
return await DealTagService(session).update(pk, request)
@router.delete(
"/{pk}",
response_model=DeleteDealTagResponse,
operation_id="delete_deal_tag",
)
async def delete_deal_tag(
session: SessionDependency,
pk: int = Path(),
):
return await DealTagService(session).delete(pk)
@router.post(
"/switch",
response_model=SwitchDealTagResponse,
operation_id="switch_deal_tag",
)
async def switch_deal_tag(
session: SessionDependency,
request: SwitchDealTagRequest,
):
return await DealTagService(session).switch_tag(request)
@router.post(
"/colors",
response_model=GetTagColorsResponse,
operation_id="get_deal_tag_colors",
)
async def get_deal_tag_colors(
session: SessionDependency,
):
return await DealTagService(session).get_tag_colors()

View File

@ -1,18 +1,104 @@
from fastapi import APIRouter
from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency
from schemas.module import GetAllBuiltInModulesResponse
from services.built_in_module import BuiltInModuleService
from schemas.module import *
from services.module import ModuleService
router = APIRouter(tags=["modules"])
@router.get(
"/built-in/",
response_model=GetAllBuiltInModulesResponse,
operation_id="get_built_in_modules",
"/",
response_model=GetAllModulesResponse,
operation_id="get_modules",
)
async def get_built_in_modules(
async def get_modules(
session: SessionDependency,
):
return await BuiltInModuleService(session).get_all()
return await ModuleService(session).get_all()
@router.get(
"/with-attributes",
response_model=GetAllWithAttributesResponse,
operation_id="get_modules_with_attributes",
)
async def get_modules_with_attributes(
session: SessionDependency,
):
return await ModuleService(session).get_with_attributes()
@router.get(
"/{pk}/with-attributes",
response_model=GetByIdWithAttributesResponse,
operation_id="get_module_with_attributes",
)
async def get_module_with_attributes(
session: SessionDependency,
pk: int = Path(),
):
return await ModuleService(session).get_by_id_with_attributes(pk)
@router.post(
"/",
response_model=CreateModuleResponse,
operation_id="create_module",
)
async def create_module(
session: SessionDependency,
request: CreateModuleRequest,
):
return await ModuleService(session).create(request)
@router.patch(
"/{pk}/common-info",
response_model=UpdateModuleCommonInfoResponse,
operation_id="update_module",
)
async def update_module_common_info(
session: SessionDependency,
request: UpdateModuleCommonInfoRequest,
pk: int = Path(),
):
return await ModuleService(session).update(pk, request)
@router.delete(
"/{pk}",
response_model=DeleteModuleResponse,
operation_id="delete_module",
)
async def delete_module(
session: SessionDependency,
pk: int = Path(),
):
return await ModuleService(session).delete(pk)
@router.post(
"/{moduleId}/attribute/{attributeId}",
response_model=AddAttributeResponse,
operation_id="add_attribute_to_module",
)
async def add_attribute_to_module(
session: SessionDependency,
module_id: int = Path(alias="moduleId"),
attribute_id: int = Path(alias="attributeId"),
):
return await ModuleService(session).add_attribute(module_id, attribute_id)
@router.delete(
"/{moduleId}/attribute/{attributeId}",
response_model=DeleteAttributeResponse,
operation_id="remove_attribute_from_module",
)
async def remove_attribute_from_module(
session: SessionDependency,
module_id: int = Path(alias="moduleId"),
attribute_id: int = Path(alias="attributeId"),
):
return await ModuleService(session).delete_attribute(module_id, attribute_id)

View File

@ -1,4 +1,6 @@
from fastapi import APIRouter, Path, Query
from typing import Annotated
from fastapi import APIRouter, Path, Query, File, UploadFile
from backend.dependecies import SessionDependency, PaginationDependency
from modules.fulfillment_base.schemas.product import *
@ -46,6 +48,19 @@ async def update_product(
return await ProductService(session).update(pk, request)
@router.post(
"{pk}/images/upload",
response_model=ProductUploadImageResponse,
operation_id="upload_product_image",
)
async def upload_product_image(
session: SessionDependency,
upload_file: Annotated[UploadFile, File()],
pk: int = Path(),
):
return await ProductService(session).upload_image(pk, upload_file)
@router.delete(
"/{pk}",
response_model=DeleteProductResponse,
@ -64,10 +79,52 @@ async def delete_product(
response_model=GetProductBarcodePdfResponse,
)
async def get_product_barcode_pdf(
request: GetProductBarcodePdfRequest, session: SessionDependency
session: SessionDependency,
request: GetProductBarcodePdfRequest,
):
service = BarcodePrinterService(session)
filename, base64_string = await service.generate_base64(request)
filename, base64_string = await service.generate_product_base64(request)
return GetProductBarcodePdfResponse(
base64_string=base64_string, filename=filename, mime_type="application/pdf"
)
@router.post(
"/barcode/for-deal/get-pdf",
operation_id="get_deal_barcodes_pdf",
response_model=GetDealBarcodesPdfResponse,
)
async def get_deal_barcodes_pdf(
session: SessionDependency,
request: GetDealBarcodesPdfRequest,
):
service = BarcodePrinterService(session)
filename, base64_string = await service.generate_deal_base64(request)
return GetProductBarcodePdfResponse(
base64_string=base64_string, filename=filename, mime_type="application/pdf"
)
@router.post(
"{pk}/barcode/image/upload",
response_model=BarcodeUploadImageResponse,
operation_id="upload_product_barcode_image",
)
async def upload_product_barcode_image(
upload_file: UploadFile,
session: SessionDependency,
pk: int,
):
return await ProductService(session).upload_barcode_image(pk, upload_file)
@router.delete(
"{pk}/barcode/image",
response_model=DeleteBarcodeImageResponse,
operation_id="delete_product_barcode_image",
)
async def delete_product_barcode_image(
session: SessionDependency,
pk: int,
):
return await ProductService(session).delete_barcode_image(pk)

60
schemas/attr_option.py Normal file
View File

@ -0,0 +1,60 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
# region Entity
class AttrOptionSchema(BaseSchema):
id: int
name: str
lexorank: str
class CreateAttrOptionSchema(BaseSchema):
name: str
lexorank: str
select_id: int
class UpdateAttrOptionSchema(BaseSchema):
name: Optional[str] = None
lexorank: Optional[str] = None
# endregion
# region Request
class CreateAttrOptionRequest(BaseSchema):
entity: CreateAttrOptionSchema
class UpdateAttrOptionRequest(BaseSchema):
entity: UpdateAttrOptionSchema
# endregion
# region Response
class GetAllAttrSelectOptionsResponse(BaseSchema):
items: list[AttrOptionSchema]
class CreateAttrOptionResponse(BaseSchema):
entity: AttrOptionSchema
class UpdateAttrOptionResponse(BaseResponse):
pass
class DeleteAttrOptionResponse(BaseSchema):
pass
# endregion

60
schemas/attr_select.py Normal file
View File

@ -0,0 +1,60 @@
from schemas.attr_option import AttrOptionSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class AttrSelectSchema(BaseSchema):
id: int
name: str
is_built_in: bool
class CreateAttrSelectSchema(BaseSchema):
name: str
class UpdateAttrSelectSchema(BaseSchema):
name: str
class AttrSelectWithOptionsSchema(AttrSelectSchema):
options: list[AttrOptionSchema]
# endregion
# region Request
class CreateAttrSelectRequest(BaseSchema):
entity: CreateAttrSelectSchema
class UpdateAttrSelectRequest(BaseSchema):
entity: UpdateAttrSelectSchema
# endregion
# region Response
class GetAllAttrSelectsResponse(BaseSchema):
items: list[AttrSelectSchema]
class CreateAttrSelectResponse(BaseResponse):
entity: AttrSelectSchema
class UpdateAttrSelectResponse(BaseResponse):
pass
class DeleteAttrSelectResponse(BaseResponse):
pass
# endregion

127
schemas/attribute.py Normal file
View File

@ -0,0 +1,127 @@
from typing import Optional, Any
from schemas.attr_select import AttrSelectSchema, AttrOptionSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class AttributeTypeSchema(BaseSchema):
id: int
type: str
name: str
class CreateAttributeSchema(BaseSchema):
label: str
is_applicable_to_group: bool
is_nullable: bool
default_value: Optional[Any]
default_option_id: Optional[int] = None
description: str
type_id: int
select_id: Optional[int] = None
class AttributeSchema(CreateAttributeSchema):
id: int
is_built_in: bool
type: AttributeTypeSchema
default_option: Optional[AttrOptionSchema] = None
select: Optional[AttrSelectSchema]
class UpdateAttributeSchema(BaseSchema):
label: Optional[str] = None
is_applicable_to_group: Optional[bool] = None
is_nullable: Optional[bool] = None
default_value: Optional[Any] = None
default_option_id: Optional[int] = None
description: Optional[str] = None
class ModuleAttributeSchema(AttributeSchema):
original_label: str
class DealModuleAttributeSchema(BaseSchema):
attribute_id: int
label: str
original_label: str
value: Optional[Any]
type: AttributeTypeSchema
select: Optional[AttrSelectSchema]
default_value: Any
description: str
is_applicable_to_group: bool
is_nullable: bool
class UpdateDealModuleAttributeSchema(BaseSchema):
attribute_id: int
is_applicable_to_group: bool
value: Optional[Any] = None
# endregion
# region Request
class CreateAttributeRequest(BaseSchema):
entity: CreateAttributeSchema
class UpdateAttributeRequest(BaseSchema):
entity: UpdateAttributeSchema
class UpdateAttributeLabelRequest(BaseSchema):
module_id: int
attribute_id: int
label: str
class UpdateDealModuleAttributesRequest(BaseSchema):
attributes: list[UpdateDealModuleAttributeSchema]
# endregion
# region Response
class GetAllAttributesResponse(BaseSchema):
items: list[AttributeSchema]
class CreateAttributeResponse(BaseResponse):
pass
class UpdateAttributeResponse(BaseResponse):
pass
class DeleteAttributeResponse(BaseResponse):
pass
class UpdateAttributeLabelResponse(BaseResponse):
pass
class GetAllAttributeTypesResponse(BaseSchema):
items: list[AttributeTypeSchema]
class GetDealModuleAttributesResponse(BaseSchema):
attributes: list[DealModuleAttributeSchema]
class UpdateDealModuleAttributesResponse(BaseResponse):
pass
# endregion

View File

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

79
schemas/deal_tag.py Normal file
View File

@ -0,0 +1,79 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
# region Entities
class DealTagColorSchema(BaseSchema):
id: int
color: str
background_color: str
label: str
class CreateDealTagSchema(BaseSchema):
name: str
project_id: int
tag_color_id: int
class DealTagSchema(CreateDealTagSchema):
id: int
tag_color: DealTagColorSchema
class UpdateDealTagSchema(BaseSchema):
name: Optional[str] = None
tag_color: Optional[DealTagColorSchema] = None
# endregion
# region Requests
class CreateDealTagRequest(BaseSchema):
entity: CreateDealTagSchema
class UpdateDealTagRequest(BaseSchema):
entity: UpdateDealTagSchema
class SwitchDealTagRequest(BaseSchema):
tag_id: int
deal_id: Optional[int] = None
group_id: Optional[int] = None
# endregion
# region Responses
class GetDealTagsResponse(BaseSchema):
items: list[DealTagSchema]
class CreateDealTagResponse(BaseResponse):
entity: DealTagSchema
class UpdateDealTagResponse(BaseResponse):
pass
class DeleteDealTagResponse(BaseResponse):
pass
class SwitchDealTagResponse(BaseResponse):
pass
class GetTagColorsResponse(BaseSchema):
items: list[DealTagColorSchema]
# endregion

View File

@ -1,23 +1,56 @@
from schemas.base import BaseSchema
from typing import Optional
from schemas.attribute import ModuleAttributeSchema
from schemas.base import BaseSchema, BaseResponse
# region Entity
class BuiltInModuleTabSchema(BaseSchema):
class ModuleTabSchema(BaseSchema):
id: int
key: str
label: str
icon_name: str
icon_name: Optional[str]
device: str
class BuiltInModuleSchema(BaseSchema):
class ModuleSchema(BaseSchema):
id: int
key: str
label: str
description: str
depends_on: list["BuiltInModuleSchema"]
tabs: list[BuiltInModuleTabSchema]
description: Optional[str]
is_built_in: bool
depends_on: list["ModuleSchema"]
tabs: list[ModuleTabSchema]
class ModuleWithAttributesSchema(ModuleSchema):
attributes: list[ModuleAttributeSchema]
class CreateModuleSchema(BaseSchema):
label: str
description: Optional[str]
class UpdateModuleCommonInfoSchema(BaseSchema):
label: str
description: Optional[str]
# endregion
# region Requests
class CreateModuleRequest(BaseSchema):
entity: CreateModuleSchema
class UpdateModuleCommonInfoRequest(BaseSchema):
entity: UpdateModuleCommonInfoSchema
# endregion
@ -25,8 +58,36 @@ class BuiltInModuleSchema(BaseSchema):
# region Response
class GetAllBuiltInModulesResponse(BaseSchema):
items: list[BuiltInModuleSchema]
class GetAllModulesResponse(BaseSchema):
items: list[ModuleSchema]
class GetAllWithAttributesResponse(BaseSchema):
items: list[ModuleWithAttributesSchema]
class GetByIdWithAttributesResponse(BaseSchema):
entity: ModuleWithAttributesSchema
class CreateModuleResponse(BaseResponse):
pass
class UpdateModuleCommonInfoResponse(BaseResponse):
pass
class DeleteModuleResponse(BaseResponse):
pass
class AddAttributeResponse(BaseResponse):
pass
class DeleteAttributeResponse(BaseResponse):
pass
# endregion

View File

@ -1,7 +1,8 @@
from typing import Optional
from schemas.base import BaseSchema, BaseResponse
from schemas.module import BuiltInModuleSchema
from schemas.deal_tag import DealTagSchema
from schemas.module import ModuleSchema
# region Entity
@ -10,7 +11,8 @@ from schemas.module import BuiltInModuleSchema
class ProjectSchema(BaseSchema):
id: int
name: str
built_in_modules: list[BuiltInModuleSchema]
modules: list[ModuleSchema]
tags: list[DealTagSchema]
class CreateProjectSchema(BaseSchema):
@ -19,7 +21,7 @@ class CreateProjectSchema(BaseSchema):
class UpdateProjectSchema(BaseSchema):
name: Optional[str] = None
built_in_modules: list[BuiltInModuleSchema] = None
modules: list[ModuleSchema] = None
# endregion

View File

@ -1,5 +1,9 @@
from .attr_select import AttrSelectService as AttrSelectService
from .attribute import AttributeService as AttributeService
from .board import BoardService as BoardService
from .deal import DealService as DealService
from .deal_group import DealGroupService as DealGroupService
from .deal_tag import DealTagService as DealTagService
from .project import ProjectService as ProjectService
from .status import StatusService as StatusService
from .deal_group import DealGroupService as DealGroupService
from .attr_option import AttrOptionService as AttrOptionService

27
services/attr_option.py Normal file
View File

@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeOption
from repositories import AttrOptionRepository
from schemas.attr_option import (
AttrOptionSchema,
CreateAttrOptionRequest,
UpdateAttrOptionRequest,
)
from services.mixins import ServiceCrudMixin
class AttrOptionService(
ServiceCrudMixin[
AttributeOption,
AttrOptionSchema,
CreateAttrOptionRequest,
UpdateAttrOptionRequest,
]
):
schema_class = AttrOptionSchema
entity_deleted_msg = "Опция успешно удалена"
entity_updated_msg = "Опция успешно обновлена"
entity_created_msg = "Опция успешно создана"
def __init__(self, session: AsyncSession):
self.repository = AttrOptionRepository(session)

24
services/attr_select.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy.ext.asyncio import AsyncSession
from models import AttributeSelect
from repositories import AttrSelectRepository
from schemas.attr_select import (
AttrSelectSchema,
CreateAttrSelectRequest,
UpdateAttrSelectRequest,
)
from services.mixins import ServiceCrudMixin
class AttrSelectService(
ServiceCrudMixin[
AttributeSelect,
AttrSelectSchema,
CreateAttrSelectRequest,
UpdateAttrSelectRequest,
]
):
schema_class = AttrSelectSchema
def __init__(self, session: AsyncSession):
self.repository = AttrSelectRepository(session)

76
services/attribute.py Normal file
View File

@ -0,0 +1,76 @@
from models import Attribute, AttributeValue, AttributeLabel
from repositories import AttributeRepository, DealRepository
from schemas.attribute import *
from services.mixins import *
class AttributeService(
ServiceCrudMixin[
Attribute, AttributeSchema, CreateAttributeRequest, UpdateAttributeRequest
]
):
schema_class = AttributeSchema
def __init__(self, session: AsyncSession):
self.repository = AttributeRepository(session)
async def update_attribute_label(
self, request: UpdateAttributeLabelRequest
) -> UpdateAttributeLabelResponse:
await self.repository.create_or_update_attribute_label(
request.module_id, request.attribute_id, request.label
)
return UpdateAttributeLabelResponse(
message="Название атрибута в модуле изменено"
)
async def get_attribute_types(self) -> GetAllAttributeTypesResponse:
types = await self.repository.get_attribute_types()
return GetAllAttributeTypesResponse(
items=[AttributeTypeSchema.model_validate(t) for t in types]
)
async def get_deal_module_attributes(
self, deal_id: int, module_id: int
) -> GetDealModuleAttributesResponse:
deal_attributes: list[
tuple[Attribute, AttributeValue, AttributeLabel]
] = await self.repository.get_deal_module_attributes(deal_id, module_id)
attributes = []
for attr, attr_value, attr_label in deal_attributes:
select_schema = (
AttrSelectSchema.model_validate(attr.select) if attr.select else None
)
attribute = DealModuleAttributeSchema(
attribute_id=attr.id,
label=attr_label.label if attr_label else attr.label,
original_label=attr.label,
value=attr_value.value if attr_value else None,
type=AttributeTypeSchema.model_validate(attr.type),
select=select_schema,
default_value=attr.default_value,
description=attr.description,
is_applicable_to_group=attr.is_applicable_to_group,
is_nullable=attr.is_nullable,
)
attributes.append(attribute)
return GetDealModuleAttributesResponse(attributes=attributes)
async def update_deal_module_attributes(
self, deal_id: int, module_id: int, request: UpdateDealModuleAttributesRequest
) -> UpdateDealModuleAttributesResponse:
deal_repo = DealRepository(self.repository.session)
deal = await deal_repo.get_by_id(deal_id)
if deal.group_id:
deals = await deal_repo.get_by_group_id(deal.group_id)
else:
deals = [deal]
group_deal_ids = [d.id for d in deals]
await self.repository.update_or_create_deals_attribute_values(
deal_id, group_deal_ids, module_id, request.attributes
)
return UpdateDealModuleAttributesResponse(message="Успешно сохранено")

View File

@ -1,11 +0,0 @@
from models import BuiltInModule
from repositories import BuiltInModuleRepository
from schemas.module import BuiltInModuleSchema
from services.mixins import *
class BuiltInModuleService(ServiceGetAllMixin[BuiltInModule, BuiltInModuleSchema]):
schema_class = BuiltInModuleSchema
def __init__(self, session: AsyncSession):
self.repository = BuiltInModuleRepository(session)

View File

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

37
services/deal_tag.py Normal file
View File

@ -0,0 +1,37 @@
from models import DealTag
from repositories import DealTagRepository, DealRepository
from schemas.deal_tag import *
from services.mixins import *
class DealTagService(
ServiceCrudMixin[DealTag, DealTagSchema, CreateDealTagRequest, UpdateDealTagRequest]
):
schema_class = DealTagSchema
entity_deleted_msg = "Тег успешно удален"
entity_updated_msg = "Тег успешно обновлен"
entity_created_msg = "Тег успешно создан"
def __init__(self, session: AsyncSession):
self.repository = DealTagRepository(session)
async def switch_tag(self, request: SwitchDealTagRequest) -> SwitchDealTagResponse:
tag: DealTag = await self.repository.get_by_id(request.tag_id)
if request.deal_id:
deal = await DealRepository(self.repository.session).get_by_id(
request.deal_id
)
await self.repository.switch_tag_in_deal(tag, deal)
else:
deals = await DealRepository(self.repository.session).get_by_group_id(
request.group_id
)
await self.repository.switch_tag_in_deals(tag, deals)
return SwitchDealTagResponse(ok=True, message="Успешно")
async def get_tag_colors(self) -> GetTagColorsResponse:
colors = await self.repository.get_tag_colors()
return GetTagColorsResponse(
items=[DealTagColorSchema.model_validate(color) for color in colors]
)

93
services/module.py Normal file
View File

@ -0,0 +1,93 @@
from models import Module, Attribute
from repositories import ModuleRepository, AttributeRepository
from schemas.module import *
from services.mixins import *
from utils.exceptions import ForbiddenException
class ModuleService(
ServiceGetAllMixin[Module, ModuleSchema],
ServiceCreateMixin[Module, CreateModuleRequest, ModuleSchema],
ServiceUpdateMixin[Module, UpdateModuleCommonInfoRequest],
ServiceDeleteMixin[Module],
):
schema_class = ModuleSchema
entity_updated_msg = "Модуль успешно обновлен"
entity_deleted_msg = "Модуль успешно удален"
def __init__(self, session: AsyncSession):
self.repository = ModuleRepository(session)
async def get_with_attributes(self) -> GetAllWithAttributesResponse:
result = await self.repository.get_with_attributes_as_tuples()
modules = self._build_modules_with_attributes(result)
return GetAllWithAttributesResponse(items=list(modules.values()))
async def get_by_id_with_attributes(self, pk: int) -> GetByIdWithAttributesResponse:
result = await self.repository.get_with_attributes_as_tuple_by_id(pk)
modules = self._build_modules_with_attributes(result)
module = next(iter(modules.values()), None)
if module is None:
raise ObjectNotFoundException(f"Модуль с ID {pk} не найден")
return GetByIdWithAttributesResponse(entity=module)
@staticmethod
def _build_modules_with_attributes(
result: list[tuple],
) -> dict[int, ModuleWithAttributesSchema]:
module_attrs_dict: dict[int, ModuleWithAttributesSchema] = {}
for module, attribute, attribute_label in result:
new_attr = None
if attribute:
original_label = attribute.label
label = attribute_label.label if attribute_label else original_label
attr_values = {
**attribute.__dict__,
"label": label,
"original_label": original_label,
}
new_attr = ModuleAttributeSchema(**attr_values)
module_schema = module_attrs_dict.get(module.id)
if not module_schema:
module_data = module.__dict__
del module_data["attributes"]
module_schema = ModuleWithAttributesSchema(
**module_data,
attributes=[new_attr] if new_attr else [],
)
module_attrs_dict[module.id] = module_schema
elif new_attr:
module_schema.attributes.append(new_attr)
return module_attrs_dict
async def is_soft_delete(self, module: Module) -> bool:
if module.is_built_in:
raise ForbiddenException("Нельзя менять встроенный модуль")
return True
async def add_attribute(self, module_id: int, attribute_id: int) -> AddAttributeResponse:
module, attribute = await self._get_module_and_attr_from_request(module_id, attribute_id)
await self.repository.add_attribute_to_module(module, attribute)
return AddAttributeResponse(message="Аттрибут успешно добавлен к модулю")
async def delete_attribute(
self, module_id: int, attribute_id: int
) -> DeleteAttributeResponse:
module, attribute = await self._get_module_and_attr_from_request(module_id, attribute_id)
await self.repository.delete_attribute_from_module(module, attribute)
return DeleteAttributeResponse(message="Аттрибут успешно удален из модуля")
async def _get_module_and_attr_from_request(
self, module_id: int, attribute_id: int,
) -> tuple[Module, Attribute]:
module = await self.repository.get_by_id(module_id)
if module.is_built_in:
raise ForbiddenException("Нельзя менять встроенный модуль")
attr_repo = AttributeRepository(self.repository.session)
attribute = await attr_repo.get_by_id(attribute_id)
return module, attribute

View File

@ -0,0 +1 @@
from .taskiq_broker import broker as broker

View File

@ -0,0 +1,15 @@
from taskiq_aio_pika import AioPikaBroker
from taskiq_postgresql import PostgresqlResultBackend
from backend.config import RABBITMQ_URL
from backend.session import DATABASE_URL
result_backend = PostgresqlResultBackend(
dsn=DATABASE_URL,
)
broker = AioPikaBroker(
RABBITMQ_URL,
).with_result_backend(
result_backend,
)

View File

@ -1,3 +1,11 @@
class ObjectNotFoundException(Exception):
class BaseResponseException(Exception):
def __init__(self, name: str):
self.name = name
class ObjectNotFoundException(BaseResponseException):
pass
class ForbiddenException(BaseResponseException):
pass

1581
uv.lock generated

File diff suppressed because it is too large Load Diff