diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md index d10cb30..5fb4ed3 100644 --- a/FRONTEND_GUIDE.md +++ b/FRONTEND_GUIDE.md @@ -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 `_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/.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/.jpg", ...}` on `PATCH /api/v1/seller/store/`). + +On read, every serializer that has an image field also returns a `_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/.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. diff --git a/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py b/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py new file mode 100644 index 0000000..dd05421 --- /dev/null +++ b/apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py @@ -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), + ), + ] diff --git a/apps/catalog/models.py b/apps/catalog/models.py index 4d3f896..d449696 100644 --- a/apps/catalog/models.py +++ b/apps/catalog/models.py @@ -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) diff --git a/apps/catalog/serializers.py b/apps/catalog/serializers.py index f0160ac..160f2be 100644 --- a/apps/catalog/serializers.py +++ b/apps/catalog/serializers.py @@ -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) diff --git a/apps/core/media.py b/apps/core/media.py new file mode 100644 index 0000000..0ced976 --- /dev/null +++ b/apps/core/media.py @@ -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 diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..9383bdc --- /dev/null +++ b/apps/core/serializers.py @@ -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() diff --git a/apps/core/urls.py b/apps/core/urls.py index 6452106..32b8b9d 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -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"), ] diff --git a/apps/core/views/__init__.py b/apps/core/views/__init__.py index 6155f2b..bf1f3c4 100644 --- a/apps/core/views/__init__.py +++ b/apps/core/views/__init__.py @@ -1,3 +1,4 @@ from .health import HealthCheckView +from .media import MediaPresignView -__all__ = ["HealthCheckView"] +__all__ = ["HealthCheckView", "MediaPresignView"] diff --git a/apps/core/views/media.py b/apps/core/views/media.py new file mode 100644 index 0000000..b1a4899 --- /dev/null +++ b/apps/core/views/media.py @@ -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, + ) diff --git a/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py b/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py new file mode 100644 index 0000000..62e2404 --- /dev/null +++ b/apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py @@ -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), + ), + ] diff --git a/apps/stores/models.py b/apps/stores/models.py index 94c91eb..686483e 100644 --- a/apps/stores/models.py +++ b/apps/stores/models.py @@ -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') diff --git a/apps/stores/serializers.py b/apps/stores/serializers.py index ce40149..f3f7bef 100644 --- a/apps/stores/serializers.py +++ b/apps/stores/serializers.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 5d83fdb..cd2914d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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)."}, diff --git a/utils/clients/minio_client.py b/utils/clients/minio_client.py new file mode 100644 index 0000000..09c1a16 --- /dev/null +++ b/utils/clients/minio_client.py @@ -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, +)