From 90c0bae8f1310e19189218f1323cd11a42cfb505 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Mon, 20 Oct 2025 16:09:29 +0400 Subject: [PATCH] feat: images uploader, endpoint for product images uploading --- .env.example | 6 ++ backend/config.py | 6 ++ external/__init__.py | 0 external/s3_uploader/__init__.py | 1 + external/s3_uploader/uploader.py | 81 +++++++++++++++++++ .../fulfillment_base/repositories/product.py | 19 +++++ modules/fulfillment_base/schemas/product.py | 17 +++- modules/fulfillment_base/services/product.py | 30 +++++-- pyproject.toml | 1 + .../v1/modules/fulfillment_base/product.py | 17 +++- uv.lock | 2 + 11 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 external/__init__.py create mode 100644 external/s3_uploader/__init__.py create mode 100644 external/s3_uploader/uploader.py diff --git a/.env.example b/.env.example index c6b65e9..ec6f96e 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ PG_DATABASE= PG_HOST= SECRET_KEY= + +S3_URL= +S3_ACCESS_KEY= +S3_SECRET_ACCESS_KEY= +S3_REGION= +S3_BUCKET= diff --git a/backend/config.py b/backend/config.py index 157c645..c462db4 100644 --- a/backend/config.py +++ b/backend/config.py @@ -12,3 +12,9 @@ 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") diff --git a/external/__init__.py b/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/external/s3_uploader/__init__.py b/external/s3_uploader/__init__.py new file mode 100644 index 0000000..1a5ea57 --- /dev/null +++ b/external/s3_uploader/__init__.py @@ -0,0 +1 @@ +from .uploader import S3Uploader as S3Uploader diff --git a/external/s3_uploader/uploader.py b/external/s3_uploader/uploader.py new file mode 100644 index 0000000..13825cc --- /dev/null +++ b/external/s3_uploader/uploader.py @@ -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}") diff --git a/modules/fulfillment_base/repositories/product.py b/modules/fulfillment_base/repositories/product.py index cf88162..cd0ecce 100644 --- a/modules/fulfillment_base/repositories/product.py +++ b/modules/fulfillment_base/repositories/product.py @@ -2,6 +2,7 @@ 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.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,20 @@ 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 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 diff --git a/modules/fulfillment_base/schemas/product.py b/modules/fulfillment_base/schemas/product.py index 18f12da..4b589bd 100644 --- a/modules/fulfillment_base/schemas/product.py +++ b/modules/fulfillment_base/schemas/product.py @@ -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 @@ -28,6 +28,17 @@ class CreateProductSchema(BaseSchema): size: Optional[str] additional_info: Optional[str] barcodes: list[str] + image_url: str | None = None + images: list[ProductImageSchema] | None = [] + + @model_validator(mode="after") + def images_list_to_image_url(cls, values): + images = values.images + if not images: + return values + latest_image = images[-1] + values.image_url = latest_image.image_url + return values class ProductSchema(CreateProductSchema): @@ -93,6 +104,10 @@ class UpdateProductResponse(BaseResponse): pass +class ProductUploadImageResponse(BaseResponse): + image_url: Optional[str] = None + + class DeleteProductResponse(BaseResponse): pass diff --git a/modules/fulfillment_base/services/product.py b/modules/fulfillment_base/services/product.py index ac30b98..c329873 100644 --- a/modules/fulfillment_base/services/product.py +++ b/modules/fulfillment_base/services/product.py @@ -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,22 @@ 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: + try: + product: Product = await self.repository.get_by_id(product_id) + s3_uploader = S3Uploader() + + 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( + ok=True, message="Изображение успешно загружено", image_url=image_url + ) + except Exception as e: + return ProductUploadImageResponse(ok=False, message=str(e)) diff --git a/pyproject.toml b/pyproject.toml index 4759848..1856301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pathlib>=1.0.1", "starlette>=0.47.2", "lexorank>=1.0.1", + "python-multipart>=0.0.20", ] [dependency-groups] diff --git a/routers/crm/v1/modules/fulfillment_base/product.py b/routers/crm/v1/modules/fulfillment_base/product.py index b60ea89..9a6721d 100644 --- a/routers/crm/v1/modules/fulfillment_base/product.py +++ b/routers/crm/v1/modules/fulfillment_base/product.py @@ -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( + "/images/upload/{productId}", + response_model=ProductUploadImageResponse, + operation_id="upload_product_image", +) +async def upload_product_image( + session: SessionDependency, + upload_file: Annotated[UploadFile, File()], + product_id: int = Path(alias="productId"), +): + return await ProductService(session).upload_image(product_id, upload_file) + + @router.delete( "/{pk}", response_model=DeleteProductResponse, diff --git a/uv.lock b/uv.lock index 32fb64f..9e1edf8 100644 --- a/uv.lock +++ b/uv.lock @@ -369,6 +369,7 @@ dependencies = [ { name = "pydantic" }, { name = "pymupdf" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "redis", extra = ["hiredis"] }, { name = "reportlab" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -399,6 +400,7 @@ requires-dist = [ { 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" },