feature/minio-integration #1

Merged
Ghasemi merged 3 commits from feature/minio-integration into master 2026-08-18 03:38:49 -04:00
14 changed files with 210 additions and 19 deletions
Showing only changes of commit 85598d347a - Show all commits

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}/`.
- 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`, ...).
- 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
@ -112,6 +112,16 @@ Default page size is 50 if `limit` is omitted.
| `OrderGroup.payment_status` | `pending`, `paid`, `failed` | در انتظار پرداخت, پرداخت شده, ناموفق |
| `Order.status` | `placed`, `preparing`, `ready_to_ship`, `handed_to_courier`, `delivered`, `cancelled` | سفارش ثبت شد → در حال آماده‌سازی → آماده ارسال → تحویل سفیر شد → تحویل داده شد (+ لغو شده) |
| `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` | برداشت, واریز |
| `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/{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)
@ -363,7 +373,7 @@ Store create/update body:
"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
@ -374,7 +384,7 @@ Store create/update body:
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
| 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
@ -432,12 +442,11 @@ Response:
## 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.
- 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.
## 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 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.

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(
'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)
class Meta:
@ -31,7 +31,7 @@ class Product(BaseModel):
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
name = models.CharField(max_length=200)
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()
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 apps.core.media import presigned_media_url
from apps.stores.serializers import StoreListSerializer
from .models import Product, ProductCategory
class ProductCategorySerializer(serializers.ModelSerializer):
icon_url = serializers.SerializerMethodField()
class Meta:
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):
is_out_of_stock = serializers.BooleanField(read_only=True)
image_url = serializers.SerializerMethodField()
class Meta:
model = Product
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',
)
def get_image_url(self, obj):
return presigned_media_url(obj.image)
class ProductDetailSerializer(ProductListSerializer):
category = ProductCategorySerializer(read_only=True)
@ -39,16 +49,20 @@ class SellerProductSerializer(serializers.ModelSerializer):
)
is_out_of_stock = serializers.BooleanField(read_only=True)
is_low_stock = serializers.BooleanField(read_only=True)
image_url = serializers.SerializerMethodField()
class Meta:
model = Product
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',
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
)
read_only_fields = ('sold_count',)
def get_image_url(self, obj):
return presigned_media_url(obj.image)
def create(self, validated_data):
validated_data['store'] = self.context['request'].user.store
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 .views import HealthCheckView
from .views import HealthCheckView, MediaPresignView
app_name = "core"
urlpatterns = [
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 .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):
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)
class Meta:
@ -31,8 +31,8 @@ class Store(BaseModel):
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
name = models.CharField(max_length=150)
description = models.TextField(blank=True)
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True)
cover_image = models.ImageField(upload_to='store_covers/', null=True, blank=True)
logo = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
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)
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 rest_framework import serializers
from apps.core.media import presigned_media_url
from apps.locations.models import City, Neighborhood
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
@ -8,9 +9,14 @@ from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
class StoreCategorySerializer(serializers.ModelSerializer):
icon_url = serializers.SerializerMethodField()
class Meta:
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):
@ -21,15 +27,23 @@ class StoreWorkingHoursSerializer(serializers.ModelSerializer):
class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
class Meta:
model = Store
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',
'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):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
@ -87,11 +101,14 @@ class SellerStoreSerializer(serializers.ModelSerializer):
latitude = serializers.FloatField(write_only=True, required=False)
longitude = serializers.FloatField(write_only=True, required=False)
bank_account = StoreBankAccountSerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
class Meta:
model = Store
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',
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
@ -100,6 +117,12 @@ class SellerStoreSerializer(serializers.ModelSerializer):
)
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):
lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None)

View file

@ -228,6 +228,7 @@ SPECTACULAR_SETTINGS = {
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"TAGS": [
{"name": "Media", "description": "Presigned MinIO upload URLs for product/store images."},
{"name": "Locations", "description": "Cities and neighborhoods (public reference data)."},
{"name": "Addresses", "description": "The authenticated customer's saved addresses."},
{"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,
)