From 276626d6f74d575c58621185f2caac094369aa1f Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Tue, 16 Sep 2025 10:54:10 +0400 Subject: [PATCH] feat: modules, products, services, services kits --- main.py | 8 +- models/__init__.py | 6 + models/built_in_module.py | 58 ++++++++ models/mixins.py | 10 +- models/project.py | 9 +- modules/__init__.py | 0 modules/fulfillment_base/__init__.py | 0 modules/fulfillment_base/enums/__init__.py | 1 + modules/fulfillment_base/enums/service.py | 7 + modules/fulfillment_base/models/__init__.py | 6 + .../fulfillment_base/models/deal_product.py | 60 ++++++++ .../fulfillment_base/models/deal_service.py | 28 ++++ modules/fulfillment_base/models/product.py | 40 +++++ modules/fulfillment_base/models/service.py | 73 ++++++++++ .../fulfillment_base/repositories/__init__.py | 6 + .../repositories/deal_product.py | 71 +++++++++ .../repositories/deal_service.py | 48 ++++++ .../fulfillment_base/repositories/product.py | 33 +++++ .../repositories/product_service.py | 84 +++++++++++ .../fulfillment_base/repositories/service.py | 20 +++ .../repositories/services_kit.py | 22 +++ modules/fulfillment_base/routers/__init__.py | 0 .../fulfillment_base/routers/deal_product.py | 137 ++++++++++++++++++ .../fulfillment_base/routers/deal_service.py | 58 ++++++++ modules/fulfillment_base/routers/product.py | 57 ++++++++ modules/fulfillment_base/routers/service.py | 55 +++++++ .../fulfillment_base/routers/services_kit.py | 55 +++++++ .../fulfillment_base/schemas/deal_product.py | 74 ++++++++++ .../fulfillment_base/schemas/deal_service.py | 64 ++++++++ modules/fulfillment_base/schemas/product.py | 77 ++++++++++ .../schemas/product_service.py | 69 +++++++++ modules/fulfillment_base/schemas/service.py | 76 ++++++++++ .../fulfillment_base/schemas/services_kit.py | 60 ++++++++ modules/fulfillment_base/services/__init__.py | 6 + .../fulfillment_base/services/deal_product.py | 72 +++++++++ .../fulfillment_base/services/deal_service.py | 45 ++++++ modules/fulfillment_base/services/product.py | 27 ++++ .../services/product_service.py | 66 +++++++++ modules/fulfillment_base/services/service.py | 27 ++++ .../fulfillment_base/services/services_kit.py | 24 +++ repositories/__init__.py | 1 + repositories/base.py | 2 + repositories/built_in_module.py | 18 +++ repositories/mixins.py | 2 +- repositories/project.py | 7 + routers/board.py | 12 +- routers/deal.py | 12 +- routers/module.py | 18 +++ routers/project.py | 12 +- routers/status.py | 12 +- schemas/module.py | 23 +++ schemas/project.py | 3 + services/built_in_module.py | 11 ++ utils/auto_include_routers.py | 51 +++++++ utils/exceptions.py | 2 + 55 files changed, 1791 insertions(+), 34 deletions(-) create mode 100644 models/built_in_module.py create mode 100644 modules/__init__.py create mode 100644 modules/fulfillment_base/__init__.py create mode 100644 modules/fulfillment_base/enums/__init__.py create mode 100644 modules/fulfillment_base/enums/service.py create mode 100644 modules/fulfillment_base/models/__init__.py create mode 100644 modules/fulfillment_base/models/deal_product.py create mode 100644 modules/fulfillment_base/models/deal_service.py create mode 100644 modules/fulfillment_base/models/product.py create mode 100644 modules/fulfillment_base/models/service.py create mode 100644 modules/fulfillment_base/repositories/__init__.py create mode 100644 modules/fulfillment_base/repositories/deal_product.py create mode 100644 modules/fulfillment_base/repositories/deal_service.py create mode 100644 modules/fulfillment_base/repositories/product.py create mode 100644 modules/fulfillment_base/repositories/product_service.py create mode 100644 modules/fulfillment_base/repositories/service.py create mode 100644 modules/fulfillment_base/repositories/services_kit.py create mode 100644 modules/fulfillment_base/routers/__init__.py create mode 100644 modules/fulfillment_base/routers/deal_product.py create mode 100644 modules/fulfillment_base/routers/deal_service.py create mode 100644 modules/fulfillment_base/routers/product.py create mode 100644 modules/fulfillment_base/routers/service.py create mode 100644 modules/fulfillment_base/routers/services_kit.py create mode 100644 modules/fulfillment_base/schemas/deal_product.py create mode 100644 modules/fulfillment_base/schemas/deal_service.py create mode 100644 modules/fulfillment_base/schemas/product.py create mode 100644 modules/fulfillment_base/schemas/product_service.py create mode 100644 modules/fulfillment_base/schemas/service.py create mode 100644 modules/fulfillment_base/schemas/services_kit.py create mode 100644 modules/fulfillment_base/services/__init__.py create mode 100644 modules/fulfillment_base/services/deal_product.py create mode 100644 modules/fulfillment_base/services/deal_service.py create mode 100644 modules/fulfillment_base/services/product.py create mode 100644 modules/fulfillment_base/services/product_service.py create mode 100644 modules/fulfillment_base/services/service.py create mode 100644 modules/fulfillment_base/services/services_kit.py create mode 100644 repositories/built_in_module.py create mode 100644 routers/module.py create mode 100644 schemas/module.py create mode 100644 services/built_in_module.py create mode 100644 utils/auto_include_routers.py create mode 100644 utils/exceptions.py diff --git a/main.py b/main.py index b624eae..2fceaf3 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,19 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import ORJSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.middleware.gzip import GZipMiddleware -from fastapi_endpoints import auto_include_routers +import modules import routers +from utils.auto_include_routers import auto_include_routers origins = ["http://localhost:3000"] app = FastAPI( separate_input_output_schemas=True, default_response_class=ORJSONResponse, - root_path="/api" + root_path="/api", ) app.add_middleware( @@ -28,5 +29,6 @@ app.add_middleware( ) auto_include_routers(app, routers) +auto_include_routers(app, modules, True) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/models/__init__.py b/models/__init__.py index d93f077..4e11006 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,7 +1,13 @@ from sqlalchemy.orm import configure_mappers +from modules.fulfillment_base.models import * # noqa: F401 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 .project import Project as Project from .status import Status as Status, CardStatusHistory as CardStatusHistory diff --git a/models/built_in_module.py b/models/built_in_module.py new file mode 100644 index 0000000..e4b7d18 --- /dev/null +++ b/models/built_in_module.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, Optional + +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() + icon_name: Mapped[Optional[str]] = mapped_column(unique=True) + 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", + ) diff --git a/models/mixins.py b/models/mixins.py index aa9546d..9d74d57 100644 --- a/models/mixins.py +++ b/models/mixins.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import DateTime +from sqlalchemy import DateTime, Numeric from sqlalchemy.orm import Mapped, mapped_column @@ -21,3 +21,11 @@ class CreatedAtMixin: default=lambda: datetime.now(timezone.utc), nullable=False, ) + + +class PriceMixin: + price: Mapped[float] = mapped_column(Numeric(12, 2), comment="Стоимость") + + +class CostMixin: + cost: Mapped[float] = mapped_column(Numeric(12, 2), comment="Себестоимость") diff --git a/models/project.py b/models/project.py index c43c59a..142da56 100644 --- a/models/project.py +++ b/models/project.py @@ -6,7 +6,7 @@ from models.base import BaseModel from models.mixins import SoftDeleteMixin, CreatedAtMixin, IdMixin if TYPE_CHECKING: - from models import Board + from models import Board, BuiltInModule class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): @@ -18,3 +18,10 @@ class Project(BaseModel, IdMixin, SoftDeleteMixin, CreatedAtMixin): back_populates="project", lazy="noload", ) + + built_in_modules: Mapped[list["BuiltInModule"]] = relationship( + secondary="project_built_in_module", + back_populates="projects", + lazy="selectin", + order_by="asc(BuiltInModule.id)", + ) diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/fulfillment_base/__init__.py b/modules/fulfillment_base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/fulfillment_base/enums/__init__.py b/modules/fulfillment_base/enums/__init__.py new file mode 100644 index 0000000..e3a9f8a --- /dev/null +++ b/modules/fulfillment_base/enums/__init__.py @@ -0,0 +1 @@ +from .service import * diff --git a/modules/fulfillment_base/enums/service.py b/modules/fulfillment_base/enums/service.py new file mode 100644 index 0000000..4453df5 --- /dev/null +++ b/modules/fulfillment_base/enums/service.py @@ -0,0 +1,7 @@ +from enum import IntEnum, unique + + +@unique +class ServiceType(IntEnum): + DEAL_SERVICE = 0 + PRODUCT_SERVICE = 1 diff --git a/modules/fulfillment_base/models/__init__.py b/modules/fulfillment_base/models/__init__.py new file mode 100644 index 0000000..5aa7c97 --- /dev/null +++ b/modules/fulfillment_base/models/__init__.py @@ -0,0 +1,6 @@ +from .deal_product import DealProduct as DealProduct, DealProductService as DealProductService +from .deal_service import ( + DealService as DealService, +) +from .product import Product as Product +from .service import Service as Service diff --git a/modules/fulfillment_base/models/deal_product.py b/modules/fulfillment_base/models/deal_product.py new file mode 100644 index 0000000..1dd26a1 --- /dev/null +++ b/modules/fulfillment_base/models/deal_product.py @@ -0,0 +1,60 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, ForeignKeyConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel +from models.mixins import PriceMixin + +if TYPE_CHECKING: + from models import Deal, Service, Product + + +class DealProduct(BaseModel): + __tablename__ = "fulfillment_base_deal_products" + + deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), primary_key=True) + product_id: Mapped[int] = mapped_column( + ForeignKey("fulfillment_base_products.id"), primary_key=True + ) + + quantity: Mapped[int] = mapped_column(default=1) + comment: Mapped[str] = mapped_column(comment="Комментарий к товару") + + deal: Mapped["Deal"] = relationship(backref="deal_products") + product: Mapped["Product"] = relationship() + + product_services: Mapped[list["DealProductService"]] = relationship( + back_populates="deal_product", + primaryjoin="and_(DealProduct.deal_id==DealProductService.deal_id, DealProduct.product_id==DealProductService.product_id)", + ) + + +class DealProductService(BaseModel, PriceMixin): + __tablename__ = "fulfillment_base_deal_products_services" + + deal_id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(primary_key=True) + service_id: Mapped[int] = mapped_column( + ForeignKey("fulfillment_base_services.id"), primary_key=True + ) + + is_fixed_price: Mapped[bool] = mapped_column( + default=False, server_default="0", comment="Фиксированная цена" + ) + + deal_product: Mapped["DealProduct"] = relationship( + back_populates="product_services", + primaryjoin="and_(DealProductService.deal_id==DealProduct.deal_id, DealProductService.product_id==DealProduct.product_id)", + ) + service: Mapped["Service"] = relationship() + + __table_args__ = ( + ForeignKeyConstraint( + ["deal_id", "product_id"], + [ + "fulfillment_base_deal_products.deal_id", + "fulfillment_base_deal_products.product_id", + ], + ), + ) diff --git a/modules/fulfillment_base/models/deal_service.py b/modules/fulfillment_base/models/deal_service.py new file mode 100644 index 0000000..29223bc --- /dev/null +++ b/modules/fulfillment_base/models/deal_service.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel +from models.mixins import PriceMixin + +if TYPE_CHECKING: + from models import Deal + from modules.fulfillment_base.models import Service + + +class DealService(BaseModel, PriceMixin): + __tablename__ = "fulfillment_base_deal_services" + + deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), primary_key=True) + deal: Mapped["Deal"] = relationship(backref="deal_services") + + service_id: Mapped[int] = mapped_column( + ForeignKey("fulfillment_base_services.id"), primary_key=True + ) + service: Mapped["Service"] = relationship() + + quantity: Mapped[int] = mapped_column(default=1) + is_fixed_price: Mapped[bool] = mapped_column( + default=False, server_default="0", comment="Фиксированная цена" + ) diff --git a/modules/fulfillment_base/models/product.py b/modules/fulfillment_base/models/product.py new file mode 100644 index 0000000..fe83b42 --- /dev/null +++ b/modules/fulfillment_base/models/product.py @@ -0,0 +1,40 @@ +from typing import Optional + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel +from models.mixins import IdMixin, SoftDeleteMixin + + +class Product(BaseModel, IdMixin, SoftDeleteMixin): + __tablename__ = "fulfillment_base_products" + + name: Mapped[str] = mapped_column() + article: Mapped[str] = mapped_column(index=True) + factory_article: Mapped[str] = mapped_column( + index=True, default="", server_default="" + ) + brand: Mapped[Optional[str]] = mapped_column(comment="Бренд") + color: Mapped[Optional[str]] = mapped_column(comment="Цвет") + composition: Mapped[Optional[str]] = mapped_column(comment="Состав") + size: Mapped[Optional[str]] = mapped_column(comment="Размер") + additional_info: Mapped[Optional[str]] = mapped_column( + comment="Дополнительная информация" + ) + + images: Mapped[list["ProductImage"]] = relationship( + "ProductImage", + back_populates="product", + lazy="selectin", + cascade="all, delete-orphan", + ) + + +class ProductImage(BaseModel, IdMixin): + __tablename__ = "fulfillment_base_product_images" + + product_id: Mapped[int] = mapped_column(ForeignKey("fulfillment_base_products.id")) + product: Mapped["Product"] = relationship(back_populates="images") + + image_url: Mapped[str] = mapped_column(nullable=False) diff --git a/modules/fulfillment_base/models/service.py b/modules/fulfillment_base/models/service.py new file mode 100644 index 0000000..7f27b93 --- /dev/null +++ b/modules/fulfillment_base/models/service.py @@ -0,0 +1,73 @@ +from sqlalchemy import ForeignKey, Table, Column +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel +from models.mixins import IdMixin, SoftDeleteMixin, CostMixin, PriceMixin +from modules.fulfillment_base import enums + +services_kit_services = Table( + "fulfillment_base_services_kit_services", + BaseModel.metadata, + Column("services_kit_id", ForeignKey("fulfillment_base_services_kits.id")), + Column("service_id", ForeignKey("fulfillment_base_services.id")), +) + + +class Service(BaseModel, IdMixin, SoftDeleteMixin, PriceMixin, CostMixin): + __tablename__ = "fulfillment_base_services" + + name: Mapped[str] = mapped_column(index=True) + + price_ranges: Mapped[list["ServicePriceRange"]] = relationship( + back_populates="service", + lazy="selectin", + order_by="asc(ServicePriceRange.from_quantity)", + cascade="all, delete-orphan", + ) + + category_id: Mapped[int] = mapped_column( + ForeignKey("fulfillment_base_service_categories.id"), + comment="ID категории услуги", + ) + category: Mapped["ServiceCategory"] = relationship("ServiceCategory", lazy="joined") + + service_type: Mapped[int] = mapped_column( + server_default=f"{enums.service.ServiceType.DEAL_SERVICE}", + comment="Тип услуги", + ) + lexorank: Mapped[str] = mapped_column(comment="Ранг услуги") + + +class ServiceCategory(BaseModel, IdMixin): + __tablename__ = "fulfillment_base_service_categories" + + name: Mapped[str] = mapped_column() + is_deleted: Mapped[bool] = mapped_column( + default=False, comment="Удалена ли категория" + ) + + deal_service_rank: Mapped[str] = mapped_column(comment="Ранг услуги для сделки") + product_service_rank: Mapped[str] = mapped_column(comment="Ранг услуги для товара") + + +class ServicesKit(BaseModel, IdMixin): + __tablename__ = "fulfillment_base_services_kits" + + name: Mapped[str] = mapped_column() + service_type: Mapped[int] = mapped_column( + server_default=f"{enums.ServiceType.DEAL_SERVICE}", + comment="Тип услуги", + ) + services: Mapped[list["Service"]] = relationship( + secondary=services_kit_services, lazy="selectin" + ) + + +class ServicePriceRange(BaseModel, IdMixin, PriceMixin): + __tablename__ = "fulfillment_base_service_price_ranges" + + service_id: Mapped[int] = mapped_column(ForeignKey("fulfillment_base_services.id")) + service: Mapped[Service] = relationship(back_populates="price_ranges") + + from_quantity: Mapped[int] = mapped_column(comment="От количества") + to_quantity: Mapped[int] = mapped_column(comment="До количества") diff --git a/modules/fulfillment_base/repositories/__init__.py b/modules/fulfillment_base/repositories/__init__.py new file mode 100644 index 0000000..a3beb73 --- /dev/null +++ b/modules/fulfillment_base/repositories/__init__.py @@ -0,0 +1,6 @@ +from .deal_product import DealProductRepository as DealProductRepository +from .product_service import ProductServiceRepository as ProductServiceRepository +from .deal_service import DealServiceRepository as DealServiceRepository +from .product import ProductRepository as ProductRepository +from .service import ServiceRepository as ServiceRepository +from .services_kit import ServicesKitRepository as ServicesKitRepository diff --git a/modules/fulfillment_base/repositories/deal_product.py b/modules/fulfillment_base/repositories/deal_product.py new file mode 100644 index 0000000..e993f35 --- /dev/null +++ b/modules/fulfillment_base/repositories/deal_product.py @@ -0,0 +1,71 @@ +from typing import Optional + +from sqlalchemy import Select, select +from sqlalchemy.orm import joinedload, selectinload + +from modules.fulfillment_base.models import DealProductService +from modules.fulfillment_base.models.deal_product import DealProduct +from modules.fulfillment_base.models.service import ServicesKit +from modules.fulfillment_base.schemas.deal_product import ( + UpdateDealProductSchema, + CreateDealProductSchema, +) +from repositories.base import BaseRepository +from repositories.mixins import RepGetAllMixin, RepUpdateMixin + + +class DealProductRepository( + BaseRepository, + RepGetAllMixin[DealProduct], + RepUpdateMixin[DealProduct, UpdateDealProductSchema], +): + entity_class = DealProduct + + def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: + deal_id = args[0] + return ( + stmt.options( + joinedload(DealProduct.product), + selectinload(DealProduct.product_services).joinedload( + DealProductService.service + ), + ) + .where(DealProduct.deal_id == deal_id) + .order_by(DealProduct.product_id) + ) + + async def get_by_id(self, deal_id: int, product_id: int) -> Optional[DealProduct]: + stmt = ( + select(DealProduct) + .options( + joinedload(DealProduct.product), + selectinload(DealProduct.product_services).joinedload( + DealProductService.service + ), + ) + .where(DealProduct.deal_id == deal_id, DealProduct.product_id == product_id) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def create(self, data: CreateDealProductSchema): + deal_product = DealProduct(**data.model_dump()) + self.session.add(deal_product) + await self.session.commit() + + async def delete(self, obj: DealProduct): + await self.session.delete(obj) + await self.session.commit() + + async def add_services_kit( + self, deal_product: DealProduct, services_kit: ServicesKit + ): + for service in services_kit.services: + deal_product_service = DealProductService( + deal_id=deal_product.deal_id, + product_id=deal_product.product_id, + service_id=service.id, + price=service.price, + ) + self.session.add(deal_product_service) + await self.session.commit() diff --git a/modules/fulfillment_base/repositories/deal_service.py b/modules/fulfillment_base/repositories/deal_service.py new file mode 100644 index 0000000..e965cfa --- /dev/null +++ b/modules/fulfillment_base/repositories/deal_service.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sqlalchemy import Select, select +from sqlalchemy.orm import joinedload + +from modules.fulfillment_base.models import DealService +from modules.fulfillment_base.schemas.deal_service import ( + UpdateDealServiceSchema, + CreateDealServiceSchema, +) +from repositories.base import BaseRepository +from repositories.mixins import RepGetAllMixin, RepUpdateMixin + + +class DealServiceRepository( + BaseRepository, + RepGetAllMixin[DealService], + RepUpdateMixin[DealService, UpdateDealServiceSchema], +): + entity_class = DealService + + def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: + deal_id = args[0] + return ( + stmt.options( + joinedload(DealService.service), + ) + .where(DealService.deal_id == deal_id) + .order_by(DealService.service_id) + ) + + async def get_by_id(self, deal_id: int, service_id: int) -> Optional[DealService]: + stmt = ( + select(DealService) + .options(joinedload(DealService.service)) + .where(DealService.deal_id == deal_id, DealService.service_id == service_id) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def create(self, data: CreateDealServiceSchema): + deal_service = DealService(**data.model_dump()) + self.session.add(deal_service) + await self.session.commit() + + async def delete(self, obj: DealService): + await self.session.delete(obj) + await self.session.commit() diff --git a/modules/fulfillment_base/repositories/product.py b/modules/fulfillment_base/repositories/product.py new file mode 100644 index 0000000..21d1bdc --- /dev/null +++ b/modules/fulfillment_base/repositories/product.py @@ -0,0 +1,33 @@ +from modules.fulfillment_base.models import Product +from modules.fulfillment_base.schemas.product import ( + CreateProductSchema, + UpdateProductSchema, +) +from repositories.mixins import * +from schemas.base import PaginationSchema + + +class ProductRepository( + BaseRepository, + RepGetAllMixin[Product], + RepDeleteMixin[Product], + RepCreateMixin[Product, CreateProductSchema], + RepUpdateMixin[Product, UpdateProductSchema], + RepGetByIdMixin[Product], +): + entity_class = Product + + def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: + search_input = args[0] + pagination: PaginationSchema = args[1] + if search_input: + stmt = stmt.where(Product.name.ilike(f"%{search_input}%")) + if pagination.items_per_page and pagination.page: + stmt = self._apply_pagination( + stmt, pagination.page, pagination.items_per_page + ) + + return stmt + + async def update(self, product: Product, data: UpdateProductSchema) -> Product: + return await self._apply_update_data_to_model(product, data, True) diff --git a/modules/fulfillment_base/repositories/product_service.py b/modules/fulfillment_base/repositories/product_service.py new file mode 100644 index 0000000..a00554c --- /dev/null +++ b/modules/fulfillment_base/repositories/product_service.py @@ -0,0 +1,84 @@ +from typing import Optional + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from modules.fulfillment_base.models import DealProductService +from modules.fulfillment_base.schemas.product_service import * +from repositories.base import BaseRepository +from repositories.mixins import RepUpdateMixin + + +class ProductServiceRepository( + BaseRepository, + RepUpdateMixin[DealProductService, UpdateProductServiceSchema], +): + entity_class = DealProductService + session: AsyncSession + + async def get_by_id( + self, deal_id: int, product_id: int, service_id: int + ) -> Optional[DealProductService]: + stmt = ( + select(DealProductService) + .options( + joinedload(DealProductService.service), + ) + .where( + DealProductService.deal_id == deal_id, + DealProductService.product_id == product_id, + DealProductService.service_id == service_id, + ) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def create(self, data: CreateProductServiceSchema): + deal_product_service = DealProductService(**data.model_dump()) + self.session.add(deal_product_service) + await self.session.commit() + + async def delete(self, obj: DealProductService): + await self.session.delete(obj) + await self.session.commit() + + async def get_product_services( + self, deal_id: int, product_id: int + ) -> list[DealProductService]: + stmt = ( + select(DealProductService) + .options( + joinedload(DealProductService.service), + ) + .where( + DealProductService.deal_id == deal_id, + DealProductService.product_id == product_id, + ) + ) + return list(await self.session.scalars(stmt)) + + async def delete_product_services(self, deal_id: int, product_ids: list[int]): + stmt = delete(DealProductService).where( + DealProductService.deal_id == deal_id, + DealProductService.product_id.in_(product_ids), + ) + await self.session.execute(stmt) + await self.session.flush() + + async def duplicate_services( + self, deal_id: int, product_ids: list[int], services: list[DealProductService] + ): + await self.delete_product_services(deal_id, product_ids) + + for product_id in product_ids: + for prod_service in services: + product_service = DealProductService( + deal_id=deal_id, + product_id=product_id, + service_id=prod_service.service.id, + price=prod_service.price, + is_fixed_price=prod_service.is_fixed_price, + ) + self.session.add(product_service) + await self.session.commit() diff --git a/modules/fulfillment_base/repositories/service.py b/modules/fulfillment_base/repositories/service.py new file mode 100644 index 0000000..c88284b --- /dev/null +++ b/modules/fulfillment_base/repositories/service.py @@ -0,0 +1,20 @@ +from modules.fulfillment_base.models import Service +from modules.fulfillment_base.schemas.service import ( + CreateServiceSchema, + UpdateServiceSchema, +) +from repositories.mixins import * + + +class ServiceRepository( + BaseRepository, + RepGetAllMixin[Service], + RepDeleteMixin[Service], + RepCreateMixin[Service, CreateServiceSchema], + RepUpdateMixin[Service, UpdateServiceSchema], + RepGetByIdMixin[Service], +): + entity_class = Service + + async def update(self, service: Service, data: UpdateServiceSchema) -> Service: + return await self._apply_update_data_to_model(service, data, True) diff --git a/modules/fulfillment_base/repositories/services_kit.py b/modules/fulfillment_base/repositories/services_kit.py new file mode 100644 index 0000000..893fbb8 --- /dev/null +++ b/modules/fulfillment_base/repositories/services_kit.py @@ -0,0 +1,22 @@ +from sqlalchemy.orm import selectinload + +from modules.fulfillment_base.models.service import ServicesKit +from modules.fulfillment_base.schemas.services_kit import ( + CreateServicesKitSchema, + UpdateServicesKitSchema, +) +from repositories.mixins import * + + +class ServicesKitRepository( + RepCrudMixin[ServicesKit, CreateServicesKitSchema, UpdateServicesKitSchema], +): + entity_class = ServicesKit + + def _process_get_by_id_stmt(self, stmt: Select) -> Select: + return stmt.options(selectinload(ServicesKit.services)) + + async def update( + self, service: ServicesKit, data: UpdateServicesKitSchema + ) -> ServicesKit: + return await self._apply_update_data_to_model(service, data, True) diff --git a/modules/fulfillment_base/routers/__init__.py b/modules/fulfillment_base/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/fulfillment_base/routers/deal_product.py b/modules/fulfillment_base/routers/deal_product.py new file mode 100644 index 0000000..b67816f --- /dev/null +++ b/modules/fulfillment_base/routers/deal_product.py @@ -0,0 +1,137 @@ +from fastapi import APIRouter, Path + +from backend.dependecies import SessionDependency +from modules.fulfillment_base.schemas.deal_product import * +from modules.fulfillment_base.schemas.product_service import * +from modules.fulfillment_base.services import DealProductService, ProductServiceService + +router = APIRouter(tags=["deal-product"]) + +# region DealProduct + + +@router.get( + "/{dealId}", + response_model=GetDealProductsResponse, + operation_id="get_deal_products", +) +async def get_deal_products( + session: SessionDependency, + deal_id: int = Path(alias="dealId"), +): + return await DealProductService(session).get_all(deal_id) + + +@router.post( + "/", + response_model=CreateDealProductResponse, + operation_id="create_deal_product", +) +async def create_deal_product( + session: SessionDependency, + request: CreateDealProductRequest, +): + return await DealProductService(session).create(request) + + +@router.patch( + "/{dealId}/product/{productId}", + response_model=UpdateDealProductResponse, + operation_id="update_deal_product", +) +async def update_deal_product( + session: SessionDependency, + request: UpdateDealProductRequest, + deal_id: int = Path(alias="dealId"), + product_id: int = Path(alias="productId"), +): + return await DealProductService(session).update(deal_id, product_id, request) + + +@router.delete( + "/{dealId}/product/{productId}", + response_model=DeleteDealProductResponse, + operation_id="delete_deal_product", +) +async def delete_deal_product( + session: SessionDependency, + deal_id: int = Path(alias="dealId"), + product_id: int = Path(alias="productId"), +): + return await DealProductService(session).delete(deal_id, product_id) + + +@router.post( + "/add-services-kit", + response_model=DealProductAddKitResponse, + operation_id="add_kit_to_deal_product", +) +async def add_kit_to_deal_product( + session: SessionDependency, + request: DealProductAddKitRequest, +): + return await DealProductService(session).add_services_kit(request) + + +# endregion + + +# region DealProductService + + +@router.post( + "/service", + response_model=CreateProductServiceResponse, + operation_id="create_deal_product_service", +) +async def create_deal_product_service( + session: SessionDependency, + request: CreateProductServiceRequest, +): + return await ProductServiceService(session).create(request) + + +@router.patch( + "/{dealId}/product/{productId}/service/{serviceId}", + response_model=UpdateProductServiceResponse, + operation_id="update_deal_product_service", +) +async def update_deal_product_service( + session: SessionDependency, + request: UpdateProductServiceRequest, + deal_id: int = Path(alias="dealId"), + product_id: int = Path(alias="productId"), + service_id: int = Path(alias="serviceId"), +): + return await ProductServiceService(session).update( + deal_id, product_id, service_id, request + ) + + +@router.delete( + "/{dealId}/product/{productId}/service/{serviceId}", + response_model=DeleteProductServiceResponse, + operation_id="delete_deal_product_service", +) +async def delete_deal_product_service( + session: SessionDependency, + deal_id: int = Path(alias="dealId"), + product_id: int = Path(alias="productId"), + service_id: int = Path(alias="serviceId"), +): + return await ProductServiceService(session).delete(deal_id, product_id, service_id) + + +@router.post( + "/services/duplicate", + response_model=ProductServicesDuplicateResponse, + operation_id="duplicate_product_services", +) +async def copy_product_services( + session: SessionDependency, + request: ProductServicesDuplicateRequest, +): + return await ProductServiceService(session).duplicate_product_services(request) + + +# endregion diff --git a/modules/fulfillment_base/routers/deal_service.py b/modules/fulfillment_base/routers/deal_service.py new file mode 100644 index 0000000..810733a --- /dev/null +++ b/modules/fulfillment_base/routers/deal_service.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Path + +from backend.dependecies import SessionDependency +from modules.fulfillment_base.schemas.deal_service import * +from modules.fulfillment_base.services import DealServiceService + +router = APIRouter(tags=["deal-service"]) + + +@router.get( + "/{dealId}", + response_model=GetDealServicesResponse, + operation_id="get_deal_services", +) +async def get_deal_services( + session: SessionDependency, + deal_id: int = Path(alias="dealId"), +): + return await DealServiceService(session).get_all(deal_id) + + +@router.post( + "/", + response_model=CreateDealServiceResponse, + operation_id="create_deal_service", +) +async def create_deal_service( + session: SessionDependency, + request: CreateDealServiceRequest, +): + return await DealServiceService(session).create(request) + + +@router.patch( + "/{dealId}/service/{serviceId}", + response_model=UpdateDealServiceResponse, + operation_id="update_deal_service", +) +async def update_deal_service( + session: SessionDependency, + request: UpdateDealServiceRequest, + deal_id: int = Path(alias="dealId"), + service_id: int = Path(alias="serviceId"), +): + return await DealServiceService(session).update(deal_id, service_id, request) + + +@router.delete( + "/{dealId}/service/{serviceId}", + response_model=DeleteDealServiceResponse, + operation_id="delete_deal_service", +) +async def delete_deal_service( + session: SessionDependency, + deal_id: int = Path(alias="dealId"), + service_id: int = Path(alias="serviceId"), +): + return await DealServiceService(session).delete(deal_id, service_id) diff --git a/modules/fulfillment_base/routers/product.py b/modules/fulfillment_base/routers/product.py new file mode 100644 index 0000000..f6a3822 --- /dev/null +++ b/modules/fulfillment_base/routers/product.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Path, Query + +from backend.dependecies import SessionDependency, PaginationDependency +from modules.fulfillment_base.schemas.product import * +from modules.fulfillment_base.services import ProductService + +router = APIRouter(tags=["product"]) + + +@router.get( + "/", + response_model=GetProductsResponse, + operation_id="get_products", +) +async def get_products( + session: SessionDependency, + pagination: PaginationDependency, + search_input: Optional[str] = Query(alias="searchInput", default=None), +): + return await ProductService(session).get_all(search_input, pagination) + + +@router.post( + "/", + response_model=CreateProductResponse, + operation_id="create_product", +) +async def create_product( + session: SessionDependency, + request: CreateProductRequest, +): + return await ProductService(session).create(request) + + +@router.patch( + "/{pk}", + response_model=UpdateProductResponse, + operation_id="update_product", +) +async def update_product( + session: SessionDependency, + request: UpdateProductRequest, + pk: int = Path(), +): + return await ProductService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteProductResponse, + operation_id="delete_product", +) +async def delete_product( + session: SessionDependency, + pk: int = Path(), +): + return await ProductService(session).delete(pk) diff --git a/modules/fulfillment_base/routers/service.py b/modules/fulfillment_base/routers/service.py new file mode 100644 index 0000000..a615483 --- /dev/null +++ b/modules/fulfillment_base/routers/service.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Path + +from backend.dependecies import SessionDependency +from modules.fulfillment_base.schemas.service import * +from modules.fulfillment_base.services import ServiceModelService + +router = APIRouter(tags=["service"]) + + +@router.get( + "/", + response_model=GetServicesResponse, + operation_id="get_services", +) +async def get_services( + session: SessionDependency, +): + return await ServiceModelService(session).get_all() + + +@router.post( + "/", + response_model=CreateServiceResponse, + operation_id="create_service", +) +async def create_service( + session: SessionDependency, + request: CreateServiceRequest, +): + return await ServiceModelService(session).create(request) + + +@router.patch( + "/{pk}", + response_model=UpdateServiceResponse, + operation_id="update_service", +) +async def update_service( + session: SessionDependency, + request: UpdateServiceRequest, + pk: int = Path(), +): + return await ServiceModelService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteServiceResponse, + operation_id="delete_service", +) +async def delete_service( + session: SessionDependency, + pk: int = Path(), +): + return await ServiceModelService(session).delete(pk) diff --git a/modules/fulfillment_base/routers/services_kit.py b/modules/fulfillment_base/routers/services_kit.py new file mode 100644 index 0000000..23be750 --- /dev/null +++ b/modules/fulfillment_base/routers/services_kit.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Path + +from backend.dependecies import SessionDependency +from modules.fulfillment_base.schemas.services_kit import * +from modules.fulfillment_base.services import ServicesKitService + +router = APIRouter(tags=["services-kit"]) + + +@router.get( + "/", + response_model=GetServicesKitResponse, + operation_id="get_services_kits", +) +async def get_services_kits( + session: SessionDependency, +): + return await ServicesKitService(session).get_all() + + +@router.post( + "/", + response_model=CreateServicesKitResponse, + operation_id="create_services_kit", +) +async def create_services_kit( + session: SessionDependency, + request: CreateServicesKitRequest, +): + return await ServicesKitService(session).create(request) + + +@router.patch( + "/{pk}", + response_model=UpdateServicesKitResponse, + operation_id="update_services_kit", +) +async def update_services_kit( + session: SessionDependency, + request: UpdateServicesKitRequest, + pk: int = Path(), +): + return await ServicesKitService(session).update(pk, request) + + +@router.delete( + "/{pk}", + response_model=DeleteServicesKitResponse, + operation_id="delete_services_kit", +) +async def delete_services_kit( + session: SessionDependency, + pk: int = Path(), +): + return await ServicesKitService(session).delete(pk) diff --git a/modules/fulfillment_base/schemas/deal_product.py b/modules/fulfillment_base/schemas/deal_product.py new file mode 100644 index 0000000..425bcbc --- /dev/null +++ b/modules/fulfillment_base/schemas/deal_product.py @@ -0,0 +1,74 @@ +from modules.fulfillment_base.schemas.product import ProductSchema +from modules.fulfillment_base.schemas.product_service import ProductServiceSchema +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class DealProductSchema(BaseSchema): + deal_id: int + product_id: int + product: ProductSchema + quantity: int + comment: str + product_services: list[ProductServiceSchema] + + +class CreateDealProductSchema(BaseSchema): + deal_id: int + product_id: int + quantity: int + comment: str + + +class UpdateDealProductSchema(BaseSchema): + quantity: int + comment: str + + +# endregion + +# region Request + + +class CreateDealProductRequest(BaseSchema): + entity: CreateDealProductSchema + + +class UpdateDealProductRequest(BaseSchema): + entity: UpdateDealProductSchema + + +class DealProductAddKitRequest(BaseSchema): + deal_id: int + product_id: int + kit_id: int + + +# endregion + +# region Response + + +class GetDealProductsResponse(BaseSchema): + items: list[DealProductSchema] + + +class CreateDealProductResponse(BaseResponse): + entity: DealProductSchema + + +class UpdateDealProductResponse(BaseResponse): + pass + + +class DeleteDealProductResponse(BaseResponse): + pass + + +class DealProductAddKitResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/schemas/deal_service.py b/modules/fulfillment_base/schemas/deal_service.py new file mode 100644 index 0000000..c953c43 --- /dev/null +++ b/modules/fulfillment_base/schemas/deal_service.py @@ -0,0 +1,64 @@ +from modules.fulfillment_base.schemas.service import ServiceSchema +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class DealServiceSchema(BaseSchema): + deal_id: int + service_id: int + service: ServiceSchema + quantity: int + price: float + is_fixed_price: bool + + +class CreateDealServiceSchema(BaseSchema): + deal_id: int + service_id: int + quantity: int + price: float + + +class UpdateDealServiceSchema(BaseSchema): + quantity: int + price: float + is_fixed_price: bool + + +# endregion + +# region Request + + +class CreateDealServiceRequest(BaseSchema): + entity: CreateDealServiceSchema + + +class UpdateDealServiceRequest(BaseSchema): + entity: UpdateDealServiceSchema + + +# endregion + +# region Response + + +class GetDealServicesResponse(BaseSchema): + items: list[DealServiceSchema] + + +class CreateDealServiceResponse(BaseResponse): + entity: DealServiceSchema + + +class UpdateDealServiceResponse(BaseResponse): + pass + + +class DeleteDealServiceResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/schemas/product.py b/modules/fulfillment_base/schemas/product.py new file mode 100644 index 0000000..2e67f31 --- /dev/null +++ b/modules/fulfillment_base/schemas/product.py @@ -0,0 +1,77 @@ +from typing import Optional + +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class ProductImageSchema(BaseSchema): + id: int + product_id: int + image_url: str + + +class CreateProductSchema(BaseSchema): + name: str + article: str + factory_article: str + brand: Optional[str] + color: Optional[str] + composition: Optional[str] + size: Optional[str] + additional_info: Optional[str] + + +class ProductSchema(CreateProductSchema): + id: int + + +class UpdateProductSchema(BaseSchema): + name: Optional[str] = None + article: Optional[str] = None + factory_article: Optional[str] = None + brand: Optional[str] = None + color: Optional[str] = None + composition: Optional[str] = None + size: Optional[str] = None + additional_info: Optional[str] = None + + images: list[ProductImageSchema] | None = [] + + +# endregion + +# region Request + + +class CreateProductRequest(BaseSchema): + entity: CreateProductSchema + + +class UpdateProductRequest(BaseSchema): + entity: UpdateProductSchema + + +# endregion + +# region Response + + +class GetProductsResponse(BaseSchema): + items: list[ProductSchema] + + +class CreateProductResponse(BaseResponse): + entity: ProductSchema + + +class UpdateProductResponse(BaseResponse): + pass + + +class DeleteProductResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/schemas/product_service.py b/modules/fulfillment_base/schemas/product_service.py new file mode 100644 index 0000000..cd3d57a --- /dev/null +++ b/modules/fulfillment_base/schemas/product_service.py @@ -0,0 +1,69 @@ +from modules.fulfillment_base.schemas.service import ServiceSchema +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class ProductServiceSchema(BaseSchema): + deal_id: int + product_id: int + service_id: int + service: ServiceSchema + price: float + is_fixed_price: bool + + +class CreateProductServiceSchema(BaseSchema): + deal_id: int + product_id: int + service_id: int + price: float + + +class UpdateProductServiceSchema(BaseSchema): + price: float + is_fixed_price: bool + + +# endregion + +# region Request + + +class CreateProductServiceRequest(BaseSchema): + entity: CreateProductServiceSchema + + +class UpdateProductServiceRequest(BaseSchema): + entity: UpdateProductServiceSchema + + +class ProductServicesDuplicateRequest(BaseSchema): + deal_id: int + source_deal_product_id: int + target_deal_product_ids: list[int] + + +# endregion + +# region Response + + +class CreateProductServiceResponse(BaseResponse): + entity: ProductServiceSchema + + +class UpdateProductServiceResponse(BaseResponse): + pass + + +class DeleteProductServiceResponse(BaseResponse): + pass + + +class ProductServicesDuplicateResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/schemas/service.py b/modules/fulfillment_base/schemas/service.py new file mode 100644 index 0000000..ad64700 --- /dev/null +++ b/modules/fulfillment_base/schemas/service.py @@ -0,0 +1,76 @@ +from typing import Optional + +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class ServicePriceRangeSchema(BaseSchema): + id: int | None + from_quantity: int + to_quantity: int + price: float + + +class ServiceCategorySchema(BaseSchema): + id: int + name: str + deal_service_rank: str + product_service_rank: str + + +class ServiceSchema(BaseSchema): + id: int + name: str + category: ServiceCategorySchema + price: float + service_type: int + price_ranges: list[ServicePriceRangeSchema] + cost: Optional[float] + lexorank: str + + +class UpdateServiceSchema(ServiceSchema): + pass + + +class CreateServiceSchema(ServiceSchema): + pass + + +# endregion + +# region Request + + +class CreateServiceRequest(BaseSchema): + entity: CreateServiceSchema + + +class UpdateServiceRequest(BaseSchema): + entity: UpdateServiceSchema + + +# endregion + +# region Response + + +class GetServicesResponse(BaseSchema): + items: list[ServiceSchema] + + +class CreateServiceResponse(BaseResponse): + entity: ServiceSchema + + +class UpdateServiceResponse(BaseResponse): + pass + + +class DeleteServiceResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/schemas/services_kit.py b/modules/fulfillment_base/schemas/services_kit.py new file mode 100644 index 0000000..70db766 --- /dev/null +++ b/modules/fulfillment_base/schemas/services_kit.py @@ -0,0 +1,60 @@ +from modules.fulfillment_base.schemas.service import ServiceSchema +from schemas.base import BaseSchema, BaseResponse + + +# region Entity + + +class BaseServicesKitSchema(BaseSchema): + name: str + service_type: int + + +class ServicesKitSchema(BaseServicesKitSchema): + id: int + services: list[ServiceSchema] + + +class CreateServicesKitSchema(BaseServicesKitSchema): + services_ids: list[int] + + +class UpdateServicesKitSchema(BaseServicesKitSchema): + services_ids: list[int] + + +# endregion + +# region Request + + +class CreateServicesKitRequest(BaseSchema): + entity: CreateServicesKitSchema + + +class UpdateServicesKitRequest(BaseSchema): + entity: UpdateServicesKitSchema + + +# endregion + +# region Response + + +class GetServicesKitResponse(BaseSchema): + items: list[ServicesKitSchema] + + +class CreateServicesKitResponse(BaseResponse): + entity: ServicesKitSchema + + +class UpdateServicesKitResponse(BaseResponse): + pass + + +class DeleteServicesKitResponse(BaseResponse): + pass + + +# endregion diff --git a/modules/fulfillment_base/services/__init__.py b/modules/fulfillment_base/services/__init__.py new file mode 100644 index 0000000..d9f56e1 --- /dev/null +++ b/modules/fulfillment_base/services/__init__.py @@ -0,0 +1,6 @@ +from .deal_product import DealProductService as DealProductService +from .deal_service import DealServiceService as DealServiceService +from .product import ProductService as ProductService +from .product_service import ProductServiceService as ProductServiceService +from .service import ServiceModelService as ServiceModelService +from .services_kit import ServicesKitService as ServicesKitService diff --git a/modules/fulfillment_base/services/deal_product.py b/modules/fulfillment_base/services/deal_product.py new file mode 100644 index 0000000..21e44cb --- /dev/null +++ b/modules/fulfillment_base/services/deal_product.py @@ -0,0 +1,72 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from modules.fulfillment_base.models import DealProduct +from modules.fulfillment_base.repositories import ( + DealProductRepository, + ServicesKitRepository, + ProductServiceRepository, +) +from modules.fulfillment_base.schemas.deal_product import * +from services.mixins import ServiceGetAllMixin + + +class DealProductService(ServiceGetAllMixin[DealProduct, DealProductSchema]): + schema_class = DealProductSchema + entity_not_found_msg = "Связь товара со сделкой не найдена" + + def __init__(self, session: AsyncSession): + self.repository = DealProductRepository(session) + + async def create( + self, request: CreateDealProductRequest + ) -> CreateDealProductResponse: + await self.repository.create(request.entity) + deal_product = await self.repository.get_by_id( + request.entity.deal_id, request.entity.product_id + ) + return CreateDealProductResponse( + entity=DealProductSchema.model_validate(deal_product), + message="Товар добавлен в сделку", + ) + + async def update( + self, deal_id: int, product_id: int, data: UpdateDealProductRequest + ) -> UpdateDealProductResponse: + entity = await self.repository.get_by_id(deal_id, product_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.update(entity, data.entity) + return UpdateDealProductResponse(message="Товар сделки обновлен") + + async def delete(self, deal_id: int, product_id: int) -> DeleteDealProductResponse: + entity = await self.repository.get_by_id(deal_id, product_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.delete(entity) + return DeleteDealProductResponse(message="Товар удален из сделки") + + async def add_services_kit( + self, request: DealProductAddKitRequest + ) -> DealProductAddKitResponse: + services_kit_repo = ServicesKitRepository(self.repository.session) + services_kit = await services_kit_repo.get_by_id(request.kit_id) + if not services_kit: + raise HTTPException(status_code=404, detail="Набор услуг не найден") + + deal_product = await self.repository.get_by_id( + request.deal_id, request.product_id + ) + if not deal_product: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + product_service_repo = ProductServiceRepository(self.repository.session) + await product_service_repo.delete_product_services( + request.deal_id, [request.product_id] + ) + + await self.repository.add_services_kit(deal_product, services_kit) + + return DealProductAddKitResponse(message="Комплект добавлен в товар") diff --git a/modules/fulfillment_base/services/deal_service.py b/modules/fulfillment_base/services/deal_service.py new file mode 100644 index 0000000..b53a28e --- /dev/null +++ b/modules/fulfillment_base/services/deal_service.py @@ -0,0 +1,45 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from modules.fulfillment_base.models import DealService +from modules.fulfillment_base.repositories import DealServiceRepository +from modules.fulfillment_base.schemas.deal_service import * +from services.mixins import ServiceGetAllMixin + + +class DealServiceService(ServiceGetAllMixin[DealService, DealServiceSchema]): + schema_class = DealServiceSchema + entity_not_found_msg = "Связь услуги со сделкой не найдена" + + def __init__(self, session: AsyncSession): + self.repository = DealServiceRepository(session) + + async def create( + self, request: CreateDealServiceRequest + ) -> CreateDealServiceResponse: + await self.repository.create(request.entity) + deal_service = await self.repository.get_by_id( + request.entity.deal_id, request.entity.service_id + ) + return CreateDealServiceResponse( + entity=DealServiceSchema.model_validate(deal_service), + message="Услуга добавлена в сделку", + ) + + async def update( + self, deal_id: int, service_id: int, data: UpdateDealServiceRequest + ) -> UpdateDealServiceResponse: + entity = await self.repository.get_by_id(deal_id, service_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.update(entity, data.entity) + return UpdateDealServiceResponse(message="Услуга сделки обновлена") + + async def delete(self, deal_id: int, service_id: int) -> DeleteDealServiceResponse: + entity = await self.repository.get_by_id(deal_id, service_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.delete(entity) + return DeleteDealServiceResponse(message="Услуга удалена из сделки") diff --git a/modules/fulfillment_base/services/product.py b/modules/fulfillment_base/services/product.py new file mode 100644 index 0000000..5944691 --- /dev/null +++ b/modules/fulfillment_base/services/product.py @@ -0,0 +1,27 @@ +from modules.fulfillment_base.models import Product +from modules.fulfillment_base.repositories import ProductRepository +from modules.fulfillment_base.schemas.product import ( + CreateProductRequest, + ProductSchema, + UpdateProductRequest, +) +from services.mixins import * + + +class ProductService( + ServiceGetAllMixin[Product, ProductSchema], + ServiceCreateMixin[Product, CreateProductRequest, ProductSchema], + ServiceUpdateMixin[Product, UpdateProductRequest], + ServiceDeleteMixin[Product], +): + schema_class = ProductSchema + entity_not_found_msg = "Товар не найден" + entity_deleted_msg = "Товар успешно удален" + entity_updated_msg = "Товар успешно обновлен" + entity_created_msg = "Товар успешно создан" + + def __init__(self, session: AsyncSession): + self.repository = ProductRepository(session) + + async def is_soft_delete(self, product: ProductSchema) -> bool: + return True diff --git a/modules/fulfillment_base/services/product_service.py b/modules/fulfillment_base/services/product_service.py new file mode 100644 index 0000000..c7572c5 --- /dev/null +++ b/modules/fulfillment_base/services/product_service.py @@ -0,0 +1,66 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from modules.fulfillment_base.models import DealProductService +from modules.fulfillment_base.repositories import ProductServiceRepository +from modules.fulfillment_base.schemas.product_service import * + + +class ProductServiceService: + schema_class = ProductServiceSchema + entity_not_found_msg = "Связь услуги с товаром не найдена" + + def __init__(self, session: AsyncSession): + self.repository = ProductServiceRepository(session) + + async def create( + self, request: CreateProductServiceRequest + ) -> CreateProductServiceResponse: + await self.repository.create(request.entity) + deal_product = await self.repository.get_by_id( + request.entity.deal_id, + request.entity.product_id, + request.entity.service_id, + ) + return CreateProductServiceResponse( + entity=ProductServiceSchema.model_validate(deal_product), + message="Услуга добавлена к товару", + ) + + async def update( + self, + deal_id: int, + product_id: int, + service_id: int, + data: UpdateProductServiceRequest, + ) -> UpdateProductServiceResponse: + entity = await self.repository.get_by_id(deal_id, product_id, service_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.update(entity, data.entity) + return UpdateProductServiceResponse(message="Услуга обновлена") + + async def delete( + self, deal_id: int, product_id: int, service_id: int + ) -> DeleteProductServiceResponse: + entity = await self.repository.get_by_id(deal_id, product_id, service_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + await self.repository.delete(entity) + return DeleteProductServiceResponse(message="Товар удален из сделки") + + async def duplicate_product_services( + self, request: ProductServicesDuplicateRequest + ) -> ProductServicesDuplicateResponse: + services_to_copy: list[ + DealProductService + ] = await self.repository.get_product_services( + request.deal_id, request.source_deal_product_id + ) + + await self.repository.duplicate_services( + request.deal_id, request.target_deal_product_ids, services_to_copy + ) + return ProductServicesDuplicateResponse(message="Услуги продублированы") diff --git a/modules/fulfillment_base/services/service.py b/modules/fulfillment_base/services/service.py new file mode 100644 index 0000000..e988a83 --- /dev/null +++ b/modules/fulfillment_base/services/service.py @@ -0,0 +1,27 @@ +from modules.fulfillment_base.models import Service +from modules.fulfillment_base.repositories import ServiceRepository +from modules.fulfillment_base.schemas.service import ( + ServiceSchema, + CreateServiceRequest, + UpdateServiceRequest, +) +from services.mixins import * + + +class ServiceModelService( + ServiceGetAllMixin[Service, ServiceSchema], + ServiceCreateMixin[Service, CreateServiceRequest, ServiceSchema], + ServiceUpdateMixin[Service, UpdateServiceRequest], + ServiceDeleteMixin[Service], +): + schema_class = ServiceSchema + entity_not_found_msg = "Услуга не найдена" + entity_deleted_msg = "Услуга успешно удалена" + entity_updated_msg = "Услуга успешно обновлена" + entity_created_msg = "Услуга успешно создана" + + def __init__(self, session: AsyncSession): + self.repository = ServiceRepository(session) + + async def is_soft_delete(self, service: ServiceSchema) -> bool: + return True diff --git a/modules/fulfillment_base/services/services_kit.py b/modules/fulfillment_base/services/services_kit.py new file mode 100644 index 0000000..4b4f7c2 --- /dev/null +++ b/modules/fulfillment_base/services/services_kit.py @@ -0,0 +1,24 @@ +from modules.fulfillment_base.models.service import ServicesKit +from modules.fulfillment_base.repositories import ServicesKitRepository +from modules.fulfillment_base.schemas.services_kit import ( + ServicesKitSchema, + CreateServicesKitRequest, + UpdateServicesKitRequest, +) +from services.mixins import * + + +class ServicesKitService( + ServiceCrudMixin[ServicesKit, ServicesKitSchema, CreateServicesKitRequest, UpdateServicesKitRequest] +): + schema_class = ServicesKitSchema + entity_not_found_msg = "Набор услуг не найден" + entity_deleted_msg = "Набор услуг успешно удален" + entity_updated_msg = "Набор услуг успешно обновлен" + entity_created_msg = "Набор услуг успешно создан" + + def __init__(self, session: AsyncSession): + self.repository = ServicesKitRepository(session) + + async def is_soft_delete(self, service: ServicesKitSchema) -> bool: + return False diff --git a/repositories/__init__.py b/repositories/__init__.py index 1911db0..148fd12 100644 --- a/repositories/__init__.py +++ b/repositories/__init__.py @@ -2,3 +2,4 @@ from .board import BoardRepository as BoardRepository from .deal import DealRepository as DealRepository from .project import ProjectRepository as ProjectRepository from .status import StatusRepository as StatusRepository +from .built_in_module import BuiltInModuleRepository as BuiltInModuleRepository diff --git a/repositories/base.py b/repositories/base.py index 8a3bf6e..2c3a7c2 100644 --- a/repositories/base.py +++ b/repositories/base.py @@ -3,6 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession class BaseRepository: + session: AsyncSession + def __init__(self, session: AsyncSession): self.session = session diff --git a/repositories/built_in_module.py b/repositories/built_in_module.py new file mode 100644 index 0000000..ebae99c --- /dev/null +++ b/repositories/built_in_module.py @@ -0,0 +1,18 @@ +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() diff --git a/repositories/mixins.py b/repositories/mixins.py index 35bc1ad..a42bcb8 100644 --- a/repositories/mixins.py +++ b/repositories/mixins.py @@ -65,7 +65,7 @@ class RepUpdateMixin(Generic[EntityType, UpdateSchemaType], RepBaseMixin[EntityT return model async def update(self, entity: EntityType, data: UpdateSchemaType) -> EntityType: - pass + return await self._apply_update_data_to_model(entity, data, True) class RepGetByIdMixin(Generic[EntityType], RepBaseMixin[EntityType]): diff --git a/repositories/project.py b/repositories/project.py index c785cb8..87613ad 100644 --- a/repositories/project.py +++ b/repositories/project.py @@ -1,6 +1,7 @@ from sqlalchemy.orm import selectinload from models.project import Project +from repositories.built_in_module import BuiltInModuleRepository from repositories.mixins import * from schemas.project import CreateProjectSchema, UpdateProjectSchema @@ -17,4 +18,10 @@ class ProjectRepository( return stmt.options(selectinload(Project.boards)) async def update(self, project: Project, data: UpdateProjectSchema) -> Project: + if data.built_in_modules is not None: + built_in_modules = data.built_in_modules + data.built_in_modules = await BuiltInModuleRepository(self.session).get_by_ids( + [module.id for module in built_in_modules] + ) + return await self._apply_update_data_to_model(project, data, True) diff --git a/routers/board.py b/routers/board.py index e237b87..9b0975a 100644 --- a/routers/board.py +++ b/routers/board.py @@ -4,12 +4,10 @@ from backend.dependecies import SessionDependency from schemas.board import * from services import BoardService -board_router = APIRouter( - tags=["board"], -) +router = APIRouter(tags=["board"]) -@board_router.get( +@router.get( "/{projectId}", response_model=GetBoardsResponse, operation_id="get_boards", @@ -21,7 +19,7 @@ async def get_boards( return await BoardService(session).get_all(project_id) -@board_router.post( +@router.post( "/", response_model=CreateBoardResponse, operation_id="create_board", @@ -33,7 +31,7 @@ async def create_board( return await BoardService(session).create(request) -@board_router.patch( +@router.patch( "/{pk}", response_model=UpdateBoardResponse, operation_id="update_board", @@ -46,7 +44,7 @@ async def update_board( return await BoardService(session).update(pk, request) -@board_router.delete( +@router.delete( "/{pk}", response_model=DeleteBoardResponse, operation_id="delete_board", diff --git a/routers/deal.py b/routers/deal.py index cb2ddad..cc82073 100644 --- a/routers/deal.py +++ b/routers/deal.py @@ -8,12 +8,10 @@ from backend.dependecies import ( from schemas.deal import * from services import DealService -deal_router = APIRouter( - tags=["deal"], -) +router = APIRouter(tags=["deal"]) -@deal_router.get( +@router.get( "/", response_model=GetDealsResponse, operation_id="get_deals", @@ -39,7 +37,7 @@ async def get_deals( ) -@deal_router.post( +@router.post( "/", response_model=CreateDealResponse, operation_id="create_deal", @@ -51,7 +49,7 @@ async def create_deal( return await DealService(session).create(request) -@deal_router.patch( +@router.patch( "/{pk}", response_model=UpdateDealResponse, operation_id="update_deal", @@ -64,7 +62,7 @@ async def update_deal( return await DealService(session).update(pk, request) -@deal_router.delete( +@router.delete( "/{pk}", response_model=DeleteDealResponse, operation_id="delete_deal", diff --git a/routers/module.py b/routers/module.py new file mode 100644 index 0000000..61004ca --- /dev/null +++ b/routers/module.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from backend.dependecies import SessionDependency +from schemas.module import GetAllBuiltInModulesResponse +from services.built_in_module import BuiltInModuleService + +router = APIRouter(tags=["modules"]) + + +@router.get( + "/built-in/", + response_model=GetAllBuiltInModulesResponse, + operation_id="get_built_in_modules", +) +async def get_built_in_modules( + session: SessionDependency, +): + return await BuiltInModuleService(session).get_all() diff --git a/routers/project.py b/routers/project.py index 4243b02..ac60999 100644 --- a/routers/project.py +++ b/routers/project.py @@ -4,12 +4,10 @@ from backend.dependecies import SessionDependency from schemas.project import * from services import ProjectService -project_router = APIRouter( - tags=["project"], -) +router = APIRouter(tags=["project"]) -@project_router.get( +@router.get( "/", response_model=GetProjectsResponse, operation_id="get_projects", @@ -20,7 +18,7 @@ async def get_projects( return await ProjectService(session).get_all() -@project_router.post( +@router.post( "/", response_model=CreateProjectResponse, operation_id="create_project", @@ -32,7 +30,7 @@ async def create_project( return await ProjectService(session).create(request) -@project_router.patch( +@router.patch( "/{pk}", response_model=UpdateProjectResponse, operation_id="update_project", @@ -45,7 +43,7 @@ async def update_project( return await ProjectService(session).update(pk, request) -@project_router.delete( +@router.delete( "/{pk}", response_model=DeleteProjectResponse, operation_id="delete_project", diff --git a/routers/status.py b/routers/status.py index f033800..cabd25f 100644 --- a/routers/status.py +++ b/routers/status.py @@ -4,12 +4,10 @@ from backend.dependecies import SessionDependency from schemas.status import * from services import StatusService -status_router = APIRouter( - tags=["status"], -) +router = APIRouter(tags=["status"]) -@status_router.get( +@router.get( "/{boardId}", response_model=GetStatusesResponse, operation_id="get_statuses", @@ -21,7 +19,7 @@ async def get_statuses( return await StatusService(session).get_all(board_id) -@status_router.post( +@router.post( "/", response_model=CreateStatusResponse, operation_id="create_status", @@ -33,7 +31,7 @@ async def create_status( return await StatusService(session).create(request) -@status_router.patch( +@router.patch( "/{pk}", response_model=UpdateStatusResponse, operation_id="update_status", @@ -46,7 +44,7 @@ async def update_status( return await StatusService(session).update(pk, request) -@status_router.delete( +@router.delete( "/{pk}", response_model=DeleteStatusResponse, operation_id="delete_status", diff --git a/schemas/module.py b/schemas/module.py new file mode 100644 index 0000000..69dcc21 --- /dev/null +++ b/schemas/module.py @@ -0,0 +1,23 @@ +from schemas.base import BaseSchema + +# region Entity + + +class BuiltInModuleSchema(BaseSchema): + id: int + key: str + label: str + icon_name: str + description: str + + +# endregion + +# region Response + + +class GetAllBuiltInModulesResponse(BaseSchema): + items: list[BuiltInModuleSchema] + + +# endregion diff --git a/schemas/project.py b/schemas/project.py index caf70b4..6e8a418 100644 --- a/schemas/project.py +++ b/schemas/project.py @@ -1,6 +1,7 @@ from typing import Optional from schemas.base import BaseSchema, BaseResponse +from schemas.module import BuiltInModuleSchema # region Entity @@ -9,6 +10,7 @@ from schemas.base import BaseSchema, BaseResponse class ProjectSchema(BaseSchema): id: int name: str + built_in_modules: list[BuiltInModuleSchema] class CreateProjectSchema(BaseSchema): @@ -17,6 +19,7 @@ class CreateProjectSchema(BaseSchema): class UpdateProjectSchema(BaseSchema): name: Optional[str] = None + built_in_modules: list[BuiltInModuleSchema] = None # endregion diff --git a/services/built_in_module.py b/services/built_in_module.py new file mode 100644 index 0000000..a9616ec --- /dev/null +++ b/services/built_in_module.py @@ -0,0 +1,11 @@ +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) diff --git a/utils/auto_include_routers.py b/utils/auto_include_routers.py new file mode 100644 index 0000000..f31da6c --- /dev/null +++ b/utils/auto_include_routers.py @@ -0,0 +1,51 @@ +import importlib +import pkgutil +from pathlib import Path +from types import ModuleType + +from fastapi import FastAPI, APIRouter + + +def auto_include_routers(app: FastAPI, package: ModuleType, full_path: bool = False): + """ + Automatically discover and include FastAPI routers from a given package. + + :param FastAPI app: FastAPI application. + :param ModuleType package: Imported package. + :param bool full_path: If True, prefix is built from full path. If False, prefix is only the last filename. + """ + + package_path = Path(package.__file__).parent + base_pkg_name = package.__name__.replace("_", "-") + + for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): + module = importlib.import_module(name) + + # Try to get `router` from the module + router = getattr(module, "router", None) + if not isinstance(router, APIRouter): + continue + + # Build API prefix + file_path = Path(module.__file__) + relative_path = file_path.relative_to(package_path) + + parts: list[str] = list(relative_path.parts) + + # Remove "routers" folder(s) from prefix parts + parts = [p for p in parts if p != "routers"] + + # Drop extension from filename + parts[-1] = parts[-1].replace(".py", "") + + if full_path: + # Use full path + prefix_parts = [p.replace("_", "-") for p in parts if p != "__init__"] + prefix_parts.insert(0, base_pkg_name) + else: + # Only use last file name + prefix_parts = [parts[-1].replace("_", "-")] + + prefix = "/" + "/".join(prefix_parts) + + app.include_router(router, prefix=prefix) diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..f24fd1d --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,2 @@ +class ObjectNotFoundException(Exception): + pass