feat: images uploader, endpoint for product images uploading
This commit is contained in:
@ -5,3 +5,9 @@ PG_DATABASE=
|
||||
PG_HOST=
|
||||
|
||||
SECRET_KEY=
|
||||
|
||||
S3_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=
|
||||
S3_BUCKET=
|
||||
|
||||
@ -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
0
external/__init__.py
vendored
Normal file
1
external/s3_uploader/__init__.py
vendored
Normal file
1
external/s3_uploader/__init__.py
vendored
Normal file
@ -0,0 +1 @@
|
||||
from .uploader import S3Uploader as S3Uploader
|
||||
81
external/s3_uploader/uploader.py
vendored
Normal file
81
external/s3_uploader/uploader.py
vendored
Normal 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}")
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -25,6 +25,7 @@ dependencies = [
|
||||
"pathlib>=1.0.1",
|
||||
"starlette>=0.47.2",
|
||||
"lexorank>=1.0.1",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@ -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
2
uv.lock
generated
@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user