Switch media uploads to presigned MinIO, matching the rest of Winsoo
The prior commit wired MinIO as Django's default storage backend but kept plain ImageField multipart uploads through the Django backend and public-bucket direct URLs — not how campaign/advertising/promotions do it. Rework to match: image fields (ProductCategory.icon, Product.image, StoreCategory.icon, Store.logo, Store.cover_image) now store an opaque MinIO object_key (CharField) instead of a Django-managed file. - utils/clients/minio_client.py — raw Minio SDK client, same shape as the other services. - apps/core/views/media.py — POST /api/media/presign/ mints a 30-minute presigned PUT URL + object_key; the client uploads bytes directly to MinIO, then submits the object_key on the owning resource. - apps/core/media.py — presigned_media_url() helper; every serializer with an image field now also exposes a `<field>_url` computed from a fresh 1-hour presigned GET, rather than trusting a stored/direct URL. - FRONTEND_GUIDE.md updated to document the new upload flow and field shapes (object_key vs `_url`), replacing the stale "no upload endpoint yet" note. Verified against a live local MinIO container: presign → direct PUT upload → object_key stored on the model → serializer mints a working presigned GET URL → URL fetches the uploaded bytes. Also exercised the actual DRF view (POST /api/media/presign/) end-to-end, including the 401 on an unauthenticated request.
This commit is contained in:
parent
c4f15d9c80
commit
85598d347a
14 changed files with 210 additions and 19 deletions
|
|
@ -97,7 +97,7 @@ Default page size is 50 if `limit` is omitted.
|
||||||
- Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`.
|
- Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`.
|
||||||
- All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string.
|
- All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string.
|
||||||
- All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...).
|
- All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...).
|
||||||
- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set.
|
- Image fields (`image`, `logo`, `cover_image`, `icon`) hold an opaque MinIO **object key** (or `null`), not a URL — read the sibling `<field>_url` (e.g. `logo_url`) for a fetchable, presigned, time-limited URL. See §4.7.
|
||||||
|
|
||||||
### 4.6 Enums
|
### 4.6 Enums
|
||||||
|
|
||||||
|
|
@ -112,6 +112,16 @@ Default page size is 50 if `limit` is omitted.
|
||||||
| `OrderGroup.payment_status` | `pending`, `paid`, `failed` | در انتظار پرداخت, پرداخت شده, ناموفق |
|
| `OrderGroup.payment_status` | `pending`, `paid`, `failed` | در انتظار پرداخت, پرداخت شده, ناموفق |
|
||||||
| `Order.status` | `placed`, `preparing`, `ready_to_ship`, `handed_to_courier`, `delivered`, `cancelled` | سفارش ثبت شد → در حال آمادهسازی → آماده ارسال → تحویل سفیر شد → تحویل داده شد (+ لغو شده) |
|
| `Order.status` | `placed`, `preparing`, `ready_to_ship`, `handed_to_courier`, `delivered`, `cancelled` | سفارش ثبت شد → در حال آمادهسازی → آماده ارسال → تحویل سفیر شد → تحویل داده شد (+ لغو شده) |
|
||||||
| `SellerWithdrawalRequest.status` | `pending`, `processing`, `paid`, `rejected` | در انتظار بررسی, در حال پردازش, واریز شده, رد شده |
|
| `SellerWithdrawalRequest.status` | `pending`, `processing`, `paid`, `rejected` | در انتظار بررسی, در حال پردازش, واریز شده, رد شده |
|
||||||
|
|
||||||
|
### 4.7 Image uploads (presigned MinIO)
|
||||||
|
|
||||||
|
Every image field (`ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image`) is a **MinIO object key**, not a Django-served file. Uploading is a two-step flow — the backend never receives the file bytes:
|
||||||
|
|
||||||
|
1. `POST /api/media/presign/` (auth required) with `{"filename": "photo.jpg"}` → `201 {"upload_url": "...", "object_key": "uploads/<uuid>.jpg"}`. `upload_url` is a presigned MinIO `PUT` URL valid for 30 minutes.
|
||||||
|
2. Upload the raw file bytes directly to `upload_url` (a plain `PUT`, no auth header, no JSON body — just the file as the request body).
|
||||||
|
3. Send the `object_key` from step 1 as the field's value in the resource's create/update body (e.g. `{"logo": "uploads/<uuid>.jpg", ...}` on `PATCH /api/v1/seller/store/`).
|
||||||
|
|
||||||
|
On read, every serializer that has an image field also returns a `<field>_url` sibling (`icon_url`, `image_url`, `logo_url`, `cover_image_url`) — a freshly presigned `GET` URL valid for 1 hour. Always re-fetch the resource (or re-request) rather than caching these URLs past an hour; they expire.
|
||||||
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
|
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
|
||||||
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
|
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
|
||||||
|
|
||||||
|
|
@ -155,7 +165,7 @@ Response mirrors this but with `city`/`neighborhood` as nested objects (not `*_u
|
||||||
| GET | `/api/v1/stores/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. |
|
| GET | `/api/v1/stores/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. |
|
||||||
| GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). |
|
| GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). |
|
||||||
|
|
||||||
Store list item shape: `uuid, name, category{uuid,name,icon,order}, logo, cover_image, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`.
|
Store list item shape: `uuid, name, category{uuid,name,icon,icon_url,order}, logo, logo_url, cover_image, cover_image_url, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. See §4.7 for `icon`/`logo`/`cover_image` vs. their `_url` siblings.
|
||||||
|
|
||||||
### Catalog (`Catalog` tag)
|
### Catalog (`Catalog` tag)
|
||||||
|
|
||||||
|
|
@ -363,7 +373,7 @@ Store create/update body:
|
||||||
"is_open": true
|
"is_open": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). `logo`/`cover_image` aren't settable through this JSON body — that needs a multipart upload endpoint, which doesn't exist yet; flag this to backend if the store-branding screen needs it.
|
`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). To set `logo`/`cover_image`, run the presign flow (§4.7) first and include the resulting `object_key`s in this JSON body — e.g. add `"logo": "uploads/<uuid>.png"`.
|
||||||
|
|
||||||
### Seller · Products
|
### Seller · Products
|
||||||
|
|
||||||
|
|
@ -374,7 +384,7 @@ Store create/update body:
|
||||||
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
|
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
|
||||||
| PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. |
|
| PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. |
|
||||||
|
|
||||||
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. Response adds `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
|
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. `image` is a MinIO object key from the presign flow (§4.7), not a file upload. Response adds `image_url` (presigned, §4.7), `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
|
||||||
|
|
||||||
### Seller · Orders
|
### Seller · Orders
|
||||||
|
|
||||||
|
|
@ -432,12 +442,11 @@ Response:
|
||||||
## 8. Local development / testing
|
## 8. Local development / testing
|
||||||
|
|
||||||
- The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures.
|
- The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures.
|
||||||
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
|
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Media, Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
|
||||||
- `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check.
|
- `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check.
|
||||||
|
|
||||||
## 9. Known gaps to flag back to backend if you hit them
|
## 9. Known gaps to flag back to backend if you hit them
|
||||||
|
|
||||||
- No multipart/image-upload endpoint yet for store logo/cover or product images (JSON `PATCH` bodies can't set binary fields).
|
|
||||||
- No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin.
|
- No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin.
|
||||||
- No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1).
|
- No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1).
|
||||||
- The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check.
|
- The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-08-18 07:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('catalog', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='image',
|
||||||
|
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='productcategory',
|
||||||
|
name='icon',
|
||||||
|
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -9,7 +9,7 @@ class ProductCategory(BaseModel):
|
||||||
parent = models.ForeignKey(
|
parent = models.ForeignKey(
|
||||||
'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True,
|
'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True,
|
||||||
)
|
)
|
||||||
icon = models.ImageField(upload_to='product_categories/', null=True, blank=True)
|
icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||||
order = models.PositiveSmallIntegerField(default=0)
|
order = models.PositiveSmallIntegerField(default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -31,7 +31,7 @@ class Product(BaseModel):
|
||||||
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
|
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
image = models.ImageField(upload_to='products/', null=True, blank=True)
|
image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||||
|
|
||||||
price = models.PositiveBigIntegerField()
|
price = models.PositiveBigIntegerField()
|
||||||
unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE)
|
unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,36 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.core.media import presigned_media_url
|
||||||
from apps.stores.serializers import StoreListSerializer
|
from apps.stores.serializers import StoreListSerializer
|
||||||
|
|
||||||
from .models import Product, ProductCategory
|
from .models import Product, ProductCategory
|
||||||
|
|
||||||
|
|
||||||
class ProductCategorySerializer(serializers.ModelSerializer):
|
class ProductCategorySerializer(serializers.ModelSerializer):
|
||||||
|
icon_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProductCategory
|
model = ProductCategory
|
||||||
fields = ('uuid', 'name', 'parent', 'icon', 'order')
|
fields = ('uuid', 'name', 'parent', 'icon', 'icon_url', 'order')
|
||||||
|
|
||||||
|
def get_icon_url(self, obj):
|
||||||
|
return presigned_media_url(obj.icon)
|
||||||
|
|
||||||
|
|
||||||
class ProductListSerializer(serializers.ModelSerializer):
|
class ProductListSerializer(serializers.ModelSerializer):
|
||||||
is_out_of_stock = serializers.BooleanField(read_only=True)
|
is_out_of_stock = serializers.BooleanField(read_only=True)
|
||||||
|
image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = (
|
fields = (
|
||||||
'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value',
|
'uuid', 'name', 'image', 'image_url', 'price', 'unit_type', 'unit_value',
|
||||||
'is_active', 'is_out_of_stock',
|
'is_active', 'is_out_of_stock',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_image_url(self, obj):
|
||||||
|
return presigned_media_url(obj.image)
|
||||||
|
|
||||||
|
|
||||||
class ProductDetailSerializer(ProductListSerializer):
|
class ProductDetailSerializer(ProductListSerializer):
|
||||||
category = ProductCategorySerializer(read_only=True)
|
category = ProductCategorySerializer(read_only=True)
|
||||||
|
|
@ -39,16 +49,20 @@ class SellerProductSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
is_out_of_stock = serializers.BooleanField(read_only=True)
|
is_out_of_stock = serializers.BooleanField(read_only=True)
|
||||||
is_low_stock = serializers.BooleanField(read_only=True)
|
is_low_stock = serializers.BooleanField(read_only=True)
|
||||||
|
image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = (
|
fields = (
|
||||||
'uuid', 'name', 'description', 'image', 'category', 'category_uuid',
|
'uuid', 'name', 'description', 'image', 'image_url', 'category', 'category_uuid',
|
||||||
'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold',
|
'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold',
|
||||||
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
|
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
|
||||||
)
|
)
|
||||||
read_only_fields = ('sold_count',)
|
read_only_fields = ('sold_count',)
|
||||||
|
|
||||||
|
def get_image_url(self, obj):
|
||||||
|
return presigned_media_url(obj.image)
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['store'] = self.context['request'].user.store
|
validated_data['store'] = self.context['request'].user.store
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
|
||||||
21
apps/core/media.py
Normal file
21
apps/core/media.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from utils.clients.minio_client import minio_client
|
||||||
|
|
||||||
|
logger = logging.getLogger('winofy.media')
|
||||||
|
|
||||||
|
|
||||||
|
def presigned_media_url(object_key, expires=timedelta(hours=1)):
|
||||||
|
"""Mints a fresh presigned GET URL for a stored MinIO object_key, or None if unset/unreachable."""
|
||||||
|
if not object_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return minio_client.presigned_get_object(
|
||||||
|
settings.MINIO_MEDIA_FILES_BUCKET, object_key, expires=expires,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Failed to presign media url for object_key=%s', object_key)
|
||||||
|
return None
|
||||||
10
apps/core/serializers.py
Normal file
10
apps/core/serializers.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPresignInputSerializer(serializers.Serializer):
|
||||||
|
filename = serializers.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPresignOutputSerializer(serializers.Serializer):
|
||||||
|
upload_url = serializers.URLField()
|
||||||
|
object_key = serializers.CharField()
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import HealthCheckView
|
from .views import HealthCheckView, MediaPresignView
|
||||||
|
|
||||||
app_name = "core"
|
app_name = "core"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("health/", HealthCheckView.as_view(), name="health"),
|
path("health/", HealthCheckView.as_view(), name="health"),
|
||||||
|
path("media/presign/", MediaPresignView.as_view(), name="media-presign"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
from .health import HealthCheckView
|
from .health import HealthCheckView
|
||||||
|
from .media import MediaPresignView
|
||||||
|
|
||||||
__all__ = ["HealthCheckView"]
|
__all__ = ["HealthCheckView", "MediaPresignView"]
|
||||||
|
|
|
||||||
51
apps/core/views/media.py
Normal file
51
apps/core/views/media.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.core.serializers import MediaPresignInputSerializer, MediaPresignOutputSerializer
|
||||||
|
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||||
|
from utils.clients.minio_client import minio_client
|
||||||
|
from utils.exceptions import ServiceUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPresignView(APIView):
|
||||||
|
"""Presigned MinIO upload URL for product/store images (icon, logo, cover, ...).
|
||||||
|
|
||||||
|
The client uploads the file bytes directly to MinIO with the returned
|
||||||
|
upload_url, then submits the returned object_key as the field value when
|
||||||
|
creating/updating the owning resource (e.g. Store.logo, Product.image).
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema_tags = ['Media']
|
||||||
|
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||||
|
required_alternate_scopes = {
|
||||||
|
"POST": [[]],
|
||||||
|
}
|
||||||
|
|
||||||
|
@extend_schema(request=MediaPresignInputSerializer, responses={201: MediaPresignOutputSerializer})
|
||||||
|
def post(self, request):
|
||||||
|
serializer = MediaPresignInputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
filename = serializer.validated_data['filename']
|
||||||
|
extension = filename.rsplit('.', 1)[-1] if '.' in filename else 'bin'
|
||||||
|
object_key = f'uploads/{uuid.uuid4()}.{extension}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload_url = minio_client.presigned_put_object(
|
||||||
|
settings.MINIO_MEDIA_FILES_BUCKET,
|
||||||
|
object_key,
|
||||||
|
expires=timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise ServiceUnavailable('خطا در دریافت لینک آپلود. لطفاً دوباره تلاش کنید.')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
MediaPresignOutputSerializer({'upload_url': upload_url, 'object_key': object_key}).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 6.0.2 on 2026-08-18 07:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stores', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='store',
|
||||||
|
name='cover_image',
|
||||||
|
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='store',
|
||||||
|
name='logo',
|
||||||
|
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='storecategory',
|
||||||
|
name='icon',
|
||||||
|
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -9,7 +9,7 @@ from utils.models import BaseModel
|
||||||
|
|
||||||
class StoreCategory(BaseModel):
|
class StoreCategory(BaseModel):
|
||||||
name = models.CharField(max_length=100, unique=True)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
icon = models.ImageField(upload_to='store_categories/', null=True, blank=True)
|
icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||||
order = models.PositiveSmallIntegerField(default=0)
|
order = models.PositiveSmallIntegerField(default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -31,8 +31,8 @@ class Store(BaseModel):
|
||||||
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
|
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
|
||||||
name = models.CharField(max_length=150)
|
name = models.CharField(max_length=150)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True)
|
logo = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||||
cover_image = models.ImageField(upload_to='store_covers/', null=True, blank=True)
|
cover_image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||||
phone_number = models.CharField(max_length=20, blank=True)
|
phone_number = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
|
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.core.media import presigned_media_url
|
||||||
from apps.locations.models import City, Neighborhood
|
from apps.locations.models import City, Neighborhood
|
||||||
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
|
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
|
||||||
|
|
||||||
|
|
@ -8,9 +9,14 @@ from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
|
||||||
|
|
||||||
|
|
||||||
class StoreCategorySerializer(serializers.ModelSerializer):
|
class StoreCategorySerializer(serializers.ModelSerializer):
|
||||||
|
icon_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StoreCategory
|
model = StoreCategory
|
||||||
fields = ('uuid', 'name', 'icon', 'order')
|
fields = ('uuid', 'name', 'icon', 'icon_url', 'order')
|
||||||
|
|
||||||
|
def get_icon_url(self, obj):
|
||||||
|
return presigned_media_url(obj.icon)
|
||||||
|
|
||||||
|
|
||||||
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
||||||
|
|
@ -21,15 +27,23 @@ class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class StoreListSerializer(serializers.ModelSerializer):
|
class StoreListSerializer(serializers.ModelSerializer):
|
||||||
category = StoreCategorySerializer(read_only=True)
|
category = StoreCategorySerializer(read_only=True)
|
||||||
|
logo_url = serializers.SerializerMethodField()
|
||||||
|
cover_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Store
|
model = Store
|
||||||
fields = (
|
fields = (
|
||||||
'uuid', 'name', 'category', 'logo', 'cover_image',
|
'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
|
||||||
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
|
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
|
||||||
'free_delivery_threshold', 'is_open',
|
'free_delivery_threshold', 'is_open',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_logo_url(self, obj):
|
||||||
|
return presigned_media_url(obj.logo)
|
||||||
|
|
||||||
|
def get_cover_image_url(self, obj):
|
||||||
|
return presigned_media_url(obj.cover_image)
|
||||||
|
|
||||||
|
|
||||||
class StoreDetailSerializer(StoreListSerializer):
|
class StoreDetailSerializer(StoreListSerializer):
|
||||||
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
|
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
|
||||||
|
|
@ -87,11 +101,14 @@ class SellerStoreSerializer(serializers.ModelSerializer):
|
||||||
latitude = serializers.FloatField(write_only=True, required=False)
|
latitude = serializers.FloatField(write_only=True, required=False)
|
||||||
longitude = serializers.FloatField(write_only=True, required=False)
|
longitude = serializers.FloatField(write_only=True, required=False)
|
||||||
bank_account = StoreBankAccountSerializer(read_only=True)
|
bank_account = StoreBankAccountSerializer(read_only=True)
|
||||||
|
logo_url = serializers.SerializerMethodField()
|
||||||
|
cover_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Store
|
model = Store
|
||||||
fields = (
|
fields = (
|
||||||
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'cover_image',
|
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'logo_url',
|
||||||
|
'cover_image', 'cover_image_url',
|
||||||
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
|
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
|
||||||
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
|
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
|
||||||
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
|
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
|
||||||
|
|
@ -100,6 +117,12 @@ class SellerStoreSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
read_only_fields = ('rating_avg', 'rating_count', 'status')
|
read_only_fields = ('rating_avg', 'rating_count', 'status')
|
||||||
|
|
||||||
|
def get_logo_url(self, obj):
|
||||||
|
return presigned_media_url(obj.logo)
|
||||||
|
|
||||||
|
def get_cover_image_url(self, obj):
|
||||||
|
return presigned_media_url(obj.cover_image)
|
||||||
|
|
||||||
def _pop_location(self, validated_data):
|
def _pop_location(self, validated_data):
|
||||||
lat = validated_data.pop('latitude', None)
|
lat = validated_data.pop('latitude', None)
|
||||||
lng = validated_data.pop('longitude', None)
|
lng = validated_data.pop('longitude', None)
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ SPECTACULAR_SETTINGS = {
|
||||||
"VERSION": "1.0.0",
|
"VERSION": "1.0.0",
|
||||||
"SERVE_INCLUDE_SCHEMA": False,
|
"SERVE_INCLUDE_SCHEMA": False,
|
||||||
"TAGS": [
|
"TAGS": [
|
||||||
|
{"name": "Media", "description": "Presigned MinIO upload URLs for product/store images."},
|
||||||
{"name": "Locations", "description": "Cities and neighborhoods (public reference data)."},
|
{"name": "Locations", "description": "Cities and neighborhoods (public reference data)."},
|
||||||
{"name": "Addresses", "description": "The authenticated customer's saved addresses."},
|
{"name": "Addresses", "description": "The authenticated customer's saved addresses."},
|
||||||
{"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."},
|
{"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."},
|
||||||
|
|
|
||||||
9
utils/clients/minio_client.py
Normal file
9
utils/clients/minio_client.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from minio import Minio
|
||||||
|
|
||||||
|
minio_client = Minio(
|
||||||
|
settings.MINIO_ENDPOINT,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
secure=settings.MINIO_USE_HTTPS,
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue