Compare commits

...

24 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
63 changed files with 3097 additions and 774 deletions

View File

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

View File

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

4
core/__init__.py Normal file
View File

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

11
core/app_settings.py Normal file
View File

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

17
core/exceptions.py Normal file
View File

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

16
core/lifespan.py Normal file
View File

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

19
core/middlewares.py Normal file
View File

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

0
external/__init__.py vendored Normal file
View File

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

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

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

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

View File

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

52
main.py
View File

@ -1,40 +1,28 @@
from fastapi import FastAPI, Request import taskiq_fastapi
from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import ORJSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
import routers import routers
from core import lifespan, settings, register_middlewares, register_exception_handlers
from task_management import broker
from utils.auto_include_routers import auto_include_routers from utils.auto_include_routers import auto_include_routers
from utils.exceptions import ObjectNotFoundException
origins = ["http://localhost:3000"]
app = FastAPI(
separate_input_output_schemas=True,
default_response_class=ORJSONResponse,
root_path="/api",
)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(
GZipMiddleware,
minimum_size=1_000,
)
@app.exception_handler(ObjectNotFoundException) def create_app() -> FastAPI:
async def unicorn_exception_handler(request: Request, exc: ObjectNotFoundException): app = FastAPI(
return JSONResponse(status_code=404, content={"detail": exc.name}) separate_input_output_schemas=True,
default_response_class=settings.DEFAULT_RESPONSE_CLASS,
root_path=settings.ROOT_PATH,
# lifespan=lifespan,
)
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
auto_include_routers(app, routers, True) app = create_app()
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@ -1,13 +1,20 @@
from sqlalchemy.orm import configure_mappers from sqlalchemy.orm import configure_mappers
from modules.fulfillment_base.models import * # noqa: F401 from modules.fulfillment_base.models import * # noqa: F401
from .attr_select import (
AttributeOption as AttributeOption,
AttributeSelect as AttributeSelect,
)
from .attribute import (
AttributeType as AttributeType,
Attribute as Attribute,
AttributeValue as AttributeValue,
AttributeLabel as AttributeLabel,
module_attribute as module_attribute,
)
from .auth import User as User
from .base import BaseModel as BaseModel from .base import BaseModel as BaseModel
from .board import Board as Board from .board import Board as Board
from .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 import Deal as Deal
from .deal_group import DealGroup as DealGroup from .deal_group import DealGroup as DealGroup
from .deal_tag import ( from .deal_tag import (
@ -15,6 +22,12 @@ from .deal_tag import (
DealTagColor as DealTagColor, DealTagColor as DealTagColor,
deals_deal_tags as deals_deal_tags, deals_deal_tags as deals_deal_tags,
) )
from .module import ( # noqa: F401
Module as Module,
ModuleTab as ModuleTab,
project_module as project_module,
module_dependencies as module_dependencies,
)
from .project import Project as Project from .project import Project as Project
from .status import Status as Status, DealStatusHistory as DealStatusHistory 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): class Board(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
__tablename__ = "boards" __tablename__ = "boards"
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column()
lexorank: Mapped[str] = mapped_column(nullable=False) lexorank: Mapped[str] = mapped_column()
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False) project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
project: Mapped["Project"] = relationship(back_populates="boards") project: Mapped["Project"] = relationship(back_populates="boards")
statuses: Mapped[list["Status"]] = relationship(back_populates="board") statuses: Mapped[list["Status"]] = relationship(back_populates="board")

View File

@ -1,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 from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from models import Status, Board, DealStatusHistory, DealGroup, DealTag from models import (
Status,
Board,
DealStatusHistory,
DealGroup,
DealTag,
AttributeValue,
)
from modules.clients.models import Client from modules.clients.models import Client
@ -49,6 +56,10 @@ class Deal(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
secondaryjoin="and_(DealTag.id == deals_deal_tags.c.deal_tag_id, DealTag.is_deleted == False)", 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 # module client
client_id: Mapped[Optional[int]] = mapped_column( client_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("clients.id", ondelete="CASCADE"), ForeignKey("clients.id", ondelete="CASCADE"),

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 from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from models import Board, BuiltInModule, DealTag from models import Board, Module, DealTag
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
@ -19,11 +19,11 @@ class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
lazy="noload", lazy="noload",
) )
built_in_modules: Mapped[list["BuiltInModule"]] = relationship( modules: Mapped[list["Module"]] = relationship(
secondary="project_built_in_module", secondary="project_module",
back_populates="projects", back_populates="projects",
lazy="selectin", lazy="selectin",
order_by="asc(BuiltInModule.id)", order_by="asc(Module.id)",
) )
tags: Mapped[list["DealTag"]] = relationship( tags: Mapped[list["DealTag"]] = relationship(

View File

@ -1,6 +1,7 @@
from io import BytesIO from io import BytesIO
from typing import Any, Optional from typing import Any, Optional
import aiohttp
from reportlab.graphics.barcode import code128 from reportlab.graphics.barcode import code128
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.platypus import Spacer, PageBreak, Paragraph from reportlab.platypus import Spacer, PageBreak, Paragraph
@ -24,7 +25,7 @@ class BarcodePdfGenerator(PDFGenerator):
return None return None
return value return value
def generate( async def generate(
self, barcodes_data: list[BarcodeData | PdfBarcodeImageGenData] self, barcodes_data: list[BarcodeData | PdfBarcodeImageGenData]
) -> BytesIO: ) -> BytesIO:
pdf_barcodes_gen_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData] = [] 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] self, barcodes_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData]
) -> BytesIO: ) -> BytesIO:
pdf_maker = PdfMaker((self.page_width, self.page_height)) pdf_maker = PdfMaker((self.page_width, self.page_height))
@ -63,9 +64,10 @@ class BarcodePdfGenerator(PDFGenerator):
for barcode_data in barcodes_data: for barcode_data in barcodes_data:
if "barcode_value" in barcode_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: 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()) pdf_files.append(self._generate_spacers())
for file in pdf_files[:-1]: for file in pdf_files[:-1]:
@ -138,11 +140,18 @@ class BarcodePdfGenerator(PDFGenerator):
buffer.seek(0) buffer.seek(0)
return buffer return buffer
def _generate_for_one_product_using_img( async def _generate_for_one_product_using_img(
self, barcode_data: PdfBarcodeImageGenData self, barcode_data: PdfBarcodeImageGenData
) -> BytesIO: ) -> BytesIO:
with open(barcode_data["barcode_image_url"], "rb") as pdf_file: pdf_url = barcode_data["barcode_image_url"]
pdf_bytes = pdf_file.read()
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)) pdf_maker = PdfMaker((self.page_width, self.page_height))
for _ in range(barcode_data["num_duplicates"]): for _ in range(barcode_data["num_duplicates"]):

View File

@ -33,4 +33,4 @@ class ProductBarcodeImage(BaseModel):
) )
product: Mapped["Product"] = relationship(back_populates="barcode_image") 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( barcode_image: Mapped["ProductBarcodeImage"] = relationship(
back_populates="product", back_populates="product",
lazy="joined", lazy="joined",
uselist=False,
) )

View File

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

View File

@ -1,7 +1,8 @@
from sqlalchemy import or_, delete from sqlalchemy import or_, delete
from sqlalchemy.orm import selectinload, joinedload 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 ( from modules.fulfillment_base.schemas.product import (
CreateProductSchema, CreateProductSchema,
UpdateProductSchema, UpdateProductSchema,
@ -16,6 +17,7 @@ class ProductRepository(
RepUpdateMixin[Product, UpdateProductSchema], RepUpdateMixin[Product, UpdateProductSchema],
RepGetByIdMixin[Product], RepGetByIdMixin[Product],
): ):
session: AsyncSession
entity_class = Product entity_class = Product
entity_not_found_msg = "Товар не найден" entity_not_found_msg = "Товар не найден"
@ -95,3 +97,36 @@ class ProductRepository(
await self._update_barcodes(product, data.barcodes) await self._update_barcodes(product, data.barcodes)
del data.barcodes del data.barcodes
return await self._apply_update_data_to_model(product, data, True) 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 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.models import ProductBarcode
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema
@ -16,6 +16,11 @@ class ProductImageSchema(BaseSchema):
image_url: str image_url: str
class ProductBarcodeImageSchema(BaseSchema):
product_id: int
image_url: str
class CreateProductSchema(BaseSchema): class CreateProductSchema(BaseSchema):
name: str name: str
article: str article: str
@ -27,10 +32,27 @@ class CreateProductSchema(BaseSchema):
composition: Optional[str] composition: Optional[str]
size: Optional[str] size: Optional[str]
additional_info: 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 id: int
barcode_template: BarcodeTemplateSchema barcode_template: BarcodeTemplateSchema
@ -75,6 +97,10 @@ class GetProductBarcodePdfRequest(BaseSchema):
barcode: str barcode: str
class GetDealBarcodesPdfRequest(BaseSchema):
deal_id: int
# endregion # endregion
# region Response # region Response
@ -93,6 +119,10 @@ class UpdateProductResponse(BaseResponse):
pass pass
class ProductUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteProductResponse(BaseResponse): class DeleteProductResponse(BaseResponse):
pass pass
@ -101,4 +131,16 @@ class GetProductBarcodePdfResponse(BasePdfResponse):
pass pass
class GetDealBarcodesPdfResponse(BasePdfResponse):
pass
class BarcodeUploadImageResponse(BaseResponse):
image_url: Optional[str] = None
class DeleteBarcodeImageResponse(BaseResponse):
pass
# endregion # endregion

View File

@ -4,9 +4,16 @@ from io import BytesIO
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from modules.fulfillment_base.barcodes_pdf_gen import BarcodePdfGenerator, BarcodeData from modules.fulfillment_base.barcodes_pdf_gen import BarcodePdfGenerator, BarcodeData
from modules.fulfillment_base.models import Product from modules.fulfillment_base.barcodes_pdf_gen.types import PdfBarcodeImageGenData
from modules.fulfillment_base.repositories import ProductRepository from modules.fulfillment_base.models import Product, DealProduct
from modules.fulfillment_base.schemas.product import GetProductBarcodePdfRequest from modules.fulfillment_base.repositories import (
ProductRepository,
DealProductRepository,
)
from modules.fulfillment_base.schemas.product import (
GetProductBarcodePdfRequest,
GetDealBarcodesPdfRequest,
)
class BarcodePrinterService: class BarcodePrinterService:
@ -15,27 +22,75 @@ class BarcodePrinterService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
async def generate_pdf( async def generate_product_pdf(
self, request: GetProductBarcodePdfRequest self, request: GetProductBarcodePdfRequest
) -> tuple[str, BytesIO]: ) -> tuple[str, BytesIO]:
product: Product = await ProductRepository(self.session).get_by_id( product: Product = await ProductRepository(self.session).get_by_id(
request.product_id request.product_id
) )
barcode_data: BarcodeData = { if product.barcode_image:
"barcode": request.barcode, barcode_data: PdfBarcodeImageGenData = {
"template": product.barcode_template, "barcode_image_url": product.barcode_image.image_url,
"product": product, "num_duplicates": request.quantity,
"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" filename = f"{product.id}_barcode.pdf"
size = product.barcode_template.size size = product.barcode_template.size
generator = BarcodePdfGenerator(size.width, size.height) 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 self, request: GetProductBarcodePdfRequest
) -> tuple[str, str]: ) -> 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") base64_string = base64.b64encode(pdf_buffer.read()).decode("utf-8")
return filename, base64_string return filename, base64_string

View File

@ -1,12 +1,11 @@
import math import math
from fastapi import UploadFile
from external.s3_uploader import S3Uploader
from modules.fulfillment_base.models import Product from modules.fulfillment_base.models import Product
from modules.fulfillment_base.repositories import ProductRepository from modules.fulfillment_base.repositories import ProductRepository
from modules.fulfillment_base.schemas.product import ( from modules.fulfillment_base.schemas.product import *
CreateProductRequest,
ProductSchema,
UpdateProductRequest, GetProductsResponse,
)
from schemas.base import PaginationSchema, PaginationInfoSchema from schemas.base import PaginationSchema, PaginationInfoSchema
from services.mixins import * from services.mixins import *
@ -46,5 +45,54 @@ class ProductService(
), ),
) )
async def is_soft_delete(self, product: ProductSchema) -> bool: async def upload_image(
return True 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", "uvicorn[standard]>=0.35.0",
"fastapi-endpoints @ git+https://github.com/vladNed/fastapi-endpoints.git@main", "fastapi-endpoints @ git+https://github.com/vladNed/fastapi-endpoints.git@main",
"uvicorn-worker>=0.3.0", "uvicorn-worker>=0.3.0",
"aioboto3>=15.4.0",
"pymupdf>=1.26.5",
"pdfrw>=0.4",
"fpdf>=1.7.2",
"reportlab>=4.4.4",
"pathlib>=1.0.1",
"starlette>=0.47.2",
"python-multipart>=0.0.20",
"lexorank-py==0.1.1",
"taskiq>=0.11.19",
"taskiq-aio-pika>=0.4.4",
"taskiq-fastapi>=0.3.5",
"taskiq-postgresql>=0.4.0",
] ]
[dependency-groups] [dependency-groups]

View File

@ -1,7 +1,10 @@
from .attr_select import AttrSelectRepository as AttrSelectRepository
from .attribute import AttributeRepository as AttributeRepository
from .board import BoardRepository as BoardRepository from .board import BoardRepository as BoardRepository
from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository
from .deal import DealRepository as DealRepository from .deal import DealRepository as DealRepository
from .deal_group import DealGroupRepository as DealGroupRepository from .deal_group import DealGroupRepository as DealGroupRepository
from .deal_tag import DealTagRepository as DealTagRepository from .deal_tag import DealTagRepository as DealTagRepository
from .module import ModuleRepository as ModuleRepository
from .project import ProjectRepository as ProjectRepository from .project import ProjectRepository as ProjectRepository
from .status import StatusRepository as StatusRepository from .status import StatusRepository as StatusRepository
from .attr_option import AttrOptionRepository as AttrOptionRepository

View File

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

View File

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

212
repositories/attribute.py Normal file
View File

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

View File

@ -1,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, DealProductService,
DealProduct, DealProduct,
) )
from repositories import AttributeRepository
from repositories.mixins import * from repositories.mixins import *
from schemas.base import SortDir from schemas.base import SortDir
from schemas.deal import UpdateDealSchema, CreateDealSchema from schemas.deal import UpdateDealSchema, CreateDealSchema
@ -146,6 +147,15 @@ class DealRepository(
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return list(result.scalars().all()) 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: def _process_get_by_id_stmt(self, stmt: Select) -> Select:
return stmt.options(joinedload(Deal.status), joinedload(Deal.board)) return stmt.options(joinedload(Deal.status), joinedload(Deal.board))

View File

@ -65,13 +65,14 @@ class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityT
data: UpdateSchemaType, data: UpdateSchemaType,
with_commit: Optional[bool] = False, with_commit: Optional[bool] = False,
fields: Optional[list[str]] = None, fields: Optional[list[str]] = None,
set_if_value_is_not_none: Optional[bool] = True,
) -> EntityType: ) -> EntityType:
if fields is None: if fields is None:
fields = data.model_dump().keys() fields = data.model_dump().keys()
for field in fields: for field in fields:
value = getattr(data, field) 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) setattr(model, field, value)
if with_commit: 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

@ -2,7 +2,7 @@ from sqlalchemy.orm import selectinload
from models import DealTag from models import DealTag
from models.project import Project from models.project import Project
from repositories.built_in_module import BuiltInModuleRepository from repositories.module import ModuleRepository
from repositories.mixins import * from repositories.mixins import *
from schemas.project import CreateProjectSchema, UpdateProjectSchema from schemas.project import CreateProjectSchema, UpdateProjectSchema
@ -26,10 +26,10 @@ class ProjectRepository(
return self._apply_options(stmt) return self._apply_options(stmt)
async def update(self, project: Project, data: UpdateProjectSchema) -> Project: async def update(self, project: Project, data: UpdateProjectSchema) -> Project:
if data.built_in_modules is not None: if data.modules is not None:
built_in_modules = data.built_in_modules modules = data.modules
module_ids = [module.id for module in built_in_modules] module_ids = [module.id for module in modules]
data.built_in_modules = await BuiltInModuleRepository( data.modules = await ModuleRepository(
self.session self.session
).get_by_ids(module_ids) ).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

@ -1,18 +1,104 @@
from fastapi import APIRouter from fastapi import APIRouter, Path
from backend.dependecies import SessionDependency from backend.dependecies import SessionDependency
from schemas.module import GetAllBuiltInModulesResponse from schemas.module import *
from services.built_in_module import BuiltInModuleService from services.module import ModuleService
router = APIRouter(tags=["modules"]) router = APIRouter(tags=["modules"])
@router.get( @router.get(
"/built-in/", "/",
response_model=GetAllBuiltInModulesResponse, response_model=GetAllModulesResponse,
operation_id="get_built_in_modules", operation_id="get_modules",
) )
async def get_built_in_modules( async def get_modules(
session: SessionDependency, 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 backend.dependecies import SessionDependency, PaginationDependency
from modules.fulfillment_base.schemas.product import * from modules.fulfillment_base.schemas.product import *
@ -46,6 +48,19 @@ async def update_product(
return await ProductService(session).update(pk, request) 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( @router.delete(
"/{pk}", "/{pk}",
response_model=DeleteProductResponse, response_model=DeleteProductResponse,
@ -64,10 +79,52 @@ async def delete_product(
response_model=GetProductBarcodePdfResponse, response_model=GetProductBarcodePdfResponse,
) )
async def get_product_barcode_pdf( async def get_product_barcode_pdf(
request: GetProductBarcodePdfRequest, session: SessionDependency session: SessionDependency,
request: GetProductBarcodePdfRequest,
): ):
service = BarcodePrinterService(session) service = BarcodePrinterService(session)
filename, base64_string = await service.generate_base64(request) filename, base64_string = await service.generate_product_base64(request)
return GetProductBarcodePdfResponse( return GetProductBarcodePdfResponse(
base64_string=base64_string, filename=filename, mime_type="application/pdf" 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

@ -30,6 +30,7 @@ class DealSchema(BaseSchema):
class CreateDealSchema(BaseSchema): class CreateDealSchema(BaseSchema):
name: str name: str
project_id: int
board_id: int board_id: int
lexorank: str lexorank: str
status_id: int status_id: int

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 # region Entity
class BuiltInModuleTabSchema(BaseSchema): class ModuleTabSchema(BaseSchema):
id: int id: int
key: str key: str
label: str label: str
icon_name: str icon_name: Optional[str]
device: str device: str
class BuiltInModuleSchema(BaseSchema): class ModuleSchema(BaseSchema):
id: int id: int
key: str key: str
label: str label: str
description: str description: Optional[str]
depends_on: list["BuiltInModuleSchema"] is_built_in: bool
tabs: list[BuiltInModuleTabSchema] 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 # endregion
@ -25,8 +58,36 @@ class BuiltInModuleSchema(BaseSchema):
# region Response # region Response
class GetAllBuiltInModulesResponse(BaseSchema): class GetAllModulesResponse(BaseSchema):
items: list[BuiltInModuleSchema] 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 # endregion

View File

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

View File

@ -1,6 +1,9 @@
from .attr_select import AttrSelectService as AttrSelectService
from .attribute import AttributeService as AttributeService
from .board import BoardService as BoardService from .board import BoardService as BoardService
from .deal import DealService as DealService from .deal import DealService as DealService
from .project import ProjectService as ProjectService
from .status import StatusService as StatusService
from .deal_group import DealGroupService as DealGroupService from .deal_group import DealGroupService as DealGroupService
from .deal_tag import DealTagService as DealTagService from .deal_tag import DealTagService as DealTagService
from .project import ProjectService as ProjectService
from .status import StatusService as StatusService
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,5 +1,4 @@
from lexorank import lexorank import lexorank
from models import DealGroup, Deal from models import DealGroup, Deal
from repositories import DealGroupRepository, DealRepository, DealTagRepository from repositories import DealGroupRepository, DealRepository, DealTagRepository
from schemas.deal_group import * from schemas.deal_group import *

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): def __init__(self, name: str):
self.name = name 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