feat: images uploader, endpoint for product images uploading
This commit is contained in:
@ -5,3 +5,9 @@ PG_DATABASE=
|
|||||||
PG_HOST=
|
PG_HOST=
|
||||||
|
|
||||||
SECRET_KEY=
|
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")
|
PG_HOST = os.environ.get("PG_HOST")
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
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 sqlalchemy.orm import selectinload, joinedload
|
||||||
|
|
||||||
from modules.fulfillment_base.models import Product, ProductBarcode, BarcodeTemplate
|
from modules.fulfillment_base.models import Product, ProductBarcode, BarcodeTemplate
|
||||||
|
from modules.fulfillment_base.models.product import ProductImage
|
||||||
from modules.fulfillment_base.schemas.product import (
|
from modules.fulfillment_base.schemas.product import (
|
||||||
CreateProductSchema,
|
CreateProductSchema,
|
||||||
UpdateProductSchema,
|
UpdateProductSchema,
|
||||||
@ -16,6 +17,7 @@ class ProductRepository(
|
|||||||
RepUpdateMixin[Product, UpdateProductSchema],
|
RepUpdateMixin[Product, UpdateProductSchema],
|
||||||
RepGetByIdMixin[Product],
|
RepGetByIdMixin[Product],
|
||||||
):
|
):
|
||||||
|
session: AsyncSession
|
||||||
entity_class = Product
|
entity_class = Product
|
||||||
entity_not_found_msg = "Товар не найден"
|
entity_not_found_msg = "Товар не найден"
|
||||||
|
|
||||||
@ -95,3 +97,20 @@ class ProductRepository(
|
|||||||
await self._update_barcodes(product, data.barcodes)
|
await self._update_barcodes(product, data.barcodes)
|
||||||
del data.barcodes
|
del data.barcodes
|
||||||
return await self._apply_update_data_to_model(product, data, True)
|
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 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.models import ProductBarcode
|
||||||
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema
|
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema
|
||||||
@ -28,6 +28,17 @@ class CreateProductSchema(BaseSchema):
|
|||||||
size: Optional[str]
|
size: Optional[str]
|
||||||
additional_info: Optional[str]
|
additional_info: Optional[str]
|
||||||
barcodes: list[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):
|
class ProductSchema(CreateProductSchema):
|
||||||
@ -93,6 +104,10 @@ class UpdateProductResponse(BaseResponse):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUploadImageResponse(BaseResponse):
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeleteProductResponse(BaseResponse):
|
class DeleteProductResponse(BaseResponse):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from external.s3_uploader import S3Uploader
|
||||||
from modules.fulfillment_base.models import Product
|
from modules.fulfillment_base.models import Product
|
||||||
from modules.fulfillment_base.repositories import ProductRepository
|
from modules.fulfillment_base.repositories import ProductRepository
|
||||||
from modules.fulfillment_base.schemas.product import (
|
from modules.fulfillment_base.schemas.product import *
|
||||||
CreateProductRequest,
|
|
||||||
ProductSchema,
|
|
||||||
UpdateProductRequest, GetProductsResponse,
|
|
||||||
)
|
|
||||||
from schemas.base import PaginationSchema, PaginationInfoSchema
|
from schemas.base import PaginationSchema, PaginationInfoSchema
|
||||||
from services.mixins import *
|
from services.mixins import *
|
||||||
|
|
||||||
@ -46,5 +45,22 @@ class ProductService(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def is_soft_delete(self, product: ProductSchema) -> bool:
|
async def upload_image(
|
||||||
return True
|
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",
|
"pathlib>=1.0.1",
|
||||||
"starlette>=0.47.2",
|
"starlette>=0.47.2",
|
||||||
"lexorank>=1.0.1",
|
"lexorank>=1.0.1",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[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 backend.dependecies import SessionDependency, PaginationDependency
|
||||||
from modules.fulfillment_base.schemas.product import *
|
from modules.fulfillment_base.schemas.product import *
|
||||||
@ -46,6 +48,19 @@ async def update_product(
|
|||||||
return await ProductService(session).update(pk, request)
|
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(
|
@router.delete(
|
||||||
"/{pk}",
|
"/{pk}",
|
||||||
response_model=DeleteProductResponse,
|
response_model=DeleteProductResponse,
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@ -369,6 +369,7 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pymupdf" },
|
{ name = "pymupdf" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "redis", extra = ["hiredis"] },
|
{ name = "redis", extra = ["hiredis"] },
|
||||||
{ name = "reportlab" },
|
{ name = "reportlab" },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
@ -399,6 +400,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic", specifier = ">=2.11.7" },
|
{ name = "pydantic", specifier = ">=2.11.7" },
|
||||||
{ name = "pymupdf", specifier = ">=1.26.5" },
|
{ name = "pymupdf", specifier = ">=1.26.5" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
{ name = "redis", extras = ["hiredis"], specifier = ">=6.2.0" },
|
{ name = "redis", extras = ["hiredis"], specifier = ">=6.2.0" },
|
||||||
{ name = "reportlab", specifier = ">=4.4.4" },
|
{ name = "reportlab", specifier = ">=4.4.4" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.41" },
|
||||||
|
|||||||
Reference in New Issue
Block a user