Compare commits
No commits in common. "3748da1af7f46b71a027cf2432f53faba087a449" and "c9b617483da74dbf99e18a3edd26e35912a24ca4" have entirely different histories.
3748da1af7
...
c9b617483d
19 changed files with 20 additions and 302 deletions
12
.env.example
12
.env.example
|
|
@ -14,18 +14,6 @@ CSRF_TRUSTED_ORIGINS=
|
||||||
NEARBY_RADIUS_METERS=5000
|
NEARBY_RADIUS_METERS=5000
|
||||||
DEFAULT_COMMISSION_PERCENT=4.0
|
DEFAULT_COMMISSION_PERCENT=4.0
|
||||||
|
|
||||||
# MinIO — object storage for media (product/store images), same pattern as the
|
|
||||||
# rest of the Winsoo ecosystem. Defaults point at the local `minio` container
|
|
||||||
# from docker-compose.yml. No bucket has been provisioned centrally yet —
|
|
||||||
# django-minio-backend will create MINIO_MEDIA_FILES_BUCKET on first save.
|
|
||||||
MINIO_ENDPOINT=localhost:9000
|
|
||||||
MINIO_USE_HTTPS=False
|
|
||||||
MINIO_EXTERNAL_ENDPOINT=localhost:9000
|
|
||||||
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=False
|
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
|
||||||
MINIO_SECRET_KEY=minioadmin
|
|
||||||
MINIO_MEDIA_FILES_BUCKET=winofy-media
|
|
||||||
|
|
||||||
# OAuth2 provider (Gooyal accounts) — resource-server introspection + this
|
# OAuth2 provider (Gooyal accounts) — resource-server introspection + this
|
||||||
# service's own client-credentials grant for outbound calls to Gooyal services.
|
# service's own client-credentials grant for outbound calls to Gooyal services.
|
||||||
OAUTH2_PROVIDER_BASE_PUBLIC_URL=
|
OAUTH2_PROVIDER_BASE_PUBLIC_URL=
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ Key `.env` variables (see `.env.example`):
|
||||||
- `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists
|
- `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists
|
||||||
- `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below)
|
- `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below)
|
||||||
- `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`)
|
- `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`)
|
||||||
- `MINIO_*` — MinIO object storage for media (`STORAGES['default']`), same pattern as the rest of the Winsoo ecosystem; defaults target the local `minio` container in `docker-compose.yml`
|
|
||||||
|
|
||||||
## Authentication — Gooyal accounts, resource-server pattern
|
## Authentication — Gooyal accounts, resource-server pattern
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`) 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.
|
- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set.
|
||||||
|
|
||||||
### 4.6 Enums
|
### 4.6 Enums
|
||||||
|
|
||||||
|
|
@ -112,16 +112,6 @@ 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` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
|
||||||
|
|
||||||
|
|
@ -165,7 +155,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,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.
|
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`.
|
||||||
|
|
||||||
### Catalog (`Catalog` tag)
|
### Catalog (`Catalog` tag)
|
||||||
|
|
||||||
|
|
@ -373,7 +363,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). 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"`.
|
`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.
|
||||||
|
|
||||||
### Seller · Products
|
### Seller · Products
|
||||||
|
|
||||||
|
|
@ -384,7 +374,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`. `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`).
|
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`).
|
||||||
|
|
||||||
### Seller · Orders
|
### Seller · Orders
|
||||||
|
|
||||||
|
|
@ -442,11 +432,12 @@ 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 (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.
|
- 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.
|
||||||
- `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.
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# 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.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
icon = models.ImageField(upload_to='product_categories/', null=True, blank=True)
|
||||||
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.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
image = models.ImageField(upload_to='products/', null=True, blank=True)
|
||||||
|
|
||||||
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,39 +1,26 @@
|
||||||
from drf_spectacular.utils import extend_schema_field
|
|
||||||
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', 'icon_url', 'order')
|
fields = ('uuid', 'name', 'parent', 'icon', 'order')
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
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', 'image_url', 'price', 'unit_type', 'unit_value',
|
'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value',
|
||||||
'is_active', 'is_out_of_stock',
|
'is_active', 'is_out_of_stock',
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
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)
|
||||||
|
|
@ -52,21 +39,16 @@ 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', 'image_url', 'category', 'category_uuid',
|
'uuid', 'name', 'description', 'image', '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',)
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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,10 +1,9 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import HealthCheckView, MediaPresignView
|
from .views import HealthCheckView
|
||||||
|
|
||||||
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,4 +1,3 @@
|
||||||
from .health import HealthCheckView
|
from .health import HealthCheckView
|
||||||
from .media import MediaPresignView
|
|
||||||
|
|
||||||
__all__ = ["HealthCheckView", "MediaPresignView"]
|
__all__ = ["HealthCheckView"]
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# 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.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
icon = models.ImageField(upload_to='store_categories/', null=True, blank=True)
|
||||||
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.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True)
|
||||||
cover_image = 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)
|
||||||
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,8 +1,6 @@
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from drf_spectacular.utils import extend_schema_field
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -10,15 +8,9 @@ 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', 'icon_url', 'order')
|
fields = ('uuid', 'name', 'icon', 'order')
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
def get_icon_url(self, obj):
|
|
||||||
return presigned_media_url(obj.icon)
|
|
||||||
|
|
||||||
|
|
||||||
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
||||||
|
|
@ -29,25 +21,15 @@ 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', 'logo_url', 'cover_image', 'cover_image_url',
|
'uuid', 'name', 'category', 'logo', 'cover_image',
|
||||||
'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',
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
def get_logo_url(self, obj):
|
|
||||||
return presigned_media_url(obj.logo)
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
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)
|
||||||
|
|
@ -105,14 +87,11 @@ 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', 'logo_url',
|
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'cover_image',
|
||||||
'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',
|
||||||
|
|
@ -121,14 +100,6 @@ class SellerStoreSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
read_only_fields = ('rating_avg', 'rating_count', 'status')
|
read_only_fields = ('rating_avg', 'rating_count', 'status')
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
def get_logo_url(self, obj):
|
|
||||||
return presigned_media_url(obj.logo)
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
|
@ -29,7 +28,6 @@ INSTALLED_APPS = [
|
||||||
'rest_framework_gis',
|
'rest_framework_gis',
|
||||||
'django_filters',
|
'django_filters',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
'django_minio_backend.apps.DjangoMinioBackendConfig',
|
|
||||||
|
|
||||||
# local apps
|
# local apps
|
||||||
'apps.core',
|
'apps.core',
|
||||||
|
|
@ -139,50 +137,9 @@ STATIC_URL = "{}static/".format(API_BASE_PATH)
|
||||||
STATIC_ROOT = BASE_DIR / "static"
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
MEDIA_URL = "{}media/".format(API_BASE_PATH)
|
MEDIA_URL = "{}media/".format(API_BASE_PATH)
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
# MinIO — same object-storage pattern as the rest of the Winsoo ecosystem
|
|
||||||
# (campaign, wallet, advertising, ...). Points at a local MinIO instance by
|
|
||||||
# default; no bucket has been provisioned centrally yet, so MINIO_MEDIA_FILES_BUCKET
|
|
||||||
# just needs to be set to whatever this service's bucket ends up being called.
|
|
||||||
MINIO_ENDPOINT = env("MINIO_ENDPOINT", default="localhost:9000")
|
|
||||||
MINIO_USE_HTTPS = env.bool("MINIO_USE_HTTPS", default=False)
|
|
||||||
MINIO_EXTERNAL_ENDPOINT = env("MINIO_EXTERNAL_ENDPOINT", default=MINIO_ENDPOINT)
|
|
||||||
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = env.bool("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", default=MINIO_USE_HTTPS)
|
|
||||||
MINIO_REGION = None
|
|
||||||
MINIO_ACCESS_KEY = env("MINIO_ACCESS_KEY", default="minioadmin")
|
|
||||||
MINIO_SECRET_KEY = env("MINIO_SECRET_KEY", default="minioadmin")
|
|
||||||
MINIO_URL_EXPIRY_HOURS = timedelta(days=1)
|
|
||||||
MINIO_CONSISTENCY_CHECK_ON_START = False
|
|
||||||
MINIO_MEDIA_FILES_BUCKET = env("MINIO_MEDIA_FILES_BUCKET", default="winofy-media") # replacement for MEDIA_ROOT
|
|
||||||
MINIO_PRIVATE_BUCKETS = [
|
|
||||||
'default',
|
|
||||||
]
|
|
||||||
MINIO_PUBLIC_BUCKETS = [
|
|
||||||
'default-public',
|
|
||||||
MINIO_MEDIA_FILES_BUCKET,
|
|
||||||
]
|
|
||||||
MINIO_POLICY_HOOKS = []
|
|
||||||
MINIO_BUCKET_CHECK_ON_SAVE = True # creates the bucket if missing, then saves
|
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django_minio_backend.models.MinioBackend",
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
"OPTIONS": {
|
|
||||||
"MINIO_ENDPOINT": MINIO_ENDPOINT,
|
|
||||||
"MINIO_USE_HTTPS": MINIO_USE_HTTPS,
|
|
||||||
"MINIO_EXTERNAL_ENDPOINT": MINIO_EXTERNAL_ENDPOINT,
|
|
||||||
"MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": MINIO_EXTERNAL_ENDPOINT_USE_HTTPS,
|
|
||||||
"MINIO_REGION": MINIO_REGION,
|
|
||||||
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
|
|
||||||
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
|
|
||||||
"MINIO_URL_EXPIRY_HOURS": MINIO_URL_EXPIRY_HOURS,
|
|
||||||
"MINIO_CONSISTENCY_CHECK_ON_START": MINIO_CONSISTENCY_CHECK_ON_START,
|
|
||||||
"MINIO_DEFAULT_BUCKET": MINIO_MEDIA_FILES_BUCKET,
|
|
||||||
"MINIO_PRIVATE_BUCKETS": MINIO_PRIVATE_BUCKETS,
|
|
||||||
"MINIO_PUBLIC_BUCKETS": MINIO_PUBLIC_BUCKETS,
|
|
||||||
"MINIO_POLICY_HOOKS": MINIO_POLICY_HOOKS,
|
|
||||||
"MINIO_BUCKET_CHECK_ON_SAVE": MINIO_BUCKET_CHECK_ON_SAVE,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
|
@ -228,7 +185,6 @@ 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)."},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
- minio
|
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
- monitoring
|
- monitoring
|
||||||
|
|
@ -33,20 +32,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
|
||||||
- miniodata:/data
|
|
||||||
networks:
|
|
||||||
- app
|
|
||||||
|
|
||||||
promtail:
|
promtail:
|
||||||
image: grafana/promtail:2.8.0
|
image: grafana/promtail:2.8.0
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -58,7 +43,6 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
miniodata:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
app:
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,6 @@ python-dateutil==2.9.0.post0
|
||||||
whitenoise==6.9.0
|
whitenoise==6.9.0
|
||||||
Pillow==12.3.0 # ImageField support (product/store images)
|
Pillow==12.3.0 # ImageField support (product/store images)
|
||||||
|
|
||||||
# Media storage — MinIO, same pattern as the rest of the Winsoo ecosystem
|
|
||||||
django-minio-backend==4.5.0
|
|
||||||
minio==7.2.20
|
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
gunicorn==26.0.0
|
gunicorn==26.0.0
|
||||||
gevent==26.7.0
|
gevent==26.7.0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
anyio==4.14.2
|
anyio==4.14.2
|
||||||
argon2-cffi==25.1.0
|
|
||||||
argon2-cffi-bindings==25.1.0
|
|
||||||
asgiref==3.11.1
|
asgiref==3.11.1
|
||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
certifi==2026.7.22
|
certifi==2026.7.22
|
||||||
|
|
@ -11,7 +9,6 @@ Django==6.0.2
|
||||||
django-cors-headers==4.9.0
|
django-cors-headers==4.9.0
|
||||||
django-environ==0.13.0
|
django-environ==0.13.0
|
||||||
django-filter==25.2
|
django-filter==25.2
|
||||||
django-minio-backend==4.5.0
|
|
||||||
django-oauth-toolkit==3.2.0
|
django-oauth-toolkit==3.2.0
|
||||||
django-redis==6.0.0
|
django-redis==6.0.0
|
||||||
djangorestframework==3.16.1
|
djangorestframework==3.16.1
|
||||||
|
|
@ -26,14 +23,12 @@ inflection==0.5.1
|
||||||
jsonschema==4.26.0
|
jsonschema==4.26.0
|
||||||
jsonschema-specifications==2025.9.1
|
jsonschema-specifications==2025.9.1
|
||||||
jwcrypto==1.5.8
|
jwcrypto==1.5.8
|
||||||
minio==7.2.20
|
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
pillow==12.3.0
|
pillow==12.3.0
|
||||||
psycopg==3.3.3
|
psycopg==3.3.3
|
||||||
psycopg-binary==3.3.3
|
psycopg-binary==3.3.3
|
||||||
psycopg-pool==3.3.1
|
psycopg-pool==3.3.1
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
pycryptodome==3.23.0
|
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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