Compare commits

..

1 commit

Author SHA1 Message Date
23eb0fc2f0 Add required_alternate_scopes to authenticated views
Declares the HTTP methods each authenticated view/viewset exposes,
scoped to empty scope requirements, ready for OAuth2 scope enforcement.
2026-08-15 11:48:35 +03:30
42 changed files with 120 additions and 1008 deletions

View file

@ -1,11 +1,12 @@
SECRET_KEY=change-me SECRET_KEY=change-me
DEBUG=True DEBUG=True
ALLOWED_HOSTS=winofy-staging.winsoo.ir,localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
DB_NAME=winofy_dev DB_NAME=winofy_dev
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432
REDIS_URL=redis://localhost:6379/1 REDIS_URL=redis://localhost:6379/1
@ -14,20 +15,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 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_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_NAME=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=

View file

@ -1,24 +0,0 @@
# Changelog
## [Unreleased]
### Fixed
- `GET /api/v1/stores/` — documented the `category`, `neighborhood`, `search`, `lat`, and `lng` query params for drf-spectacular so they render in swagger. The filtering itself already worked; only the OpenAPI schema was missing them (`apps/stores/views.py`).
- `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`).
- Cart items: `CartItemView` was registered on both `/api/v1/cart/items/` and `/api/v1/cart/items/{item_uuid}/`, so swagger showed POST/PATCH/DELETE on both paths — 3 of those 6 combinations crashed with a `TypeError` at runtime (`item_uuid` missing or unexpected). Split into `CartItemView` (POST only, list path) and `CartItemDetailView` (PATCH/DELETE only, detail path) so swagger only shows the combinations that actually work (`apps/orders/views.py`, `apps/orders/urls.py`).
- Cancelling an order never restored the product stock that `checkout()` decremented, so cancelled orders' items stayed permanently out of stock. Added `_restock_order_items()`, run on the `CANCELLED` transition (`apps/orders/services.py`).
- `POST /api/v1/checkout/` always checked out the customer's entire cart, even when it held items from stores the customer didn't intend to order from yet. It now checks out only the requested stores when `store_uuids` is given (default: whole cart, unchanged), and only clears those stores' items — the rest of the cart is left intact for a later checkout (`apps/orders/serializers.py`, `apps/orders/services.py`).
### Added
- `GET /api/v1/cities/?search=` — search cities by name (`apps/locations/views.py`); previously no text search existed.
- `GET /api/v1/neighborhoods/?search=` — search neighborhoods by neighborhood name or city name, alongside the existing `city` UUID filter (`apps/locations/views.py`).
- `GET /api/v1/stores/` now returns `phone_number` in the list (previously only on the single-store detail view) and a `distance_km` field, populated whenever `lat`/`lng` are passed (`apps/stores/serializers.py`).
- Store `latitude`/`longitude` now come back on every store response — list, retrieve, and the seller's own store (`GET`/`PATCH`/`POST /api/v1/seller/store/`) — derived from `Store.location` via new `Store.latitude`/`Store.longitude` properties (`apps/stores/models.py`, `apps/stores/serializers.py`). Previously they were write-only on the seller serializer and absent everywhere else.
- Test coverage for `StoreViewSet` list filtering (`apps/stores/tests/test_store_list.py`) — previously untested.
- Test coverage for `ProductViewSet` list filtering (`apps/catalog/tests/test_product_list.py`) — previously untested.
- Test coverage for `CityViewSet`/`NeighborhoodViewSet` list filtering (`apps/locations/tests/test_location_list.py`) — previously untested.
- New `Store.max_delivery_time_minutes` field, set by the seller at registration (default 60), returned wherever a store appears in a response — store list/detail, the seller's own store, cart groups (`GET /api/v1/cart/`), and order/order-group serializers — so the checkout page's estimated-delivery-time UI no longer needs a frontend mock (`apps/stores/models.py`, `apps/stores/serializers.py`, migration `0003_store_max_delivery_time_minutes`).
- Documented `CartSerializer`'s `groups`/`grand_total` fields for swagger (`apps/orders/serializers.py`) — they were `SerializerMethodField`s with no schema, showing as opaque strings instead of the actual nested structure.
### Changed
- Replaced hand-rolled `get_queryset` filtering with `django-filter` `FilterSet` classes (`apps/{stores,catalog,locations,reviews}/filters.py`) and `filterset_fields` (`apps/orders` status). `django-filter` was already installed and set as `DEFAULT_FILTER_BACKENDS` but unused everywhere; params are now auto-documented in swagger by drf-spectacular's django-filter integration, so the manual `OpenApiParameter` declarations for those fields were removed (stores keeps `lat`/`lng` manual since geo-distance isn't a plain filter).

View file

@ -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

View file

@ -1,10 +1,10 @@
# Winofy API — Frontend Integration Guide # 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. Jump to §10 for a one-glance "which screen calls which endpoint" table. 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.
Backend repo: `winofy-backend` (Django 6 + DRF). Swagger UI: `GET /api/swagger/swagger-ui/`. Raw OpenAPI schema: `GET /api/swagger/schema/`. 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 `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. > **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.
## 1. Base URL ## 1. Base URL
@ -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
@ -115,18 +115,6 @@ Default page size is 50 if `limit` is omitted.
| `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` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
`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)
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.
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3. `Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3.
## 5. Customer app — endpoint reference ## 5. Customer app — endpoint reference
@ -167,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)
@ -242,7 +230,7 @@ Response = an `OrderGroup` object (§5, Orders below):
"created_at": "..." "created_at": "..."
} }
``` ```
**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: **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:
- `wallet`: `payment` = `{"method": "wallet", "status": "paid"}` — synchronous, done. - `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`). - `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. - `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery.
@ -332,11 +320,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`). 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 `master`**, `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 `main`**, `delivered` just marks the order delivered; no money moves yet.
### 6.4 Payment methods, in full (`feature/payment` only) ### 6.4 Payment methods, in full (`feature/payment` only)
`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: `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:
| `payment_method` | What happens | Client follow-up | | `payment_method` | What happens | Client follow-up |
|---|---|---| |---|---|---|
@ -375,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
@ -386,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
@ -402,7 +390,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. 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 `master`) ### Seller · Wallet (`feature/payment` only — not on `main`)
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
@ -437,57 +425,19 @@ Response:
| GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. | | GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. |
| POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. | | POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. |
### Payments webhook (`feature/payment` only — not on `master`, and not called by either client app) ### Payments webhook (`feature/payment` only — not on `main`, 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. `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.
## 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.
## 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/` |

View file

@ -1,138 +0,0 @@
# 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

@ -1,17 +0,0 @@
from django.db.models import Q
from django_filters import rest_framework as filters
from .models import Product
class ProductFilter(filters.FilterSet):
store = filters.UUIDFilter(field_name='store__uuid', help_text='Filter by store UUID.')
category = filters.UUIDFilter(field_name='category__uuid', help_text='Filter by product category UUID.')
search = filters.CharFilter(method='filter_search', help_text='Search by product name/description.')
class Meta:
model = Product
fields = ['store', 'category', 'search']
def filter_search(self, queryset, name, value):
return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value))

View file

@ -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),
),
]

View file

@ -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)

View file

@ -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)

View file

@ -1,37 +0,0 @@
from apps.catalog.models import ProductCategory
from apps.orders.tests.base import OrdersTestCase
class ProductListFilterTests(OrdersTestCase):
def test_filter_by_store(self):
response = self.client.get('/api/v1/products/', {'store': str(self.store1.uuid)})
self.assertEqual(response.status_code, 200)
uuids = {product['uuid'] for product in response.data['results']}
self.assertEqual(uuids, {str(self.product1.uuid)})
def test_filter_by_category(self):
other_category = ProductCategory.objects.create(name='نوشیدنی')
self.product2.category = other_category
self.product2.save(update_fields=['category'])
response = self.client.get('/api/v1/products/', {'category': str(self.product1.category.uuid)})
self.assertEqual(response.status_code, 200)
uuids = {product['uuid'] for product in response.data['results']}
self.assertEqual(uuids, {str(self.product1.uuid)})
def test_filter_by_search(self):
response = self.client.get('/api/v1/products/', {'search': 'کاله'})
self.assertEqual(response.status_code, 200)
uuids = {product['uuid'] for product in response.data['results']}
self.assertEqual(uuids, {str(self.product1.uuid)})
def test_no_filter_returns_all_products(self):
response = self.client.get('/api/v1/products/')
self.assertEqual(response.status_code, 200)
uuids = {product['uuid'] for product in response.data['results']}
self.assertEqual(uuids, {str(self.product1.uuid), str(self.product2.uuid)})

View file

@ -1,14 +1,12 @@
from django.db.models import Q
from rest_framework import mixins, viewsets from rest_framework import mixins, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.core.permissions import IsStoreOwner from apps.core.permissions import IsStoreOwner
from apps.stores.models import Store from apps.stores.models import Store
from .filters import ProductFilter
from .models import Product, ProductCategory from .models import Product, ProductCategory
from .serializers import ( from .serializers import (
ProductCategorySerializer, ProductCategorySerializer,
@ -34,22 +32,35 @@ class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.
queryset = Product.objects.filter( queryset = Product.objects.filter(
is_active=True, store__status=Store.Status.APPROVED, is_active=True, store__status=Store.Status.APPROVED,
).select_related('store', 'category') ).select_related('store', 'category')
filterset_class = ProductFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.action == 'retrieve': if self.action == 'retrieve':
return ProductDetailSerializer return ProductDetailSerializer
return ProductListSerializer return ProductListSerializer
def get_queryset(self):
queryset = super().get_queryset()
store_uuid = self.request.query_params.get('store')
if store_uuid:
queryset = queryset.filter(store__uuid=store_uuid)
category_uuid = self.request.query_params.get('category')
if category_uuid:
queryset = queryset.filter(category__uuid=category_uuid)
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search))
return queryset
class SellerProductViewSet(viewsets.ModelViewSet): class SellerProductViewSet(viewsets.ModelViewSet):
"""Seller's own product management (S06 list, S07 add, S08 inventory).""" """Seller's own product management (S06 list, S07 add, S08 inventory)."""
schema_tags = ['Seller · Products'] schema_tags = ['Seller · Products']
serializer_class = SellerProductSerializer permission_classes = [IsAuthenticated, IsStoreOwner]
# permission_classes = [IsAuthenticated, IsStoreOwner]
# TODO: IsStoreOwner?
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
@ -57,6 +68,7 @@ class SellerProductViewSet(viewsets.ModelViewSet):
"PATCH": [[]], "PATCH": [[]],
"DELETE": [[]], "DELETE": [[]],
} }
serializer_class = SellerProductSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):

View file

@ -33,11 +33,6 @@ 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_CATEGORIES = ['سوپرمارکت', 'کافه', 'رستوران', 'نانوایی', 'میوه و تره‌بار', 'قصابی']
STORE_NAMES = { STORE_NAMES = {
@ -243,8 +238,6 @@ class Command(BaseCommand):
'name': name, 'name': name,
'category': store_categories[store_category_name], 'category': store_categories[store_category_name],
'description': f'عرضه انواع کالاهای با کیفیت در {city_name}.', 'description': f'عرضه انواع کالاهای با کیفیت در {city_name}.',
'logo': DEMO_IMAGE_OBJECT_KEY,
'cover_image': DEMO_IMAGE_OBJECT_KEY,
'city': city_info['city'], 'city': city_info['city'],
'address': f'{random.choice(STREETS)}، {neighborhood.name}', 'address': f'{random.choice(STREETS)}، {neighborhood.name}',
'location': neighborhood.center, 'location': neighborhood.center,
@ -281,7 +274,6 @@ class Command(BaseCommand):
defaults={ defaults={
'category': product_categories[category_name], 'category': product_categories[category_name],
'description': f'{name} با بهترین کیفیت و قیمت مناسب.', 'description': f'{name} با بهترین کیفیت و قیمت مناسب.',
'image': DEMO_IMAGE_OBJECT_KEY,
'price': max(price, 1000), 'price': max(price, 1000),
'unit_type': unit_type, 'unit_type': unit_type,
'unit_value': unit_value, 'unit_value': unit_value,

View file

@ -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_BUCKET_NAME, object_key, expires=expires,
)
except Exception:
logger.exception('Failed to presign media url for object_key=%s', object_key)
return None

View file

@ -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()

View file

@ -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"),
] ]

View file

@ -1,4 +1,3 @@
from .health import HealthCheckView from .health import HealthCheckView
from .media import MediaPresignView
__all__ = ["HealthCheckView", "MediaPresignView"] __all__ = ["HealthCheckView"]

View file

@ -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_BUCKET_NAME,
object_key,
expires=timedelta(minutes=30),
)
except Exception:
raise ServiceUnavailable('خطا در دریافت لینک آپلود. لطفاً دوباره تلاش کنید.')
return Response(
MediaPresignOutputSerializer({'upload_url': upload_url, 'object_key': object_key}).data,
status=status.HTTP_201_CREATED,
)

View file

@ -1,24 +0,0 @@
from django.db.models import Q
from django_filters import rest_framework as filters
from .models import City, Neighborhood
class CityFilter(filters.FilterSet):
search = filters.CharFilter(field_name='name', lookup_expr='icontains', help_text='Search by city name.')
class Meta:
model = City
fields = ['search']
class NeighborhoodFilter(filters.FilterSet):
city = filters.UUIDFilter(field_name='city__uuid', help_text='Filter by city UUID.')
search = filters.CharFilter(method='filter_search', help_text='Search by neighborhood or city name.')
class Meta:
model = Neighborhood
fields = ['city', 'search']
def filter_search(self, queryset, name, value):
return queryset.filter(Q(name__icontains=value) | Q(city__name__icontains=value))

View file

@ -1,42 +0,0 @@
from rest_framework.test import APITestCase
from apps.locations.models import City, Neighborhood
class LocationListFilterTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.tehran = City.objects.create(name='تهران', slug='tehran')
cls.mashhad = City.objects.create(name='مشهد', slug='mashhad')
cls.vanak = Neighborhood.objects.create(city=cls.tehran, name='ونک', slug='vanak')
cls.tajrish = Neighborhood.objects.create(city=cls.tehran, name='تجریش', slug='tajrish')
cls.mashhad_hood = Neighborhood.objects.create(city=cls.mashhad, name='احمدآباد', slug='ahmadabad')
def test_search_city_by_name(self):
response = self.client.get('/api/v1/cities/', {'search': 'مشهد'})
self.assertEqual(response.status_code, 200)
uuids = {city['uuid'] for city in response.data['results']}
self.assertEqual(uuids, {str(self.mashhad.uuid)})
def test_filter_neighborhood_by_city(self):
response = self.client.get('/api/v1/neighborhoods/', {'city': str(self.tehran.uuid)})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.vanak.uuid), str(self.tajrish.uuid)})
def test_search_neighborhood_by_name(self):
response = self.client.get('/api/v1/neighborhoods/', {'search': 'ونک'})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.vanak.uuid)})
def test_search_neighborhood_by_city_name(self):
response = self.client.get('/api/v1/neighborhoods/', {'search': 'مشهد'})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.mashhad_hood.uuid)})

View file

@ -1,9 +1,6 @@
from rest_framework import mixins, viewsets from rest_framework import mixins, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from .filters import CityFilter, NeighborhoodFilter
from .models import Address, City, Neighborhood from .models import Address, City, Neighborhood
from .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer from .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer
@ -13,7 +10,6 @@ class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Gen
permission_classes = [AllowAny] permission_classes = [AllowAny]
serializer_class = CitySerializer serializer_class = CitySerializer
queryset = City.objects.filter(is_active=True) queryset = City.objects.filter(is_active=True)
filterset_class = CityFilter
class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -21,13 +17,18 @@ class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
permission_classes = [AllowAny] permission_classes = [AllowAny]
serializer_class = NeighborhoodSerializer serializer_class = NeighborhoodSerializer
queryset = Neighborhood.objects.filter(is_active=True).select_related('city') queryset = Neighborhood.objects.filter(is_active=True).select_related('city')
filterset_class = NeighborhoodFilter
def get_queryset(self):
queryset = super().get_queryset()
city_uuid = self.request.query_params.get('city')
if city_uuid:
queryset = queryset.filter(city__uuid=city_uuid)
return queryset
class AddressViewSet(viewsets.ModelViewSet): class AddressViewSet(viewsets.ModelViewSet):
schema_tags = ['Addresses'] schema_tags = ['Addresses']
serializer_class = AddressSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
@ -35,6 +36,7 @@ class AddressViewSet(viewsets.ModelViewSet):
"PATCH": [[]], "PATCH": [[]],
"DELETE": [[]], "DELETE": [[]],
} }
serializer_class = AddressSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):

View file

@ -1,4 +1,3 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from apps.catalog.models import Product from apps.catalog.models import Product
@ -48,11 +47,9 @@ class CartSerializer(serializers.Serializer):
self._groups_cache = build_cart_groups(cart) self._groups_cache = build_cart_groups(cart)
return self._groups_cache return self._groups_cache
@extend_schema_field(StoreCartGroupSerializer(many=True))
def get_groups(self, cart): def get_groups(self, cart):
return StoreCartGroupSerializer(self._build_groups(cart), many=True, context=self.context).data return StoreCartGroupSerializer(self._build_groups(cart), many=True, context=self.context).data
@extend_schema_field(serializers.IntegerField())
def get_grand_total(self, cart): def get_grand_total(self, cart):
return sum(group['total'] for group in self._build_groups(cart)) return sum(group['total'] for group in self._build_groups(cart))
@ -109,10 +106,6 @@ class CheckoutSerializer(serializers.Serializer):
scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None) scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None)
payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices) payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices)
notes = serializers.CharField(required=False, allow_blank=True, default='') notes = serializers.CharField(required=False, allow_blank=True, default='')
store_uuids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_empty=False, default=None,
help_text='بررسی و پرداخت فقط برای فروشگاه‌های انتخاب‌شده از سبد خرید. در صورت خالی بودن، کل سبد خرید نهایی می‌شود.',
)
def validate(self, attrs): def validate(self, attrs):
if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'): if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'):

View file

@ -1,15 +1,12 @@
from itertools import groupby from itertools import groupby
from django.db import transaction from django.db import transaction
from django.db.models import F
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from utils.exceptions import Conflict from utils.exceptions import Conflict
from apps.catalog.models import Product from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
from .models import Cart, CartItem, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
def build_cart_groups(cart): def build_cart_groups(cart):
@ -59,27 +56,14 @@ def _build_full_address(address):
@transaction.atomic @transaction.atomic
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes, store_uuids=None): def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
"""Splits the user's multi-store cart into one Order per store. """Splits the user's multi-store cart into one Order per store."""
By default checks out the whole cart. If `store_uuids` is given, only those stores'
groups are turned into orders and cleared from the cart — the rest of the cart (from
other stores) is left untouched for a later checkout.
"""
cart = Cart.objects.filter(user=user).first() cart = Cart.objects.filter(user=user).first()
if not cart or not cart.items.exists(): if not cart or not cart.items.exists():
raise ValidationError({'cart': 'سبد خرید شما خالی است.'}) raise ValidationError({'cart': 'سبد خرید شما خالی است.'})
groups = build_cart_groups(cart) groups = build_cart_groups(cart)
if store_uuids is not None:
wanted = {str(uuid) for uuid in store_uuids}
found = {str(group['store'].uuid) for group in groups}
missing = wanted - found
if missing:
raise ValidationError({'store_uuids': f'فروشگاه(های) {", ".join(missing)} در سبد خرید شما نیستند.'})
groups = [group for group in groups if str(group['store'].uuid) in wanted]
for group in groups: for group in groups:
store = group['store'] store = group['store']
if not group['meets_minimum_order']: if not group['meets_minimum_order']:
@ -147,22 +131,11 @@ def checkout(user, address, delivery_type, scheduled_at, payment_method, notes,
item.product.sold_count += item.quantity item.product.sold_count += item.quantity
item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at']) item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at'])
checked_out_item_ids = [item.pk for group in groups for item in group['items']] cart.items.all().delete()
CartItem.objects.filter(pk__in=checked_out_item_ids).delete()
return order_group return order_group
def _restock_order_items(order):
"""Reverses the stock_quantity/sold_count decrement made at checkout (see checkout())."""
for item in order.items.select_related('product').filter(product__isnull=False):
Product.objects.filter(pk=item.product_id).update(
stock_quantity=F('stock_quantity') + item.quantity,
sold_count=F('sold_count') - item.quantity,
)
@transaction.atomic
def _transition(order, to_status, *, changed_by=None, note='', **extra_fields): def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
from_status = order.status from_status = order.status
order.status = to_status order.status = to_status
@ -174,7 +147,6 @@ def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
OrderStatusLog.objects.create(order=order, from_status=from_status, to_status=to_status, changed_by=changed_by, note=note) OrderStatusLog.objects.create(order=order, from_status=from_status, to_status=to_status, changed_by=changed_by, note=note)
if to_status == Order.Status.CANCELLED: if to_status == Order.Status.CANCELLED:
_restock_order_items(order)
notify( notify(
recipient=order.store.owner, recipient=order.store.owner,
type=Notification.Type.ORDER_CANCELLED, type=Notification.Type.ORDER_CANCELLED,

View file

@ -15,15 +15,6 @@ class CartTests(OrdersTestCase):
self.assertEqual(len(response.data['groups']), 2) self.assertEqual(len(response.data['groups']), 2)
self.assertEqual(response.data['grand_total'], (28_500 * 2 + 15_000) + (85_000 + 20_000)) self.assertEqual(response.data['grand_total'], (28_500 * 2 + 15_000) + (85_000 + 20_000))
def test_cart_group_includes_store_max_delivery_time(self):
self.store1.max_delivery_time_minutes = 35
self.store1.save(update_fields=['max_delivery_time_minutes'])
self.add_to_cart(self.product1, quantity=1)
response = self.client.get('/api/v1/cart/')
self.assertEqual(response.data['groups'][0]['store']['max_delivery_time_minutes'], 35)
def test_adding_same_product_twice_increments_quantity(self): def test_adding_same_product_twice_increments_quantity(self):
self.add_to_cart(self.product1, quantity=1) self.add_to_cart(self.product1, quantity=1)
self.add_to_cart(self.product1, quantity=2) self.add_to_cart(self.product1, quantity=2)

View file

@ -33,38 +33,6 @@ class CheckoutTests(OrdersTestCase):
self.product1.refresh_from_db() self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 48) self.assertEqual(self.product1.stock_quantity, 48)
def test_checkout_can_be_limited_to_selected_stores(self):
self.add_to_cart(self.product1, quantity=1)
self.add_to_cart(self.product2, quantity=1)
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
'store_uuids': [str(self.store1.uuid)],
})
self.assertEqual(response.status_code, 201, response.data)
group = OrderGroup.objects.get(uuid=response.data['uuid'])
self.assertEqual(group.orders.count(), 1)
self.assertEqual(group.orders.get().store, self.store1)
# store2's cart item is left untouched for a later checkout.
remaining_cart = Cart.objects.get(user=self.customer)
self.assertEqual(remaining_cart.items.count(), 1)
self.assertEqual(remaining_cart.items.get().product, self.product2)
def test_checkout_rejects_store_uuid_not_in_cart(self):
self.add_to_cart(self.product1, quantity=1)
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
'store_uuids': [str(self.store2.uuid)],
})
self.assertEqual(response.status_code, 400)
self.assertTrue(Cart.objects.get(user=self.customer).items.exists())
def test_checkout_rejects_empty_cart(self): def test_checkout_rejects_empty_cart(self):
response = self.client.post('/api/v1/checkout/', { response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid), 'address_uuid': str(self.address.uuid),

View file

@ -50,30 +50,6 @@ class StatusTransitionTests(OrdersTestCase):
self.order.refresh_from_db() self.order.refresh_from_db()
self.assertEqual(self.order.status, Order.Status.CANCELLED) self.assertEqual(self.order.status, Order.Status.CANCELLED)
def test_cancelling_an_order_restocks_its_products(self):
self.product1.refresh_from_db()
stock_after_checkout = self.product1.stock_quantity
sold_after_checkout = self.product1.sold_count
self.assertEqual(stock_after_checkout, 49) # base.py seeds 50, checkout bought 1
self.assertEqual(sold_after_checkout, 1)
self.client.force_authenticate(user=self.customer)
response = self.client.post(f'/api/v1/orders/{self.order.uuid}/cancel/')
self.assertEqual(response.status_code, 200)
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, stock_after_checkout + 1)
self.assertEqual(self.product1.sold_count, sold_after_checkout - 1)
def test_cancelling_after_confirm_also_restocks(self):
self._as_seller()
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/confirm/')
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/cancel/')
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 50)
self.assertEqual(self.product1.sold_count, 0)
def test_cannot_cancel_after_handed_to_courier(self): def test_cannot_cancel_after_handed_to_courier(self):
self._as_seller() self._as_seller()
for action in ('confirm', 'mark-ready', 'mark-shipped'): for action in ('confirm', 'mark-ready', 'mark-shipped'):

View file

@ -2,7 +2,6 @@ from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
CartItemDetailView,
CartItemView, CartItemView,
CartView, CartView,
CheckoutView, CheckoutView,
@ -24,5 +23,5 @@ urlpatterns = router.urls + [
path('checkout/', CheckoutView.as_view(), name='checkout'), path('checkout/', CheckoutView.as_view(), name='checkout'),
path('cart/', CartView.as_view(), name='cart'), path('cart/', CartView.as_view(), name='cart'),
path('cart/items/', CartItemView.as_view(), name='cart-items'), path('cart/items/', CartItemView.as_view(), name='cart-items'),
path('cart/items/<uuid:item_uuid>/', CartItemDetailView.as_view(), name='cart-item-detail'), path('cart/items/<uuid:item_uuid>/', CartItemView.as_view(), name='cart-item-detail'),
] ]

View file

@ -6,8 +6,6 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.core.permissions import IsStoreOwner from apps.core.permissions import IsStoreOwner
from . import services from . import services
@ -29,12 +27,12 @@ class CartView(APIView):
"""The authenticated user's cart, grouped by store (C07).""" """The authenticated user's cart, grouped by store (C07)."""
schema_tags = ['Cart'] schema_tags = ['Cart']
serializer_class = CartSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"DELETE": [[]], "DELETE": [[]],
} }
serializer_class = CartSerializer
def get(self, request): def get(self, request):
cart, _ = Cart.objects.get_or_create(user=request.user) cart, _ = Cart.objects.get_or_create(user=request.user)
@ -48,14 +46,16 @@ class CartView(APIView):
class CartItemView(APIView): class CartItemView(APIView):
"""Add a product line to the authenticated user's cart.""" """Add/update/remove a single product line in the authenticated user's cart."""
schema_tags = ['Cart'] schema_tags = ['Cart']
serializer_class = CartItemWriteSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"POST": [[]], "POST": [[]],
"PATCH": [[]],
"DELETE": [[]],
} }
serializer_class = CartItemWriteSerializer
@extend_schema(request=CartItemWriteSerializer, responses=CartItemSerializer) @extend_schema(request=CartItemWriteSerializer, responses=CartItemSerializer)
def post(self, request): def post(self, request):
@ -74,18 +74,6 @@ class CartItemView(APIView):
return Response(CartItemSerializer(item).data, status=status.HTTP_201_CREATED) return Response(CartItemSerializer(item).data, status=status.HTTP_201_CREATED)
class CartItemDetailView(APIView):
"""Update/remove a single product line in the authenticated user's cart."""
schema_tags = ['Cart']
serializer_class = CartItemWriteSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"DELETE": [[]],
"PATCH": [[]],
}
@extend_schema(responses=CartItemSerializer) @extend_schema(responses=CartItemSerializer)
def patch(self, request, item_uuid): def patch(self, request, item_uuid):
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user) item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
@ -107,11 +95,11 @@ class CheckoutView(APIView):
"""Splits the authenticated customer's multi-store cart into per-store orders (C08).""" """Splits the authenticated customer's multi-store cart into per-store orders (C08)."""
schema_tags = ['Checkout'] schema_tags = ['Checkout']
serializer_class = CheckoutSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"POST": [[]], "POST": [[]],
} }
serializer_class = CheckoutSerializer
@extend_schema(request=CheckoutSerializer, responses=OrderGroupSerializer) @extend_schema(request=CheckoutSerializer, responses=OrderGroupSerializer)
def post(self, request): def post(self, request):
@ -126,11 +114,11 @@ class OrderGroupViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewse
"""Customer order history — each group may contain orders from several stores.""" """Customer order history — each group may contain orders from several stores."""
schema_tags = ['Orders'] schema_tags = ['Orders']
serializer_class = OrderGroupSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
} }
serializer_class = OrderGroupSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):
@ -142,12 +130,12 @@ class OrderViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing single-order tracking (C09) + cancel.""" """Customer-facing single-order tracking (C09) + cancel."""
schema_tags = ['Orders'] schema_tags = ['Orders']
serializer_class = OrderSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
} }
serializer_class = OrderSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):
@ -167,20 +155,21 @@ class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, views
"""Seller order management (S09 list w/ status tabs, S10 detail + stepper actions).""" """Seller order management (S09 list w/ status tabs, S10 detail + stepper actions)."""
schema_tags = ['Seller · Orders'] schema_tags = ['Seller · Orders']
serializer_class = OrderSerializer permission_classes = [IsAuthenticated, IsStoreOwner]
# permission_classes = [IsAuthenticated, IsStoreOwner]
# TODO: IsStoreOwner
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
} }
filterset_fields = ['status'] serializer_class = OrderSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):
return Order.objects.none() return Order.objects.none()
return Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items') queryset = Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items')
status_param = self.request.query_params.get('status')
if status_param:
queryset = queryset.filter(status=status_param)
return queryset
def _apply_transition(self, request, to_status): def _apply_transition(self, request, to_status):
order = self.get_object() order = self.get_object()
@ -218,12 +207,12 @@ class NotificationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
"""Shared notification feed (S16 for sellers; same model serves the customer app).""" """Shared notification feed (S16 for sellers; same model serves the customer app)."""
schema_tags = ['Notifications'] schema_tags = ['Notifications']
serializer_class = NotificationSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
} }
serializer_class = NotificationSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):

View file

@ -1,11 +0,0 @@
from django_filters import rest_framework as filters
from .models import Review
class ReviewFilter(filters.FilterSet):
store = filters.UUIDFilter(field_name='store__uuid', help_text='Filter by store UUID.')
class Meta:
model = Review
fields = ['store']

View file

@ -3,10 +3,8 @@ from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.core.permissions import IsStoreOwner from apps.core.permissions import IsStoreOwner
from .filters import ReviewFilter
from .models import Review from .models import Review
from .serializers import ReviewSerializer, SellerReplySerializer from .serializers import ReviewSerializer, SellerReplySerializer
@ -16,31 +14,33 @@ class ReviewViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.Gen
schema_tags = ['Reviews'] schema_tags = ['Reviews']
serializer_class = ReviewSerializer serializer_class = ReviewSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"POST": [[]], "POST": [[]],
} }
filterset_class = ReviewFilter
queryset = Review.objects.select_related('customer', 'store')
def get_permissions(self): def get_permissions(self):
if self.action == 'create': if self.action == 'create':
return [IsAuthenticatedOrTokenMatchesOASRequirements] return [IsAuthenticated()]
return [AllowAny()] return [AllowAny()]
def get_queryset(self):
queryset = Review.objects.select_related('customer', 'store')
store_uuid = self.request.query_params.get('store')
if store_uuid:
queryset = queryset.filter(store__uuid=store_uuid)
return queryset
class SellerReviewViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): class SellerReviewViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
"""Reviews left for the authenticated seller's store, with reply support.""" """Reviews left for the authenticated seller's store, with reply support."""
schema_tags = ['Seller · Reviews'] schema_tags = ['Seller · Reviews']
serializer_class = ReviewSerializer permission_classes = [IsAuthenticated, IsStoreOwner]
# permission_classes = [IsAuthenticated, IsStoreOwner]
# TODO:
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
} }
serializer_class = ReviewSerializer
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):

View file

@ -1,19 +0,0 @@
from django.db.models import Q
from django_filters import rest_framework as filters
from .models import Store
class StoreFilter(filters.FilterSet):
category = filters.UUIDFilter(field_name='category__uuid', help_text='Filter by store category UUID.')
neighborhood = filters.UUIDFilter(
field_name='service_neighborhoods__uuid', help_text='Filter by service neighborhood UUID.',
)
search = filters.CharFilter(method='filter_search', help_text='Search by store name/description.')
class Meta:
model = Store
fields = ['category', 'neighborhood', 'search']
def filter_search(self, queryset, name, value):
return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value))

View file

@ -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),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0.2 on 2026-09-02 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stores', '0002_alter_store_cover_image_alter_store_logo_and_more'),
]
operations = [
migrations.AddField(
model_name='store',
name='max_delivery_time_minutes',
field=models.PositiveSmallIntegerField(default=60, help_text='حداکثر زمان ارسال (دقیقه)، هنگام ثبت فروشگاه توسط فروشنده مشخص می\u200cشود.'),
),
]

View file

@ -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')
@ -43,9 +43,6 @@ class Store(BaseModel):
help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.', help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.',
) )
delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5) delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5)
max_delivery_time_minutes = models.PositiveSmallIntegerField(
default=60, help_text='حداکثر زمان ارسال (دقیقه)، هنگام ثبت فروشگاه توسط فروشنده مشخص می‌شود.',
)
min_order_amount = models.PositiveBigIntegerField(default=0) min_order_amount = models.PositiveBigIntegerField(default=0)
delivery_fee = models.PositiveBigIntegerField(default=0) delivery_fee = models.PositiveBigIntegerField(default=0)
@ -80,14 +77,6 @@ class Store(BaseModel):
from django.conf import settings as dj_settings from django.conf import settings as dj_settings
return dj_settings.DEFAULT_COMMISSION_PERCENT return dj_settings.DEFAULT_COMMISSION_PERCENT
@property
def latitude(self):
return self.location.y if self.location else None
@property
def longitude(self):
return self.location.x if self.location else None
class StoreWorkingHours(BaseModel): class StoreWorkingHours(BaseModel):
class Weekday(models.IntegerChoices): class Weekday(models.IntegerChoices):

View file

@ -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,42 +21,22 @@ 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()
distance_km = serializers.SerializerMethodField()
latitude = serializers.FloatField(read_only=True)
longitude = serializers.FloatField(read_only=True)
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',
'phone_number', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open', 'distance_km', 'latitude', 'longitude', 'free_delivery_threshold', 'is_open',
'max_delivery_time_minutes',
) )
@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)
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_distance_km(self, obj):
"""Only present when the request passed `lat`/`lng` (see StoreViewSet.get_queryset)."""
distance = getattr(obj, 'distance', None)
return round(distance.km, 2) if distance is not None else None
class StoreDetailSerializer(StoreListSerializer): class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True) working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta): class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + ( fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'description', 'address', 'phone_number',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours', 'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
) )
@ -112,34 +84,22 @@ class SellerStoreSerializer(serializers.ModelSerializer):
source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True), source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True),
write_only=True, many=True, required=False, write_only=True, many=True, required=False,
) )
latitude = serializers.FloatField(required=False) latitude = serializers.FloatField(write_only=True, required=False)
longitude = serializers.FloatField(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',
'max_delivery_time_minutes',
'min_order_amount', 'delivery_fee', 'free_delivery_threshold', 'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery',
'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at', 'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at',
) )
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)

View file

@ -19,26 +19,9 @@ class SellerStoreTests(OrdersTestCase):
'category_uuid': str(self.category.uuid), 'category_uuid': str(self.category.uuid),
'city_uuid': str(self.city.uuid), 'city_uuid': str(self.city.uuid),
'address': 'خیابان ولیعصر', 'address': 'خیابان ولیعصر',
'latitude': 35.75,
'longitude': 51.4,
'max_delivery_time_minutes': 40,
}) })
self.assertEqual(response.status_code, 201, response.data) self.assertEqual(response.status_code, 201, response.data)
self.assertTrue(Store.objects.filter(owner=self.new_seller).exists()) self.assertTrue(Store.objects.filter(owner=self.new_seller).exists())
self.assertAlmostEqual(response.data['latitude'], 35.75)
self.assertAlmostEqual(response.data['longitude'], 51.4)
self.assertEqual(response.data['max_delivery_time_minutes'], 40)
def test_max_delivery_time_minutes_defaults_when_not_provided(self):
self.client.force_authenticate(user=self.new_seller)
response = self.client.post('/api/v1/seller/store/', {
'name': 'کافه من',
'category_uuid': str(self.category.uuid),
'city_uuid': str(self.city.uuid),
'address': 'خیابان ولیعصر',
})
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data['max_delivery_time_minutes'], 60)
def test_seller_cannot_create_a_second_store(self): def test_seller_cannot_create_a_second_store(self):
self.client.force_authenticate(user=self.seller1) self.client.force_authenticate(user=self.seller1)

View file

@ -1,88 +0,0 @@
from django.contrib.gis.geos import Point
from apps.stores.models import StoreCategory
from apps.orders.tests.base import OrdersTestCase
class StoreListFilterTests(OrdersTestCase):
def test_filter_by_category(self):
other_category = StoreCategory.objects.create(name='رستوران')
self.store2.category = other_category
self.store2.save(update_fields=['category'])
response = self.client.get('/api/v1/stores/', {'category': str(self.store1.category.uuid)})
self.assertEqual(response.status_code, 200)
uuids = {store['uuid'] for store in response.data['results']}
self.assertEqual(uuids, {str(self.store1.uuid)})
def test_no_category_filter_returns_all_stores(self):
response = self.client.get('/api/v1/stores/')
self.assertEqual(response.status_code, 200)
uuids = {store['uuid'] for store in response.data['results']}
self.assertEqual(uuids, {str(self.store1.uuid), str(self.store2.uuid)})
def test_phone_number_included_in_list(self):
self.store1.phone_number = '02112345678'
self.store1.save(update_fields=['phone_number'])
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertEqual(store['phone_number'], '02112345678')
def test_distance_km_present_when_lat_lng_given(self):
self.store1.location = Point(51.4, 35.75, srid=4326)
self.store1.save(update_fields=['location'])
response = self.client.get('/api/v1/stores/', {'lat': '35.75', 'lng': '51.4'})
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertIsNotNone(store['distance_km'])
self.assertAlmostEqual(store['distance_km'], 0, delta=0.1)
def test_distance_km_absent_without_lat_lng(self):
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertIsNone(store['distance_km'])
def test_latitude_longitude_included_in_list(self):
self.store1.location = Point(51.4, 35.75, srid=4326)
self.store1.save(update_fields=['location'])
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertAlmostEqual(store['latitude'], 35.75)
self.assertAlmostEqual(store['longitude'], 51.4)
def test_latitude_longitude_null_without_location(self):
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertIsNone(store['latitude'])
self.assertIsNone(store['longitude'])
def test_latitude_longitude_included_in_retrieve(self):
self.store1.location = Point(51.4, 35.75, srid=4326)
self.store1.save(update_fields=['location'])
response = self.client.get(f'/api/v1/stores/{self.store1.uuid}/')
self.assertEqual(response.status_code, 200)
self.assertAlmostEqual(response.data['latitude'], 35.75)
self.assertAlmostEqual(response.data['longitude'], 51.4)
def test_max_delivery_time_minutes_included_in_list_and_retrieve(self):
self.store1.max_delivery_time_minutes = 45
self.store1.save(update_fields=['max_delivery_time_minutes'])
list_response = self.client.get('/api/v1/stores/')
store = next(s for s in list_response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertEqual(store['max_delivery_time_minutes'], 45)
detail_response = self.client.get(f'/api/v1/stores/{self.store1.uuid}/')
self.assertEqual(detail_response.data['max_delivery_time_minutes'], 45)

View file

@ -1,18 +1,16 @@
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D from django.contrib.gis.measure import D
from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.core.permissions import IsStoreOwner from apps.core.permissions import IsStoreOwner
from .filters import StoreFilter
from .models import Store, StoreCategory, StoreWorkingHours from .models import Store, StoreCategory, StoreWorkingHours
from .serializers import ( from .serializers import (
SellerStoreSerializer, SellerStoreSerializer,
@ -30,21 +28,12 @@ class StoreCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = StoreCategory.objects.all() queryset = StoreCategory.objects.all()
@extend_schema_view(
list=extend_schema(
parameters=[
OpenApiParameter('lat', float, description='Latitude; used with `lng` to sort by distance (15km radius).'),
OpenApiParameter('lng', float, description='Longitude; used with `lat` to sort by distance (15km radius).'),
],
),
)
class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing store browsing (home feed, store page).""" """Customer-facing store browsing (home feed, store page)."""
schema_tags = ['Stores'] schema_tags = ['Stores']
permission_classes = [AllowAny] permission_classes = [AllowAny]
queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city').distinct() queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city')
filterset_class = StoreFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.action == 'retrieve': if self.action == 'retrieve':
@ -54,6 +43,18 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
neighborhood_uuid = self.request.query_params.get('neighborhood')
if neighborhood_uuid:
queryset = queryset.filter(service_neighborhoods__uuid=neighborhood_uuid)
category_uuid = self.request.query_params.get('category')
if category_uuid:
queryset = queryset.filter(category__uuid=category_uuid)
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search))
lat = self.request.query_params.get('lat') lat = self.request.query_params.get('lat')
lng = self.request.query_params.get('lng') lng = self.request.query_params.get('lng')
if lat and lng: if lat and lng:
@ -62,20 +63,20 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
distance=Distance('location', point) distance=Distance('location', point)
).order_by('distance') ).order_by('distance')
return queryset return queryset.distinct()
class SellerStoreView(APIView): class SellerStoreView(APIView):
"""The authenticated seller's own store — GET/PATCH to manage it, POST to create it.""" """The authenticated seller's own store — GET/PATCH to manage it, POST to create it."""
schema_tags = ['Seller · Store'] schema_tags = ['Seller · Store']
serializer_class = SellerStoreSerializer permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"POST": [[]], "POST": [[]],
"PATCH": [[]], "PATCH": [[]],
} }
serializer_class = SellerStoreSerializer
def get(self, request): def get(self, request):
store = get_object_or_404(Store, owner=request.user) store = get_object_or_404(Store, owner=request.user)
@ -106,14 +107,12 @@ class SellerStoreWorkingHoursView(APIView):
"""Bulk get/set the authenticated seller's weekly working hours (S14).""" """Bulk get/set the authenticated seller's weekly working hours (S14)."""
schema_tags = ['Seller · Store'] schema_tags = ['Seller · Store']
serializer_class = StoreWorkingHoursSerializer permission_classes = [IsAuthenticated, IsStoreOwner]
# permission_classes = [IsAuthenticated, IsStoreOwner]
# TODO:
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = { required_alternate_scopes = {
"GET": [[]], "GET": [[]],
"PUT": [[]], "PUT": [[]],
} }
serializer_class = StoreWorkingHoursSerializer
def get(self, request): def get(self, request):
hours = StoreWorkingHours.objects.filter(store=request.user.store) hours = StoreWorkingHours.objects.filter(store=request.user.store)

View file

@ -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_BUCKET_NAME
# 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_BUCKET_NAME = env("MINIO_BUCKET_NAME", default="winofy-media") # replacement for MEDIA_ROOT
MINIO_PRIVATE_BUCKETS = [
'default',
]
MINIO_PUBLIC_BUCKETS = [
'default-public',
MINIO_BUCKET_NAME,
]
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_BUCKET_NAME,
"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)."},

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,
)