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:
Ali Asadi 2026-08-18 10:42:44 +03:30
parent c4f15d9c80
commit 85598d347a
14 changed files with 210 additions and 19 deletions

View file

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

View file

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

View file

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

View file

@ -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
View 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
View 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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