Compare commits

...

22 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
61 changed files with 2213 additions and 227 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):

44
main.py
View File

@ -1,40 +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 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"]
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,13 +1,20 @@
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 (
@ -15,6 +22,12 @@ from .deal_tag import (
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

@ -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, DealTag
from models import (
Status,
Board,
DealStatusHistory,
DealGroup,
DealTag,
AttributeValue,
)
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)",
)
attributes_values: Mapped[list["AttributeValue"]] = relationship(
back_populates="deal",
)
# module client
client_id: Mapped[Optional[int]] = mapped_column(
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
if TYPE_CHECKING:
from models import Board, BuiltInModule, DealTag
from models import Board, Module, DealTag
class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin):
@ -19,11 +19,11 @@ 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(

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

@ -24,7 +24,12 @@ dependencies = [
"reportlab>=4.4.4",
"pathlib>=1.0.1",
"starlette>=0.47.2",
"lexorank>=1.0.1",
"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,7 +1,10 @@
from .attr_select import AttrSelectRepository as AttrSelectRepository
from .attribute import AttributeRepository as AttributeRepository
from .board import BoardRepository as BoardRepository
from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository
from .deal import DealRepository as DealRepository
from .deal_group import DealGroupRepository as DealGroupRepository
from .deal_tag import DealTagRepository as DealTagRepository
from .module import ModuleRepository as ModuleRepository
from .project import ProjectRepository as ProjectRepository
from .status import StatusRepository as StatusRepository
from .attr_option import AttrOptionRepository as AttrOptionRepository

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
@ -146,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

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

@ -2,7 +2,7 @@ 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
@ -26,10 +26,10 @@ class ProjectRepository(
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

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

@ -30,6 +30,7 @@ class DealSchema(BaseSchema):
class CreateDealSchema(BaseSchema):
name: str
project_id: int
board_id: int
lexorank: str
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
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

@ -2,7 +2,7 @@ from typing import Optional
from schemas.base import BaseSchema, BaseResponse
from schemas.deal_tag import DealTagSchema
from schemas.module import BuiltInModuleSchema
from schemas.module import ModuleSchema
# region Entity
@ -11,7 +11,7 @@ from schemas.module import BuiltInModuleSchema
class ProjectSchema(BaseSchema):
id: int
name: str
built_in_modules: list[BuiltInModuleSchema]
modules: list[ModuleSchema]
tags: list[DealTagSchema]
@ -21,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,6 +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 .project import ProjectService as ProjectService
from .status import StatusService as StatusService
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 .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 repositories import DealGroupRepository, DealRepository, DealTagRepository
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):
self.name = name
class ObjectNotFoundException(BaseResponseException):
pass
class ForbiddenException(BaseResponseException):
pass

173
uv.lock generated
View File

@ -2,6 +2,19 @@ version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "aio-pika"
version = "9.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiormq" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/ad/0ddde89d7a018f4304aac687e5b65c07d308644f51da3c4ae411184bb237/aio_pika-9.5.7.tar.gz", hash = "sha256:0569b59d3c7b36ca76abcb213cdc3677e2a4710a3c371dd27359039f9724f4ee", size = 47298, upload-time = "2025-08-05T18:21:18.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/be/9b08e7c4d1b3b9a1184e63965d13c811366444cb42c6e809910ab17e916c/aio_pika-9.5.7-py3-none-any.whl", hash = "sha256:684316a0e92157754bb2d6927c5568fd997518b123add342e97405aa9066772b", size = 54297, upload-time = "2025-08-05T18:21:16.99Z" },
]
[[package]]
name = "aioboto3"
version = "15.4.0"
@ -118,6 +131,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/85/13/58b70a580de00893223d61de8fea167877a3aed97d4a5e1405c9159ef925/aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796", size = 24345, upload-time = "2024-09-02T03:34:59.454Z" },
]
[[package]]
name = "aiormq"
version = "6.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pamqp" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/f6/01bc850db6d9b46ae825e3c373f610b0544e725a1159745a6de99ad0d9f1/aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3", size = 30554, upload-time = "2025-10-20T10:49:59.763Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/ec/763b13f148f3760c1562cedb593feaffbae177eeece61af5d0ace7b72a3e/aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003", size = 31829, upload-time = "2025-10-20T10:49:58.547Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@ -362,17 +388,22 @@ dependencies = [
{ name = "fastapi-endpoints" },
{ name = "fpdf" },
{ name = "gunicorn" },
{ name = "lexorank" },
{ name = "lexorank-py" },
{ name = "orjson" },
{ name = "pathlib" },
{ name = "pdfrw" },
{ name = "pydantic" },
{ name = "pymupdf" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "redis", extra = ["hiredis"] },
{ name = "reportlab" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "starlette" },
{ name = "taskiq" },
{ name = "taskiq-aio-pika" },
{ name = "taskiq-fastapi" },
{ name = "taskiq-postgresql" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "uvicorn-worker" },
]
@ -392,17 +423,22 @@ requires-dist = [
{ name = "fastapi-endpoints", git = "https://github.com/vladNed/fastapi-endpoints.git?rev=main" },
{ name = "fpdf", specifier = ">=1.7.2" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "lexorank", specifier = ">=1.0.1" },
{ name = "lexorank-py", specifier = "==0.1.1" },
{ name = "orjson", specifier = ">=3.11.1" },
{ name = "pathlib", specifier = ">=1.0.1" },
{ name = "pdfrw", specifier = ">=0.4" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pymupdf", specifier = ">=1.26.5" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "redis", extras = ["hiredis"], specifier = ">=6.2.0" },
{ name = "reportlab", specifier = ">=4.4.4" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41" },
{ name = "starlette", specifier = ">=0.47.2" },
{ name = "taskiq", specifier = ">=0.11.19" },
{ name = "taskiq-aio-pika", specifier = ">=0.4.4" },
{ name = "taskiq-fastapi", specifier = ">=0.3.5" },
{ name = "taskiq-postgresql", specifier = ">=0.4.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
{ name = "uvicorn-worker", specifier = ">=0.3.0" },
]
@ -671,6 +707,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]]
name = "izulu"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/58/6d6335c78b7ade54d8a6c6dbaa589e5c21b3fd916341d5a16f774c72652a/izulu-0.50.0.tar.gz", hash = "sha256:cc8e252d5e8560c70b95380295008eeb0786f7b745a405a40d3556ab3252d5f5", size = 48558, upload-time = "2025-03-24T15:52:21.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/9f/bf9d33546bbb6e5e80ebafe46f90b7d8b4a77410b7b05160b0ca8978c15a/izulu-0.50.0-py3-none-any.whl", hash = "sha256:4e9ae2508844e7c5f62c468a8b9e2deba2f60325ef63f01e65b39fd9a6b3fab4", size = 18095, upload-time = "2025-03-24T15:52:19.667Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@ -693,12 +750,12 @@ wheels = [
]
[[package]]
name = "lexorank"
version = "1.0.1"
name = "lexorank-py"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/58/87b3ced0716ea1997315b6f690edbe52d38f8145c12e56c35e1b7cfe306e/lexorank-1.0.1.tar.gz", hash = "sha256:e84869f626ddf4295cc848fd639a76f87bb8a17b2f649aa1d6449a4c53530fd7", size = 1758, upload-time = "2022-05-25T22:07:16.683Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/b9/a8213f5ed28cbb6f1f24354157745787e1d6776cbbc8106f11c49625d3bd/lexorank-py-0.1.1.tar.gz", hash = "sha256:2045d8cef314cea8f5bd7df036b4dffafab12a569842e60efed6c8f7c3bc31e8", size = 48670, upload-time = "2023-08-26T09:49:41.214Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/d7/b7bde2527103aeb4ddfd02e13b689a2c2ead51971276b4633e24fbbbdc68/lexorank-1.0.1-py3-none-any.whl", hash = "sha256:3a734155866e7c52b2e0e11f92226d002e79b7442271bf969b513aa5278b65c5", size = 1804, upload-time = "2022-05-25T22:07:15.03Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ce/563e2935fb89ea66828201d2eba5ce737e76866d1d0067791374007cb380/lexorank_py-0.1.1-py3-none-any.whl", hash = "sha256:b0798b15e7d47d0f94591f872e5d6cf5f2d2ea0186226673141cd1e35f28dfe4", size = 11301, upload-time = "2023-08-26T09:49:40.07Z" },
]
[[package]]
@ -850,6 +907,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pamqp"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" },
]
[[package]]
name = "pathlib"
version = "1.0.1"
@ -1002,6 +1068,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "pycron"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/5d/340be12ae4a69c33102dfb6ddc1dc6e53e69b2d504fa26b5d34a472c3057/pycron-3.2.0.tar.gz", hash = "sha256:e125a28aca0295769541a40633f70b602579df48c9cb357c36c28d2628ba2b13", size = 4248, upload-time = "2025-06-05T13:24:12.636Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/76/caf316909f4545e7158e0e1defd8956a1da49f4af04f5d16b18c358dfeac/pycron-3.2.0-py3-none-any.whl", hash = "sha256:6d2349746270bd642b71b9f7187cf13f4d9ee2412b4710396a507b5fe4f60dac", size = 4904, upload-time = "2025-06-05T13:24:11.477Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
@ -1104,6 +1179,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@ -1322,6 +1406,74 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
]
[[package]]
name = "taskiq"
version = "0.11.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "anyio" },
{ name = "importlib-metadata" },
{ name = "izulu" },
{ name = "packaging" },
{ name = "pycron" },
{ name = "pydantic" },
{ name = "pytz" },
{ name = "taskiq-dependencies" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/f8/e26c3a6d169e8ecee228d67d12003d25192d663b27f8007e41575e7a710c/taskiq-0.11.19.tar.gz", hash = "sha256:b6f03e398f0c6e7eb784f22b3c63a2efeb9e113bdce792dbd9ccb81b9edb17c4", size = 55910, upload-time = "2025-10-23T15:01:10.071Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/3a/55fe61591ba4d282cbfc12e66c6d8f5646b9ce81bbfbd958bca55eebbe5b/taskiq-0.11.19-py3-none-any.whl", hash = "sha256:630b39531284f802860fa28ec54707c7e68e57b2011bcb5f8ce9c71508a273f7", size = 81848, upload-time = "2025-10-23T15:01:08.907Z" },
]
[[package]]
name = "taskiq-aio-pika"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aio-pika" },
{ name = "taskiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/b0/e12a219535ad55034156ab66c3aa07a3673a64d2a6fda83eeabaa6115732/taskiq_aio_pika-0.4.4.tar.gz", hash = "sha256:357f37a6b8828bc61fc4091dcd80b54c6b820eb6774cc72d2f9e372c6a8c0037", size = 7180, upload-time = "2025-10-23T20:16:18.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/cb/808172ab28959a6ce9a0638398ed0a59b2ad61d09e87c092b3508f04ad3a/taskiq_aio_pika-0.4.4-py3-none-any.whl", hash = "sha256:8f3405daab41d46f9c5273bdbbb977dd43fd44e82d1b87619078794ae37bcc45", size = 7251, upload-time = "2025-10-23T20:16:17.689Z" },
]
[[package]]
name = "taskiq-dependencies"
version = "1.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/90/47a627696e53bfdcacabc3e8c05b73bf1424685bcb5f17209cb8b12da1bf/taskiq_dependencies-1.5.7.tar.gz", hash = "sha256:0d3b240872ef152b719153b9526d866d2be978aeeaea6600e878414babc2dcb4", size = 14875, upload-time = "2025-02-26T22:07:39.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/6d/4a012f2de002c2e93273f5e7d3e3feea02f7fdbb7b75ca2ca1dd10703091/taskiq_dependencies-1.5.7-py3-none-any.whl", hash = "sha256:6fcee5d159bdb035ef915d4d848826169b6f06fe57cc2297a39b62ea3e76036f", size = 13801, upload-time = "2025-02-26T22:07:38.622Z" },
]
[[package]]
name = "taskiq-fastapi"
version = "0.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "taskiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/29/d4f08a3730d23870f73404d855e5145d13d0bcf1e5376008522804afa51b/taskiq_fastapi-0.3.5.tar.gz", hash = "sha256:0df3df3e7e0a355b9f21a4f81429ad95d0d33af305e75039654ac13ae26c538f", size = 5102, upload-time = "2025-04-20T22:45:21.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/06/6ba382f300aebc5d739cd01d708572329d4360ea0c02b7734583a2894b16/taskiq_fastapi-0.3.5-py3-none-any.whl", hash = "sha256:35553cb05cfb8e34d50179bad747d79a42d782f5b9c529128ad64263af8c246f", size = 5116, upload-time = "2025-04-20T22:45:21.049Z" },
]
[[package]]
name = "taskiq-postgresql"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "taskiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/1a/dc903813bc238fcba16624e22f959e681f96bd210583a98e77c1d1c7607c/taskiq_postgresql-0.4.0.tar.gz", hash = "sha256:3e8cda663ec2893adfcf2d1f30447b2c03cc46067622e1f9e1c9a45f4e22731f", size = 125271, upload-time = "2025-10-01T17:30:28.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/c7/9a5bec60989cd1d7d97c624fd272a36efe087a0e54ae1f17117c2b56655f/taskiq_postgresql-0.4.0-py3-none-any.whl", hash = "sha256:0e64adc6faf5ffa6b67a59722e56181fc6e79a1bac6d5584fbaca6916bb1ec7b", size = 25844, upload-time = "2025-10-01T17:30:27.348Z" },
]
[[package]]
name = "typer"
version = "0.16.0"
@ -1578,3 +1730,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]