feat: images uploader, endpoint for product images uploading

This commit is contained in:
2025-10-20 16:09:29 +04:00
parent 34ac2a0a69
commit 90c0bae8f1
11 changed files with 171 additions and 9 deletions

View File

@ -5,3 +5,9 @@ PG_DATABASE=
PG_HOST=
SECRET_KEY=
S3_URL=
S3_ACCESS_KEY=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_BUCKET=

View File

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

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

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

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

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,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))

View File

@ -25,6 +25,7 @@ dependencies = [
"pathlib>=1.0.1",
"starlette>=0.47.2",
"lexorank>=1.0.1",
"python-multipart>=0.0.20",
]
[dependency-groups]

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(
"/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,

2
uv.lock generated
View File

@ -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" },