Seed demo stores/products with a placeholder image key; doc cleanup #2

Merged
Ghasemi merged 2 commits from feature/minio-integration into master 2026-08-18 09:34:04 -04:00
4 changed files with 201 additions and 12 deletions

View file

@ -17,11 +17,13 @@ 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_BUCKET_NAME on first save.
# django-minio-backend will create MINIO_MEDIA_FILES_BUCKET on first save.
# MINIO_ENDPOINT is used both for Django's own connection to MinIO and for
# building the presigned URLs returned to clients — leave MINIO_EXTERNAL_ENDPOINT
# unset unless Django reaches MinIO through a different address than clients do
# (e.g. an internal Docker/VPC hostname vs. a public domain).
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_BUCKET_NAME=winofy-media

View file

@ -1,10 +1,10 @@
# Winofy API — Frontend Integration Guide
This document is written for whoever (human or AI assistant) is building the two Winofy clients — **اپ مشتری** (customer app) and **پنل فروشنده** (seller panel) — against this backend. It covers auth, conventions, every endpoint, and the end-to-end flows that tie them together. Read §3 (Authentication) and §4 (Conventions) first — everything else assumes them.
This document is written for whoever (human or AI assistant) is building the two Winofy clients — **اپ مشتری** (customer app) and **پنل فروشنده** (seller panel) — against this backend. It covers auth, conventions, every endpoint, and the end-to-end flows that tie them together. Read §3 (Authentication) and §4 (Conventions) first — everything else assumes them. Jump to §10 for a one-glance "which screen calls which endpoint" table.
Backend repo: `winofy-backend` (Django 6 + DRF). Swagger UI: `GET /api/swagger/swagger-ui/`. Raw OpenAPI schema: `GET /api/swagger/schema/`.
> **Branch note:** this guide describes `main`, which does **not** yet include payment integration (wallet debits, the online gateway, seller payouts/withdrawals) — that's on `feature/payment`. On `main`, `checkout` still accepts a `payment_method` and creates orders normally, but `OrderGroup.payment_status` just stays `pending` regardless of method, there's no `payment` key in the checkout response, and the `Seller · Wallet` endpoints (§7) don't exist yet. Everything else in this doc applies to both branches.
> **Branch note:** this guide describes `master`, which does **not** yet include payment integration (wallet debits, the online gateway, seller payouts/withdrawals) — that's on `feature/payment`. On `master`, `checkout` still accepts a `payment_method` and creates orders normally, but `OrderGroup.payment_status` just stays `pending` regardless of method, there's no `payment` key in the checkout response, and the `Seller · Wallet` endpoints (§7) don't exist yet. Everything else in this doc applies to both branches.
## 1. Base URL
@ -112,6 +112,10 @@ 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` | در انتظار بررسی, در حال پردازش, واریز شده, رد شده |
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3. `SellerWithdrawalRequest`/`WalletTransactionRef` only exist on `feature/payment`.
### 4.7 Image uploads (presigned MinIO)
@ -122,8 +126,6 @@ Every image field (`ProductCategory.icon`, `Product.image`, `StoreCategory.icon`
3. Send the `object_key` from step 1 as the field's value in the resource's create/update body (e.g. `{"logo": "uploads/<uuid>.jpg", ...}` on `PATCH /api/v1/seller/store/`).
On read, every serializer that has an image field also returns a `<field>_url` sibling (`icon_url`, `image_url`, `logo_url`, `cover_image_url`) — a freshly presigned `GET` URL valid for 1 hour. Always re-fetch the resource (or re-request) rather than caching these URLs past an hour; they expire.
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3.
@ -240,7 +242,7 @@ Response = an `OrderGroup` object (§5, Orders below):
"created_at": "..."
}
```
**On `main`**, that's it — `payment_status` stays `"pending"` no matter which `payment_method` you send; nothing external is contacted. **On `feature/payment`**, the response also includes a `payment` key and `payment_status` actually reflects what happened:
**On `master`**, that's it — `payment_status` stays `"pending"` no matter which `payment_method` you send; nothing external is contacted. **On `feature/payment`**, the response also includes a `payment` key and `payment_status` actually reflects what happened:
- `wallet`: `payment` = `{"method": "wallet", "status": "paid"}` — synchronous, done.
- `online`: `payment` = `{"method": "online", "status": "pending", "payment_url": "..."}` — **redirect/open a webview at `payment_url`**; the gateway calls Winofy back and payment_status updates asynchronously (poll `GET /api/v1/order-groups/{uuid}/` or `GET /api/v1/orders/{uuid}/` to see it flip to `paid`).
- `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery.
@ -330,11 +332,11 @@ preparing → cancelled
```
No other transition is allowed — the backend returns `409` for anything else (e.g. trying to jump from `placed` straight to `delivered`, or cancelling after `handed_to_courier`). See §7 for the seller-side action endpoints. The customer app's `POST /orders/{uuid}/cancel/` uses the same rule (only while `placed`/`preparing`).
Commission (`Order.commission_amount`) is fixed at checkout time from the store's rate (4% by default) applied to `items_subtotal` only — `delivery_fee` is excluded from the split and goes to the seller, on both branches. **On `feature/payment` only**, the `delivered` transition additionally credits the seller's Gooyal wallet and fires a `settlement_done` notification — none of this needs client involvement either way. **On `main`**, `delivered` just marks the order delivered; no money moves yet.
Commission (`Order.commission_amount`) is fixed at checkout time from the store's rate (4% by default) applied to `items_subtotal` only — `delivery_fee` is excluded from the split and goes to the seller, on both branches. **On `feature/payment` only**, the `delivered` transition additionally credits the seller's Gooyal wallet and fires a `settlement_done` notification — none of this needs client involvement either way. **On `master`**, `delivered` just marks the order delivered; no money moves yet.
### 6.4 Payment methods, in full (`feature/payment` only)
`main` accepts and stores `payment_method` on checkout but doesn't act on it — `payment_status` stays `pending` regardless. The behavior below is what `feature/payment` adds:
`master` accepts and stores `payment_method` on checkout but doesn't act on it — `payment_status` stays `pending` regardless. The behavior below is what `feature/payment` adds:
| `payment_method` | What happens | Client follow-up |
|---|---|---|
@ -400,7 +402,7 @@ Product body: `name, description, image, category_uuid, price, unit_type, unit_v
All the action endpoints accept an optional body `{"note": "", "courier_name": "", "courier_phone": ""}` and return the updated `Order` object (§5 shape). A `409` means the transition isn't legal from the order's current status (§6.3) — don't just retry, refetch the order and re-render the correct available actions.
### Seller · Wallet (`feature/payment` only — not on `main`)
### Seller · Wallet (`feature/payment` only — not on `master`)
| Method | Path | Notes |
|---|---|---|
@ -435,7 +437,7 @@ Response:
| GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. |
| POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. |
### Payments webhook (`feature/payment` only — not on `main`, and not called by either client app)
### Payments webhook (`feature/payment` only — not on `master`, and not called by either client app)
`GET`/`POST /api/v1/payments/ipg/callback/` is hit by the `ipg` payment gateway service itself after a customer completes an online payment, not by the frontend. Don't call it directly; it's documented here only so you know it exists and isn't a client-facing endpoint.
@ -450,3 +452,42 @@ Response:
- 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.
## 10. Quick reference — screen → endpoint
The same information as §5/§7, but organized by Figma screen code instead of by resource, for wiring up one screen at a time. "Auth" endpoints need a bearer token (§3); "Public" ones don't.
### اپ مشتری (Customer app)
| Screen | Endpoint(s) |
|---|---|
| C01/C02 — Login, OTP | *None* — Gooyal accounts OAuth2 handles this entirely; see §3. |
| C03 / guest-home — Home feed | `GET /api/v1/cities/`, `GET /api/v1/neighborhoods/?city=`, `GET /api/v1/store-categories/`, `GET /api/v1/stores/?neighborhood=&category=&search=&lat=&lng=` |
| Address list / map picker / address form | `GET/POST /api/v1/addresses/`, `GET/PUT/PATCH/DELETE /api/v1/addresses/{uuid}/` |
| C04 — Store page | `GET /api/v1/stores/{uuid}/`, `GET /api/v1/product-categories/`, `GET /api/v1/products/?store={uuid}&category=` |
| C05 — Product detail | `GET /api/v1/products/{uuid}/` |
| C06 — Search | `GET /api/v1/products/?search=`, `GET /api/v1/stores/?search=` |
| C07 — Cart | `GET/DELETE /api/v1/cart/`, `POST /api/v1/cart/items/`, `PATCH/DELETE /api/v1/cart/items/{item_uuid}/` |
| C08 — Checkout | `POST /api/v1/checkout/` (§6.2, §6.4) |
| C09 — Order tracking / history | `GET /api/v1/order-groups/` (history), `GET /api/v1/order-groups/{uuid}/`, `GET /api/v1/orders/{uuid}/`, `POST /api/v1/orders/{uuid}/cancel/` |
| C10 — Post-delivery / reviews | `POST /api/v1/reviews/` (after `delivered`), `GET /api/v1/reviews/?store=` (store page's review list) |
| Notifications (bell icon) | `GET /api/v1/notifications/`, `POST /api/v1/notifications/{uuid}/mark-read/`, `POST /api/v1/notifications/mark-all-read/` |
### پنل فروشنده (Seller panel)
| Screen | Endpoint(s) |
|---|---|
| S01/S02 — Seller login, OTP | *None* — Gooyal accounts OAuth2; see §3. |
| S04 — Create store | `POST /api/v1/seller/store/` |
| S05 — Dashboard | `GET /api/v1/seller/store/`, `GET /api/v1/seller/analytics/?period=today`, `GET /api/v1/seller/orders/?status=placed` |
| S06 — Product management (list) | `GET /api/v1/seller/products/` |
| S07 — Add product | `POST /api/media/presign/` (§4.7) → upload the file → `POST /api/v1/seller/products/` with `image` = the returned `object_key` |
| S08 — Inventory | `GET /api/v1/seller/products/`, `PATCH /api/v1/seller/products/{uuid}/stock/` |
| S09 — Order management (status tabs) | `GET /api/v1/seller/orders/?status=` |
| S10 — Order detail + stepper | `GET /api/v1/seller/orders/{uuid}/`, then `POST .../confirm/`, `.../mark-ready/`, `.../mark-shipped/`, `.../mark-delivered/`, `.../cancel/` |
| S11/S12 — Wallet, withdrawal | **Not on `master`** — `feature/payment` only (§7 Seller · Wallet). |
| S14 — Store settings | `GET/PATCH /api/v1/seller/store/` (logo/cover uploads use the same presign flow as S07), `GET/PUT /api/v1/seller/store/working-hours/` |
| S15 — Seller profile | `GET /api/v1/seller/store/`, `GET /api/v1/seller/reviews/` (rating summary) |
| S16 — Notifications | Same shared endpoints as the customer app, above. |
| S17 — Analytics | `GET /api/v1/seller/analytics/?period=today\|week\|month` |
| Reviews & replies | `GET /api/v1/seller/reviews/`, `POST /api/v1/seller/reviews/{uuid}/reply/` |

138
MEDIA_INTEGRATION.md Normal file
View file

@ -0,0 +1,138 @@
# Media storage — MinIO integration
How Winofy stores and serves product/store images. This matches the pattern used across the rest of the Winsoo ecosystem (`campaign`, `advertising`, `promotions`): presigned direct-to-MinIO uploads, object keys stored on the model, presigned reads minted per-response. Branch: `feature/minio-integration`.
## Why this shape
The first pass at this integration used a Django `ImageField` + `django_minio_backend.MinioBackend` as the default storage, relying on normal DRF multipart uploads and a public-bucket direct URL. That worked, but didn't match how `campaign`/`advertising`/`promotions` do it, so it was reworked to the presign/object_key pattern below. See git history on this branch for both stages if you want the contrast.
## Architecture
```
1. Client: POST /api/media/presign/ {"filename": "photo.jpg"}
Server: generates object_key, returns a presigned MinIO PUT url (30 min expiry)
2. Client: PUT <upload_url> <raw file bytes>
(goes straight to MinIO — the Django backend never sees the bytes)
3. Client: POST/PATCH the owning resource with the object_key
e.g. PATCH /api/v1/seller/store/ {"logo": "uploads/<uuid>.jpg"}
4. Any read of that resource returns both the raw object_key AND a
freshly presigned GET url (1 hour expiry), e.g. "logo" + "logo_url"
```
Reads never trust a cached/stored URL — every serialization mints a new presigned GET URL via `apps/core/media.py::presigned_media_url()`. This is deliberate: it means bucket privacy can be tightened later (private bucket + short-lived signed links) without any API shape change, and callers can't accidentally cache a URL past its expiry without noticing (it 404s).
## Files added
| File | Purpose |
|---|---|
| `utils/clients/minio_client.py` | Raw `Minio` SDK client (same shape as `advertising`/`campaign`'s equivalent) — used only for `presigned_put_object`/`presigned_get_object`. |
| `apps/core/media.py` | `presigned_media_url(object_key, expires=1h)` — shared helper every serializer's `<field>_url` calls. Swallows/logs errors and returns `None` rather than raising, so a MinIO blip doesn't break the whole response. |
| `apps/core/serializers.py` | `MediaPresignInputSerializer` (`filename`), `MediaPresignOutputSerializer` (`upload_url`, `object_key`). |
| `apps/core/views/media.py` | `MediaPresignView` — `POST /api/media/presign/`. |
## Files changed
- `config/settings.py` — `django_minio_backend` added to `INSTALLED_APPS`; `STORAGES["default"]` points at `MinioBackend`; `MINIO_*` settings read from env (see below); `"Media"` tag added to `SPECTACULAR_SETTINGS`.
- `apps/core/urls.py` — registers `media/presign/`.
- `apps/catalog/models.py`, `apps/stores/models.py` — `icon`/`image`/`logo`/`cover_image` changed from `ImageField` to `CharField(max_length=500, null=True, blank=True)` storing a MinIO object key, not a Django-managed file.
- `apps/catalog/serializers.py`, `apps/stores/serializers.py` — every serializer exposing one of those fields gained a `<field>_url` `SerializerMethodField` (`icon_url`, `image_url`, `logo_url`, `cover_image_url`), typed via `@extend_schema_field(serializers.URLField(allow_null=True))` for clean OpenAPI output.
- `docker-compose.yml` — added a local `minio` service (ports `9000`/`9001`, `miniodata` volume), wired as a `winofy` dependency.
- `.env.example`, `CLAUDE.md` — documented the new `MINIO_*` vars.
- `requirements.in`/`requirements.txt` — `django-minio-backend`, `minio`, `pycryptodome`, `argon2-cffi`(-bindings).
- `FRONTEND_GUIDE.md` — §4.7 added (the upload flow), plus every stale "image field is a URL" / "no upload endpoint yet" reference updated across §4.5, Stores, Seller · Store, Seller · Products, §8, §9.
## Migrations
- `apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py`
- `apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py`
Both are pure `AlterField` (type/metadata change on an existing string column) — no data is dropped or moved. See **Existing data** below for what that means in practice.
## Settings / env vars
```
MINIO_ENDPOINT=localhost:9000
MINIO_USE_HTTPS=False
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_MEDIA_FILES_BUCKET=winofy-media
```
Defaults target the local `minio` container in `docker-compose.yml`. No shared bucket has been provisioned centrally yet (unlike `advertising`, which points at `drive.gooyal.com`) — `MINIO_MEDIA_FILES_BUCKET` just needs to match whatever this service's bucket ends up being called once one exists.
**`MINIO_EXTERNAL_ENDPOINT` / `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS`** (not set — intentionally omitted) exist in `django_minio_backend`'s `MinioBackend` for deployments where Django reaches MinIO through a different address than external clients do (e.g. an internal Docker/VPC hostname for Django vs. a public domain for browsers/apps) — they default to `MINIO_ENDPOINT`/`MINIO_USE_HTTPS` when unset, which is correct as long as Django and clients reach MinIO the same way (confirmed as the case here). Worth knowing if that topology ever changes: `utils/clients/minio_client.py` — the client actually used for every presign/read in this app — only reads `MINIO_ENDPOINT` and has no external-vs-internal split at all, since `STORAGES["default"]`'s `MinioBackend` (the only thing that respects `MINIO_EXTERNAL_ENDPOINT`) isn't exercised by any model field anymore now that `icon`/`image`/`logo`/`cover_image` are plain `CharField`s rather than `ImageField`s. Splitting internal/external addresses later would require adding that logic to `minio_client.py` directly, not just setting the env var.
**First-run setup against a fresh MinIO instance:** the bucket doesn't exist until something creates it. Run:
```bash
python manage.py initialize_buckets
```
(a `django_minio_backend` management command) before the presign flow will work — otherwise uploads fail with `S3Error: NoSuchBucket`.
## API reference
### `POST /api/media/presign/`
Auth required (any authenticated user — permission checks for the *resource* still happen at the resource's own endpoint, e.g. `IsStoreOwner` on `/seller/store/`).
```http
POST /api/media/presign/
Authorization: Bearer <token>
Content-Type: application/json
{"filename": "photo.jpg"}
```
```json
201
{
"upload_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-Algorithm=...&X-Amz-Expires=1800&...",
"object_key": "uploads/<uuid>.jpg"
}
```
`upload_url` expires in 30 minutes.
### Direct upload
```http
PUT <upload_url>
<raw file bytes>
```
`200`, empty body. No auth header, no JSON — the signature in the URL is the auth.
### Attach to a resource
```http
PATCH /api/v1/seller/store/
{"logo": "uploads/<uuid>.jpg"}
```
Works identically via `POST` (create) or `PATCH`/`PUT` (update) on any endpoint that owns one of these fields — the object_key is just an ordinary writable string field, not special-cased per HTTP method.
### Reading
Every serializer with an image field always includes both:
```json
{
"logo": "uploads/<uuid>.jpg",
"logo_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-...&X-Amz-Expires=3600&...",
"cover_image": null,
"cover_image_url": null
}
```
`<field>_url` is re-signed fresh on every request (1 hour expiry) — don't cache it past that.
**Fields covered:** `ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image` (each with its `_url` sibling).
## Existing data — read before deploying to an environment with real uploads
The migrations are schema-only, so no row is deleted and no string value is rewritten. But any value written **before** this branch (a path under the old local `FileSystemStorage`, e.g. `products/2026/03/01/photo.jpg`) is now meaningless: the app reads that same string as a MinIO object key and mints a presigned URL for it — which will `404` on fetch, because the actual file bytes were never copied into MinIO. `image_url`/`logo_url`/etc. will look populated in the response but not resolve.
Nothing here deletes the original files either — they're still sitting wherever they were (local disk `media/` folder under the old storage), just no longer referenced by the app.
**Before this branch reaches an environment with real uploaded images:** write a one-off backfill (upload each existing file into the `winofy-media` bucket under its existing relative path as the key, so the already-stored DB value keeps resolving without a bulk rewrite) or accept that sellers/customers will need to re-upload. Ask backend/infra whether the target environment has any real uploads before merging — this repo's local dev has none (no `media/` directory exists), so it's untested against real legacy data.
## Verification performed
Ran against a live local MinIO container (not just unit tests):
- `manage.py check` / `manage.py test` clean (pre-existing, unrelated failures in `apps/reviews` aside — see PR/branch notes).
- `initialize_buckets` → bucket created with public read policy.
- Presign → raw `PUT` upload → `object_key` stored on a real model instance → serializer-minted presigned GET URL → fetched and got the uploaded bytes back.
- Hit the actual `POST /api/media/presign/` view through Django's test client: `201` authenticated, `401` anonymous.
- `manage.py spectacular` schema generation: `/api/media/presign/` correctly tagged `Media`, request/response schemas resolve, `logo`/`cover_image`/`image`/`icon` show as writable strings (not binary) in both create and patch schemas, `_url` fields typed as nullable `string($uri)`.

View file

@ -33,6 +33,11 @@ CITIES = {
},
}
# Placeholder MinIO object key shared by every seeded store/product image —
# nothing is actually uploaded to MinIO for demo data, so `<field>_url` will
# 404 unless this exact object_key has been separately uploaded to the bucket.
DEMO_IMAGE_OBJECT_KEY = 'uploads/c1508b9e-c01f-4892-b24c-c1a949b5b5f5.png'
STORE_CATEGORIES = ['سوپرمارکت', 'کافه', 'رستوران', 'نانوایی', 'میوه و تره‌بار', 'قصابی']
STORE_NAMES = {
@ -238,6 +243,8 @@ class Command(BaseCommand):
'name': name,
'category': store_categories[store_category_name],
'description': f'عرضه انواع کالاهای با کیفیت در {city_name}.',
'logo': DEMO_IMAGE_OBJECT_KEY,
'cover_image': DEMO_IMAGE_OBJECT_KEY,
'city': city_info['city'],
'address': f'{random.choice(STREETS)}، {neighborhood.name}',
'location': neighborhood.center,
@ -274,6 +281,7 @@ class Command(BaseCommand):
defaults={
'category': product_categories[category_name],
'description': f'{name} با بهترین کیفیت و قیمت مناسب.',
'image': DEMO_IMAGE_OBJECT_KEY,
'price': max(price, 1000),
'unit_type': unit_type,
'unit_value': unit_value,