feature/minio-integration #1
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