Compare commits
33 commits
feature/sc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3deb360acb | |||
| aff0e77339 | |||
| ea9474633b | |||
| 85fe57d1ca | |||
| 6431c28e0f | |||
| 5117fe9b05 | |||
| dcf63952b5 | |||
| f7a65cb902 | |||
| 1d58c52a0d | |||
| 674ca34041 | |||
| 3d10c897af | |||
| ce4fe5a2c9 | |||
| dfbbbb2b22 | |||
| d4254ac086 | |||
| 6796922fe6 | |||
| eb5eb38665 | |||
| 755d74a9ff | |||
| fa9a69fff3 | |||
| 2b3fd0fc28 | |||
| 661129d0a1 | |||
| 4073081c25 | |||
| 4aacaabdeb | |||
| e85b4f19ad | |||
| 56c07c3678 | |||
| b162a9f31c | |||
| 7ed94df6b3 | |||
| 3748da1af7 | |||
| 85598d347a | |||
| c4f15d9c80 | |||
| c9b617483d | |||
| 63a116c2b2 | |||
| 1409a9cddf | |||
| d2cc4c2ec6 |
42 changed files with 1008 additions and 120 deletions
17
.env.example
17
.env.example
|
|
@ -1,12 +1,11 @@
|
|||
SECRET_KEY=change-me
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
ALLOWED_HOSTS=winofy-staging.winsoo.ir,localhost,127.0.0.1
|
||||
|
||||
DB_NAME=winofy_dev
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
|
||||
|
|
@ -15,6 +14,20 @@ CSRF_TRUSTED_ORIGINS=
|
|||
NEARBY_RADIUS_METERS=5000
|
||||
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
|
||||
# service's own client-credentials grant for outbound calls to Gooyal services.
|
||||
OAUTH2_PROVIDER_BASE_PUBLIC_URL=
|
||||
|
|
|
|||
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# 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).
|
||||
|
|
@ -44,6 +44,7 @@ Key `.env` variables (see `.env.example`):
|
|||
- `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)
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Winofy API — Frontend Integration Guide
|
||||
|
||||
This document is written for whoever (human or AI assistant) is building the two Winofy clients — **اپ مشتری** (customer app) and **پنل فروشنده** (seller panel) — against this backend. It covers auth, conventions, every endpoint, and the end-to-end flows that tie them together. Read §3 (Authentication) and §4 (Conventions) first — everything else assumes them.
|
||||
This document is written for whoever (human or AI assistant) is building the two Winofy clients — **اپ مشتری** (customer app) and **پنل فروشنده** (seller panel) — against this backend. It covers auth, conventions, every endpoint, and the end-to-end flows that tie them together. Read §3 (Authentication) and §4 (Conventions) first — everything else assumes them. Jump to §10 for a one-glance "which screen calls which endpoint" table.
|
||||
|
||||
Backend repo: `winofy-backend` (Django 6 + DRF). Swagger UI: `GET /api/swagger/swagger-ui/`. Raw OpenAPI schema: `GET /api/swagger/schema/`.
|
||||
|
||||
> **Branch note:** this guide describes `main`, which does **not** yet include payment integration (wallet debits, the online gateway, seller payouts/withdrawals) — that's on `feature/payment`. On `main`, `checkout` still accepts a `payment_method` and creates orders normally, but `OrderGroup.payment_status` just stays `pending` regardless of method, there's no `payment` key in the checkout response, and the `Seller · Wallet` endpoints (§7) don't exist yet. Everything else in this doc applies to both branches.
|
||||
> **Branch note:** this guide describes `master`, which does **not** yet include payment integration (wallet debits, the online gateway, seller payouts/withdrawals) — that's on `feature/payment`. On `master`, `checkout` still accepts a `payment_method` and creates orders normally, but `OrderGroup.payment_status` just stays `pending` regardless of method, there's no `payment` key in the checkout response, and the `Seller · Wallet` endpoints (§7) don't exist yet. Everything else in this doc applies to both branches.
|
||||
|
||||
## 1. Base URL
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ Default page size is 50 if `limit` is omitted.
|
|||
- Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`.
|
||||
- All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string.
|
||||
- All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...).
|
||||
- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set.
|
||||
- Image fields (`image`, `logo`, `cover_image`, `icon`) hold an opaque MinIO **object key** (or `null`), not a URL — read the sibling `<field>_url` (e.g. `logo_url`) for a fetchable, presigned, time-limited URL. See §4.7.
|
||||
|
||||
### 4.6 Enums
|
||||
|
||||
|
|
@ -115,6 +115,18 @@ Default page size is 50 if `limit` is omitted.
|
|||
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
|
||||
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
|
||||
|
||||
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3. `SellerWithdrawalRequest`/`WalletTransactionRef` only exist on `feature/payment`.
|
||||
|
||||
### 4.7 Image uploads (presigned MinIO)
|
||||
|
||||
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.
|
||||
|
||||
## 5. Customer app — endpoint reference
|
||||
|
|
@ -155,7 +167,7 @@ Response mirrors this but with `city`/`neighborhood` as nested objects (not `*_u
|
|||
| GET | `/api/v1/stores/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. |
|
||||
| GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). |
|
||||
|
||||
Store list item shape: `uuid, name, category{uuid,name,icon,order}, logo, cover_image, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`.
|
||||
Store list item shape: `uuid, name, category{uuid,name,icon,icon_url,order}, logo, logo_url, cover_image, cover_image_url, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. See §4.7 for `icon`/`logo`/`cover_image` vs. their `_url` siblings.
|
||||
|
||||
### Catalog (`Catalog` tag)
|
||||
|
||||
|
|
@ -230,7 +242,7 @@ Response = an `OrderGroup` object (§5, Orders below):
|
|||
"created_at": "..."
|
||||
}
|
||||
```
|
||||
**On `main`**, that's it — `payment_status` stays `"pending"` no matter which `payment_method` you send; nothing external is contacted. **On `feature/payment`**, the response also includes a `payment` key and `payment_status` actually reflects what happened:
|
||||
**On `master`**, that's it — `payment_status` stays `"pending"` no matter which `payment_method` you send; nothing external is contacted. **On `feature/payment`**, the response also includes a `payment` key and `payment_status` actually reflects what happened:
|
||||
- `wallet`: `payment` = `{"method": "wallet", "status": "paid"}` — synchronous, done.
|
||||
- `online`: `payment` = `{"method": "online", "status": "pending", "payment_url": "..."}` — **redirect/open a webview at `payment_url`**; the gateway calls Winofy back and payment_status updates asynchronously (poll `GET /api/v1/order-groups/{uuid}/` or `GET /api/v1/orders/{uuid}/` to see it flip to `paid`).
|
||||
- `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery.
|
||||
|
|
@ -320,11 +332,11 @@ preparing → cancelled
|
|||
```
|
||||
No other transition is allowed — the backend returns `409` for anything else (e.g. trying to jump from `placed` straight to `delivered`, or cancelling after `handed_to_courier`). See §7 for the seller-side action endpoints. The customer app's `POST /orders/{uuid}/cancel/` uses the same rule (only while `placed`/`preparing`).
|
||||
|
||||
Commission (`Order.commission_amount`) is fixed at checkout time from the store's rate (4% by default) applied to `items_subtotal` only — `delivery_fee` is excluded from the split and goes to the seller, on both branches. **On `feature/payment` only**, the `delivered` transition additionally credits the seller's Gooyal wallet and fires a `settlement_done` notification — none of this needs client involvement either way. **On `main`**, `delivered` just marks the order delivered; no money moves yet.
|
||||
Commission (`Order.commission_amount`) is fixed at checkout time from the store's rate (4% by default) applied to `items_subtotal` only — `delivery_fee` is excluded from the split and goes to the seller, on both branches. **On `feature/payment` only**, the `delivered` transition additionally credits the seller's Gooyal wallet and fires a `settlement_done` notification — none of this needs client involvement either way. **On `master`**, `delivered` just marks the order delivered; no money moves yet.
|
||||
|
||||
### 6.4 Payment methods, in full (`feature/payment` only)
|
||||
|
||||
`main` accepts and stores `payment_method` on checkout but doesn't act on it — `payment_status` stays `pending` regardless. The behavior below is what `feature/payment` adds:
|
||||
`master` accepts and stores `payment_method` on checkout but doesn't act on it — `payment_status` stays `pending` regardless. The behavior below is what `feature/payment` adds:
|
||||
|
||||
| `payment_method` | What happens | Client follow-up |
|
||||
|---|---|---|
|
||||
|
|
@ -363,7 +375,7 @@ Store create/update body:
|
|||
"is_open": true
|
||||
}
|
||||
```
|
||||
`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). `logo`/`cover_image` aren't settable through this JSON body — that needs a multipart upload endpoint, which doesn't exist yet; flag this to backend if the store-branding screen needs it.
|
||||
`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). To set `logo`/`cover_image`, run the presign flow (§4.7) first and include the resulting `object_key`s in this JSON body — e.g. add `"logo": "uploads/<uuid>.png"`.
|
||||
|
||||
### Seller · Products
|
||||
|
||||
|
|
@ -374,7 +386,7 @@ Store create/update body:
|
|||
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
|
||||
| PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. |
|
||||
|
||||
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. Response adds `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
|
||||
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. `image` is a MinIO object key from the presign flow (§4.7), not a file upload. Response adds `image_url` (presigned, §4.7), `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
|
||||
|
||||
### Seller · Orders
|
||||
|
||||
|
|
@ -390,7 +402,7 @@ Product body: `name, description, image, category_uuid, price, unit_type, unit_v
|
|||
|
||||
All the action endpoints accept an optional body `{"note": "", "courier_name": "", "courier_phone": ""}` and return the updated `Order` object (§5 shape). A `409` means the transition isn't legal from the order's current status (§6.3) — don't just retry, refetch the order and re-render the correct available actions.
|
||||
|
||||
### Seller · Wallet (`feature/payment` only — not on `main`)
|
||||
### Seller · Wallet (`feature/payment` only — not on `master`)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
|
|
@ -425,19 +437,57 @@ Response:
|
|||
| GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. |
|
||||
| POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. |
|
||||
|
||||
### Payments webhook (`feature/payment` only — not on `main`, and not called by either client app)
|
||||
### Payments webhook (`feature/payment` only — not on `master`, and not called by either client app)
|
||||
|
||||
`GET`/`POST /api/v1/payments/ipg/callback/` is hit by the `ipg` payment gateway service itself after a customer completes an online payment, not by the frontend. Don't call it directly; it's documented here only so you know it exists and isn't a client-facing endpoint.
|
||||
|
||||
## 8. Local development / testing
|
||||
|
||||
- The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures.
|
||||
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
|
||||
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Media, Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
|
||||
- `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check.
|
||||
|
||||
## 9. Known gaps to flag back to backend if you hit them
|
||||
|
||||
- No multipart/image-upload endpoint yet for store logo/cover or product images (JSON `PATCH` bodies can't set binary fields).
|
||||
- No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin.
|
||||
- No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1).
|
||||
- The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check.
|
||||
|
||||
## 10. Quick reference — screen → endpoint
|
||||
|
||||
The same information as §5/§7, but organized by Figma screen code instead of by resource, for wiring up one screen at a time. "Auth" endpoints need a bearer token (§3); "Public" ones don't.
|
||||
|
||||
### اپ مشتری (Customer app)
|
||||
|
||||
| Screen | Endpoint(s) |
|
||||
|---|---|
|
||||
| C01/C02 — Login, OTP | *None* — Gooyal accounts OAuth2 handles this entirely; see §3. |
|
||||
| C03 / guest-home — Home feed | `GET /api/v1/cities/`, `GET /api/v1/neighborhoods/?city=`, `GET /api/v1/store-categories/`, `GET /api/v1/stores/?neighborhood=&category=&search=&lat=&lng=` |
|
||||
| Address list / map picker / address form | `GET/POST /api/v1/addresses/`, `GET/PUT/PATCH/DELETE /api/v1/addresses/{uuid}/` |
|
||||
| C04 — Store page | `GET /api/v1/stores/{uuid}/`, `GET /api/v1/product-categories/`, `GET /api/v1/products/?store={uuid}&category=` |
|
||||
| C05 — Product detail | `GET /api/v1/products/{uuid}/` |
|
||||
| C06 — Search | `GET /api/v1/products/?search=`, `GET /api/v1/stores/?search=` |
|
||||
| C07 — Cart | `GET/DELETE /api/v1/cart/`, `POST /api/v1/cart/items/`, `PATCH/DELETE /api/v1/cart/items/{item_uuid}/` |
|
||||
| C08 — Checkout | `POST /api/v1/checkout/` (§6.2, §6.4) |
|
||||
| C09 — Order tracking / history | `GET /api/v1/order-groups/` (history), `GET /api/v1/order-groups/{uuid}/`, `GET /api/v1/orders/{uuid}/`, `POST /api/v1/orders/{uuid}/cancel/` |
|
||||
| C10 — Post-delivery / reviews | `POST /api/v1/reviews/` (after `delivered`), `GET /api/v1/reviews/?store=` (store page's review list) |
|
||||
| Notifications (bell icon) | `GET /api/v1/notifications/`, `POST /api/v1/notifications/{uuid}/mark-read/`, `POST /api/v1/notifications/mark-all-read/` |
|
||||
|
||||
### پنل فروشنده (Seller panel)
|
||||
|
||||
| Screen | Endpoint(s) |
|
||||
|---|---|
|
||||
| S01/S02 — Seller login, OTP | *None* — Gooyal accounts OAuth2; see §3. |
|
||||
| S04 — Create store | `POST /api/v1/seller/store/` |
|
||||
| S05 — Dashboard | `GET /api/v1/seller/store/`, `GET /api/v1/seller/analytics/?period=today`, `GET /api/v1/seller/orders/?status=placed` |
|
||||
| S06 — Product management (list) | `GET /api/v1/seller/products/` |
|
||||
| S07 — Add product | `POST /api/media/presign/` (§4.7) → upload the file → `POST /api/v1/seller/products/` with `image` = the returned `object_key` |
|
||||
| S08 — Inventory | `GET /api/v1/seller/products/`, `PATCH /api/v1/seller/products/{uuid}/stock/` |
|
||||
| S09 — Order management (status tabs) | `GET /api/v1/seller/orders/?status=` |
|
||||
| S10 — Order detail + stepper | `GET /api/v1/seller/orders/{uuid}/`, then `POST .../confirm/`, `.../mark-ready/`, `.../mark-shipped/`, `.../mark-delivered/`, `.../cancel/` |
|
||||
| S11/S12 — Wallet, withdrawal | **Not on `master`** — `feature/payment` only (§7 Seller · Wallet). |
|
||||
| S14 — Store settings | `GET/PATCH /api/v1/seller/store/` (logo/cover uploads use the same presign flow as S07), `GET/PUT /api/v1/seller/store/working-hours/` |
|
||||
| S15 — Seller profile | `GET /api/v1/seller/store/`, `GET /api/v1/seller/reviews/` (rating summary) |
|
||||
| S16 — Notifications | Same shared endpoints as the customer app, above. |
|
||||
| S17 — Analytics | `GET /api/v1/seller/analytics/?period=today\|week\|month` |
|
||||
| Reviews & replies | `GET /api/v1/seller/reviews/`, `POST /api/v1/seller/reviews/{uuid}/reply/` |
|
||||
|
|
|
|||
138
MEDIA_INTEGRATION.md
Normal file
138
MEDIA_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Media storage — MinIO integration
|
||||
|
||||
How Winofy stores and serves product/store images. This matches the pattern used across the rest of the Winsoo ecosystem (`campaign`, `advertising`, `promotions`): presigned direct-to-MinIO uploads, object keys stored on the model, presigned reads minted per-response. Branch: `feature/minio-integration`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The first pass at this integration used a Django `ImageField` + `django_minio_backend.MinioBackend` as the default storage, relying on normal DRF multipart uploads and a public-bucket direct URL. That worked, but didn't match how `campaign`/`advertising`/`promotions` do it, so it was reworked to the presign/object_key pattern below. See git history on this branch for both stages if you want the contrast.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
1. Client: POST /api/media/presign/ {"filename": "photo.jpg"}
|
||||
Server: generates object_key, returns a presigned MinIO PUT url (30 min expiry)
|
||||
|
||||
2. Client: PUT <upload_url> <raw file bytes>
|
||||
(goes straight to MinIO — the Django backend never sees the bytes)
|
||||
|
||||
3. Client: POST/PATCH the owning resource with the object_key
|
||||
e.g. PATCH /api/v1/seller/store/ {"logo": "uploads/<uuid>.jpg"}
|
||||
|
||||
4. Any read of that resource returns both the raw object_key AND a
|
||||
freshly presigned GET url (1 hour expiry), e.g. "logo" + "logo_url"
|
||||
```
|
||||
|
||||
Reads never trust a cached/stored URL — every serialization mints a new presigned GET URL via `apps/core/media.py::presigned_media_url()`. This is deliberate: it means bucket privacy can be tightened later (private bucket + short-lived signed links) without any API shape change, and callers can't accidentally cache a URL past its expiry without noticing (it 404s).
|
||||
|
||||
## Files added
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `utils/clients/minio_client.py` | Raw `Minio` SDK client (same shape as `advertising`/`campaign`'s equivalent) — used only for `presigned_put_object`/`presigned_get_object`. |
|
||||
| `apps/core/media.py` | `presigned_media_url(object_key, expires=1h)` — shared helper every serializer's `<field>_url` calls. Swallows/logs errors and returns `None` rather than raising, so a MinIO blip doesn't break the whole response. |
|
||||
| `apps/core/serializers.py` | `MediaPresignInputSerializer` (`filename`), `MediaPresignOutputSerializer` (`upload_url`, `object_key`). |
|
||||
| `apps/core/views/media.py` | `MediaPresignView` — `POST /api/media/presign/`. |
|
||||
|
||||
## Files changed
|
||||
|
||||
- `config/settings.py` — `django_minio_backend` added to `INSTALLED_APPS`; `STORAGES["default"]` points at `MinioBackend`; `MINIO_*` settings read from env (see below); `"Media"` tag added to `SPECTACULAR_SETTINGS`.
|
||||
- `apps/core/urls.py` — registers `media/presign/`.
|
||||
- `apps/catalog/models.py`, `apps/stores/models.py` — `icon`/`image`/`logo`/`cover_image` changed from `ImageField` to `CharField(max_length=500, null=True, blank=True)` storing a MinIO object key, not a Django-managed file.
|
||||
- `apps/catalog/serializers.py`, `apps/stores/serializers.py` — every serializer exposing one of those fields gained a `<field>_url` `SerializerMethodField` (`icon_url`, `image_url`, `logo_url`, `cover_image_url`), typed via `@extend_schema_field(serializers.URLField(allow_null=True))` for clean OpenAPI output.
|
||||
- `docker-compose.yml` — added a local `minio` service (ports `9000`/`9001`, `miniodata` volume), wired as a `winofy` dependency.
|
||||
- `.env.example`, `CLAUDE.md` — documented the new `MINIO_*` vars.
|
||||
- `requirements.in`/`requirements.txt` — `django-minio-backend`, `minio`, `pycryptodome`, `argon2-cffi`(-bindings).
|
||||
- `FRONTEND_GUIDE.md` — §4.7 added (the upload flow), plus every stale "image field is a URL" / "no upload endpoint yet" reference updated across §4.5, Stores, Seller · Store, Seller · Products, §8, §9.
|
||||
|
||||
## Migrations
|
||||
|
||||
- `apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py`
|
||||
- `apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py`
|
||||
|
||||
Both are pure `AlterField` (type/metadata change on an existing string column) — no data is dropped or moved. See **Existing data** below for what that means in practice.
|
||||
|
||||
## Settings / env vars
|
||||
|
||||
```
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_USE_HTTPS=False
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_MEDIA_FILES_BUCKET=winofy-media
|
||||
```
|
||||
|
||||
Defaults target the local `minio` container in `docker-compose.yml`. No shared bucket has been provisioned centrally yet (unlike `advertising`, which points at `drive.gooyal.com`) — `MINIO_MEDIA_FILES_BUCKET` just needs to match whatever this service's bucket ends up being called once one exists.
|
||||
|
||||
**`MINIO_EXTERNAL_ENDPOINT` / `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS`** (not set — intentionally omitted) exist in `django_minio_backend`'s `MinioBackend` for deployments where Django reaches MinIO through a different address than external clients do (e.g. an internal Docker/VPC hostname for Django vs. a public domain for browsers/apps) — they default to `MINIO_ENDPOINT`/`MINIO_USE_HTTPS` when unset, which is correct as long as Django and clients reach MinIO the same way (confirmed as the case here). Worth knowing if that topology ever changes: `utils/clients/minio_client.py` — the client actually used for every presign/read in this app — only reads `MINIO_ENDPOINT` and has no external-vs-internal split at all, since `STORAGES["default"]`'s `MinioBackend` (the only thing that respects `MINIO_EXTERNAL_ENDPOINT`) isn't exercised by any model field anymore now that `icon`/`image`/`logo`/`cover_image` are plain `CharField`s rather than `ImageField`s. Splitting internal/external addresses later would require adding that logic to `minio_client.py` directly, not just setting the env var.
|
||||
|
||||
**First-run setup against a fresh MinIO instance:** the bucket doesn't exist until something creates it. Run:
|
||||
```bash
|
||||
python manage.py initialize_buckets
|
||||
```
|
||||
(a `django_minio_backend` management command) before the presign flow will work — otherwise uploads fail with `S3Error: NoSuchBucket`.
|
||||
|
||||
## API reference
|
||||
|
||||
### `POST /api/media/presign/`
|
||||
Auth required (any authenticated user — permission checks for the *resource* still happen at the resource's own endpoint, e.g. `IsStoreOwner` on `/seller/store/`).
|
||||
|
||||
```http
|
||||
POST /api/media/presign/
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"filename": "photo.jpg"}
|
||||
```
|
||||
```json
|
||||
201
|
||||
{
|
||||
"upload_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-Algorithm=...&X-Amz-Expires=1800&...",
|
||||
"object_key": "uploads/<uuid>.jpg"
|
||||
}
|
||||
```
|
||||
`upload_url` expires in 30 minutes.
|
||||
|
||||
### Direct upload
|
||||
```http
|
||||
PUT <upload_url>
|
||||
<raw file bytes>
|
||||
```
|
||||
`200`, empty body. No auth header, no JSON — the signature in the URL is the auth.
|
||||
|
||||
### Attach to a resource
|
||||
```http
|
||||
PATCH /api/v1/seller/store/
|
||||
{"logo": "uploads/<uuid>.jpg"}
|
||||
```
|
||||
Works identically via `POST` (create) or `PATCH`/`PUT` (update) on any endpoint that owns one of these fields — the object_key is just an ordinary writable string field, not special-cased per HTTP method.
|
||||
|
||||
### Reading
|
||||
Every serializer with an image field always includes both:
|
||||
```json
|
||||
{
|
||||
"logo": "uploads/<uuid>.jpg",
|
||||
"logo_url": "http://localhost:9000/winofy-media/uploads/<uuid>.jpg?X-Amz-...&X-Amz-Expires=3600&...",
|
||||
"cover_image": null,
|
||||
"cover_image_url": null
|
||||
}
|
||||
```
|
||||
`<field>_url` is re-signed fresh on every request (1 hour expiry) — don't cache it past that.
|
||||
|
||||
**Fields covered:** `ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image` (each with its `_url` sibling).
|
||||
|
||||
## Existing data — read before deploying to an environment with real uploads
|
||||
|
||||
The migrations are schema-only, so no row is deleted and no string value is rewritten. But any value written **before** this branch (a path under the old local `FileSystemStorage`, e.g. `products/2026/03/01/photo.jpg`) is now meaningless: the app reads that same string as a MinIO object key and mints a presigned URL for it — which will `404` on fetch, because the actual file bytes were never copied into MinIO. `image_url`/`logo_url`/etc. will look populated in the response but not resolve.
|
||||
|
||||
Nothing here deletes the original files either — they're still sitting wherever they were (local disk `media/` folder under the old storage), just no longer referenced by the app.
|
||||
|
||||
**Before this branch reaches an environment with real uploaded images:** write a one-off backfill (upload each existing file into the `winofy-media` bucket under its existing relative path as the key, so the already-stored DB value keeps resolving without a bulk rewrite) or accept that sellers/customers will need to re-upload. Ask backend/infra whether the target environment has any real uploads before merging — this repo's local dev has none (no `media/` directory exists), so it's untested against real legacy data.
|
||||
|
||||
## Verification performed
|
||||
|
||||
Ran against a live local MinIO container (not just unit tests):
|
||||
- `manage.py check` / `manage.py test` clean (pre-existing, unrelated failures in `apps/reviews` aside — see PR/branch notes).
|
||||
- `initialize_buckets` → bucket created with public read policy.
|
||||
- Presign → raw `PUT` upload → `object_key` stored on a real model instance → serializer-minted presigned GET URL → fetched and got the uploaded bytes back.
|
||||
- Hit the actual `POST /api/media/presign/` view through Django's test client: `201` authenticated, `401` anonymous.
|
||||
- `manage.py spectacular` schema generation: `/api/media/presign/` correctly tagged `Media`, request/response schemas resolve, `logo`/`cover_image`/`image`/`icon` show as writable strings (not binary) in both create and patch schemas, `_url` fields typed as nullable `string($uri)`.
|
||||
17
apps/catalog/filters.py
Normal file
17
apps/catalog/filters.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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))
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 6.0.2 on 2026-08-18 07:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='image',
|
||||
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productcategory',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -9,7 +9,7 @@ class ProductCategory(BaseModel):
|
|||
parent = models.ForeignKey(
|
||||
'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True,
|
||||
)
|
||||
icon = models.ImageField(upload_to='product_categories/', null=True, blank=True)
|
||||
icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||
order = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -31,7 +31,7 @@ class Product(BaseModel):
|
|||
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
image = models.ImageField(upload_to='products/', null=True, blank=True)
|
||||
image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||
|
||||
price = models.PositiveBigIntegerField()
|
||||
unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,39 @@
|
|||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.media import presigned_media_url
|
||||
from apps.stores.serializers import StoreListSerializer
|
||||
|
||||
from .models import Product, ProductCategory
|
||||
|
||||
|
||||
class ProductCategorySerializer(serializers.ModelSerializer):
|
||||
icon_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProductCategory
|
||||
fields = ('uuid', 'name', 'parent', 'icon', 'order')
|
||||
fields = ('uuid', 'name', 'parent', 'icon', 'icon_url', 'order')
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_icon_url(self, obj):
|
||||
return presigned_media_url(obj.icon)
|
||||
|
||||
|
||||
class ProductListSerializer(serializers.ModelSerializer):
|
||||
is_out_of_stock = serializers.BooleanField(read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = (
|
||||
'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value',
|
||||
'uuid', 'name', 'image', 'image_url', 'price', 'unit_type', 'unit_value',
|
||||
'is_active', 'is_out_of_stock',
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_image_url(self, obj):
|
||||
return presigned_media_url(obj.image)
|
||||
|
||||
|
||||
class ProductDetailSerializer(ProductListSerializer):
|
||||
category = ProductCategorySerializer(read_only=True)
|
||||
|
|
@ -39,16 +52,21 @@ class SellerProductSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
is_out_of_stock = serializers.BooleanField(read_only=True)
|
||||
is_low_stock = serializers.BooleanField(read_only=True)
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = (
|
||||
'uuid', 'name', 'description', 'image', 'category', 'category_uuid',
|
||||
'uuid', 'name', 'description', 'image', 'image_url', 'category', 'category_uuid',
|
||||
'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold',
|
||||
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
|
||||
)
|
||||
read_only_fields = ('sold_count',)
|
||||
|
||||
@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):
|
||||
validated_data['store'] = self.context['request'].user.store
|
||||
return super().create(validated_data)
|
||||
|
|
|
|||
37
apps/catalog/tests/test_product_list.py
Normal file
37
apps/catalog/tests/test_product_list.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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)})
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
from django.db.models import Q
|
||||
from rest_framework import mixins, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
|
||||
from apps.core.permissions import IsStoreOwner
|
||||
from apps.stores.models import Store
|
||||
|
||||
from .filters import ProductFilter
|
||||
from .models import Product, ProductCategory
|
||||
from .serializers import (
|
||||
ProductCategorySerializer,
|
||||
|
|
@ -32,35 +34,22 @@ class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.
|
|||
queryset = Product.objects.filter(
|
||||
is_active=True, store__status=Store.Status.APPROVED,
|
||||
).select_related('store', 'category')
|
||||
filterset_class = ProductFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
return ProductDetailSerializer
|
||||
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):
|
||||
"""Seller's own product management (S06 list, S07 add, S08 inventory)."""
|
||||
|
||||
schema_tags = ['Seller · Products']
|
||||
permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
serializer_class = SellerProductSerializer
|
||||
# permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
# TODO: IsStoreOwner?
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
|
|
@ -68,7 +57,6 @@ class SellerProductViewSet(viewsets.ModelViewSet):
|
|||
"PATCH": [[]],
|
||||
"DELETE": [[]],
|
||||
}
|
||||
serializer_class = SellerProductSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ CITIES = {
|
|||
},
|
||||
}
|
||||
|
||||
# Placeholder MinIO object key shared by every seeded store/product image —
|
||||
# nothing is actually uploaded to MinIO for demo data, so `<field>_url` will
|
||||
# 404 unless this exact object_key has been separately uploaded to the bucket.
|
||||
DEMO_IMAGE_OBJECT_KEY = 'uploads/c1508b9e-c01f-4892-b24c-c1a949b5b5f5.png'
|
||||
|
||||
STORE_CATEGORIES = ['سوپرمارکت', 'کافه', 'رستوران', 'نانوایی', 'میوه و ترهبار', 'قصابی']
|
||||
|
||||
STORE_NAMES = {
|
||||
|
|
@ -238,6 +243,8 @@ class Command(BaseCommand):
|
|||
'name': name,
|
||||
'category': store_categories[store_category_name],
|
||||
'description': f'عرضه انواع کالاهای با کیفیت در {city_name}.',
|
||||
'logo': DEMO_IMAGE_OBJECT_KEY,
|
||||
'cover_image': DEMO_IMAGE_OBJECT_KEY,
|
||||
'city': city_info['city'],
|
||||
'address': f'{random.choice(STREETS)}، {neighborhood.name}',
|
||||
'location': neighborhood.center,
|
||||
|
|
@ -274,6 +281,7 @@ class Command(BaseCommand):
|
|||
defaults={
|
||||
'category': product_categories[category_name],
|
||||
'description': f'{name} با بهترین کیفیت و قیمت مناسب.',
|
||||
'image': DEMO_IMAGE_OBJECT_KEY,
|
||||
'price': max(price, 1000),
|
||||
'unit_type': unit_type,
|
||||
'unit_value': unit_value,
|
||||
|
|
|
|||
21
apps/core/media.py
Normal file
21
apps/core/media.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from utils.clients.minio_client import minio_client
|
||||
|
||||
logger = logging.getLogger('winofy.media')
|
||||
|
||||
|
||||
def presigned_media_url(object_key, expires=timedelta(hours=1)):
|
||||
"""Mints a fresh presigned GET URL for a stored MinIO object_key, or None if unset/unreachable."""
|
||||
if not object_key:
|
||||
return None
|
||||
try:
|
||||
return minio_client.presigned_get_object(
|
||||
settings.MINIO_BUCKET_NAME, object_key, expires=expires,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to presign media url for object_key=%s', object_key)
|
||||
return None
|
||||
10
apps/core/serializers.py
Normal file
10
apps/core/serializers.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class MediaPresignInputSerializer(serializers.Serializer):
|
||||
filename = serializers.CharField(max_length=255)
|
||||
|
||||
|
||||
class MediaPresignOutputSerializer(serializers.Serializer):
|
||||
upload_url = serializers.URLField()
|
||||
object_key = serializers.CharField()
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import HealthCheckView
|
||||
from .views import HealthCheckView, MediaPresignView
|
||||
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("health/", HealthCheckView.as_view(), name="health"),
|
||||
path("media/presign/", MediaPresignView.as_view(), name="media-presign"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from .health import HealthCheckView
|
||||
from .media import MediaPresignView
|
||||
|
||||
__all__ = ["HealthCheckView"]
|
||||
__all__ = ["HealthCheckView", "MediaPresignView"]
|
||||
|
|
|
|||
51
apps/core/views/media.py
Normal file
51
apps/core/views/media.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.serializers import MediaPresignInputSerializer, MediaPresignOutputSerializer
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from utils.clients.minio_client import minio_client
|
||||
from utils.exceptions import ServiceUnavailable
|
||||
|
||||
|
||||
class MediaPresignView(APIView):
|
||||
"""Presigned MinIO upload URL for product/store images (icon, logo, cover, ...).
|
||||
|
||||
The client uploads the file bytes directly to MinIO with the returned
|
||||
upload_url, then submits the returned object_key as the field value when
|
||||
creating/updating the owning resource (e.g. Store.logo, Product.image).
|
||||
"""
|
||||
|
||||
schema_tags = ['Media']
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"POST": [[]],
|
||||
}
|
||||
|
||||
@extend_schema(request=MediaPresignInputSerializer, responses={201: MediaPresignOutputSerializer})
|
||||
def post(self, request):
|
||||
serializer = MediaPresignInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
filename = serializer.validated_data['filename']
|
||||
extension = filename.rsplit('.', 1)[-1] if '.' in filename else 'bin'
|
||||
object_key = f'uploads/{uuid.uuid4()}.{extension}'
|
||||
|
||||
try:
|
||||
upload_url = minio_client.presigned_put_object(
|
||||
settings.MINIO_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,
|
||||
)
|
||||
24
apps/locations/filters.py
Normal file
24
apps/locations/filters.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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))
|
||||
42
apps/locations/tests/test_location_list.py
Normal file
42
apps/locations/tests/test_location_list.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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)})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
from rest_framework import mixins, viewsets
|
||||
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 .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer
|
||||
|
||||
|
|
@ -10,6 +13,7 @@ class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Gen
|
|||
permission_classes = [AllowAny]
|
||||
serializer_class = CitySerializer
|
||||
queryset = City.objects.filter(is_active=True)
|
||||
filterset_class = CityFilter
|
||||
|
||||
|
||||
class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
|
|
@ -17,18 +21,13 @@ class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
|
|||
permission_classes = [AllowAny]
|
||||
serializer_class = NeighborhoodSerializer
|
||||
queryset = Neighborhood.objects.filter(is_active=True).select_related('city')
|
||||
|
||||
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
|
||||
filterset_class = NeighborhoodFilter
|
||||
|
||||
|
||||
class AddressViewSet(viewsets.ModelViewSet):
|
||||
schema_tags = ['Addresses']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = AddressSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
|
|
@ -36,7 +35,6 @@ class AddressViewSet(viewsets.ModelViewSet):
|
|||
"PATCH": [[]],
|
||||
"DELETE": [[]],
|
||||
}
|
||||
serializer_class = AddressSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.catalog.models import Product
|
||||
|
|
@ -47,9 +48,11 @@ class CartSerializer(serializers.Serializer):
|
|||
self._groups_cache = build_cart_groups(cart)
|
||||
return self._groups_cache
|
||||
|
||||
@extend_schema_field(StoreCartGroupSerializer(many=True))
|
||||
def get_groups(self, cart):
|
||||
return StoreCartGroupSerializer(self._build_groups(cart), many=True, context=self.context).data
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_grand_total(self, cart):
|
||||
return sum(group['total'] for group in self._build_groups(cart))
|
||||
|
||||
|
|
@ -106,6 +109,10 @@ class CheckoutSerializer(serializers.Serializer):
|
|||
scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None)
|
||||
payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices)
|
||||
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):
|
||||
if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'):
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from itertools import groupby
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from utils.exceptions import Conflict
|
||||
|
||||
from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
|
||||
from apps.catalog.models import Product
|
||||
|
||||
from .models import Cart, CartItem, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
|
||||
|
||||
|
||||
def build_cart_groups(cart):
|
||||
|
|
@ -56,14 +59,27 @@ def _build_full_address(address):
|
|||
|
||||
|
||||
@transaction.atomic
|
||||
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
|
||||
"""Splits the user's multi-store cart into one Order per store."""
|
||||
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes, store_uuids=None):
|
||||
"""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()
|
||||
if not cart or not cart.items.exists():
|
||||
raise ValidationError({'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:
|
||||
store = group['store']
|
||||
if not group['meets_minimum_order']:
|
||||
|
|
@ -131,11 +147,22 @@ def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
|
|||
item.product.sold_count += item.quantity
|
||||
item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at'])
|
||||
|
||||
cart.items.all().delete()
|
||||
checked_out_item_ids = [item.pk for group in groups for item in group['items']]
|
||||
CartItem.objects.filter(pk__in=checked_out_item_ids).delete()
|
||||
|
||||
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):
|
||||
from_status = order.status
|
||||
order.status = to_status
|
||||
|
|
@ -147,6 +174,7 @@ 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)
|
||||
|
||||
if to_status == Order.Status.CANCELLED:
|
||||
_restock_order_items(order)
|
||||
notify(
|
||||
recipient=order.store.owner,
|
||||
type=Notification.Type.ORDER_CANCELLED,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ class CartTests(OrdersTestCase):
|
|||
self.assertEqual(len(response.data['groups']), 2)
|
||||
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):
|
||||
self.add_to_cart(self.product1, quantity=1)
|
||||
self.add_to_cart(self.product1, quantity=2)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,38 @@ class CheckoutTests(OrdersTestCase):
|
|||
self.product1.refresh_from_db()
|
||||
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):
|
||||
response = self.client.post('/api/v1/checkout/', {
|
||||
'address_uuid': str(self.address.uuid),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,30 @@ class StatusTransitionTests(OrdersTestCase):
|
|||
self.order.refresh_from_db()
|
||||
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):
|
||||
self._as_seller()
|
||||
for action in ('confirm', 'mark-ready', 'mark-shipped'):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from django.urls import path
|
|||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
CartItemDetailView,
|
||||
CartItemView,
|
||||
CartView,
|
||||
CheckoutView,
|
||||
|
|
@ -23,5 +24,5 @@ urlpatterns = router.urls + [
|
|||
path('checkout/', CheckoutView.as_view(), name='checkout'),
|
||||
path('cart/', CartView.as_view(), name='cart'),
|
||||
path('cart/items/', CartItemView.as_view(), name='cart-items'),
|
||||
path('cart/items/<uuid:item_uuid>/', CartItemView.as_view(), name='cart-item-detail'),
|
||||
path('cart/items/<uuid:item_uuid>/', CartItemDetailView.as_view(), name='cart-item-detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
|
||||
from apps.core.permissions import IsStoreOwner
|
||||
|
||||
from . import services
|
||||
|
|
@ -27,12 +29,12 @@ class CartView(APIView):
|
|||
"""The authenticated user's cart, grouped by store (C07)."""
|
||||
|
||||
schema_tags = ['Cart']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = CartSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"DELETE": [[]],
|
||||
}
|
||||
serializer_class = CartSerializer
|
||||
|
||||
def get(self, request):
|
||||
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||||
|
|
@ -46,16 +48,14 @@ class CartView(APIView):
|
|||
|
||||
|
||||
class CartItemView(APIView):
|
||||
"""Add/update/remove a single product line in the authenticated user's cart."""
|
||||
"""Add a product line to the authenticated user's cart."""
|
||||
|
||||
schema_tags = ['Cart']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = CartItemWriteSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"POST": [[]],
|
||||
"PATCH": [[]],
|
||||
"DELETE": [[]],
|
||||
}
|
||||
serializer_class = CartItemWriteSerializer
|
||||
|
||||
@extend_schema(request=CartItemWriteSerializer, responses=CartItemSerializer)
|
||||
def post(self, request):
|
||||
|
|
@ -74,6 +74,18 @@ class CartItemView(APIView):
|
|||
|
||||
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)
|
||||
def patch(self, request, item_uuid):
|
||||
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
|
||||
|
|
@ -95,11 +107,11 @@ class CheckoutView(APIView):
|
|||
"""Splits the authenticated customer's multi-store cart into per-store orders (C08)."""
|
||||
|
||||
schema_tags = ['Checkout']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = CheckoutSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"POST": [[]],
|
||||
}
|
||||
serializer_class = CheckoutSerializer
|
||||
|
||||
@extend_schema(request=CheckoutSerializer, responses=OrderGroupSerializer)
|
||||
def post(self, request):
|
||||
|
|
@ -114,11 +126,11 @@ class OrderGroupViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewse
|
|||
"""Customer order history — each group may contain orders from several stores."""
|
||||
|
||||
schema_tags = ['Orders']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = OrderGroupSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
}
|
||||
serializer_class = OrderGroupSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
@ -130,12 +142,12 @@ class OrderViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
|||
"""Customer-facing single-order tracking (C09) + cancel."""
|
||||
|
||||
schema_tags = ['Orders']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = OrderSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
}
|
||||
serializer_class = OrderSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
@ -155,21 +167,20 @@ class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, views
|
|||
"""Seller order management (S09 list w/ status tabs, S10 detail + stepper actions)."""
|
||||
|
||||
schema_tags = ['Seller · Orders']
|
||||
permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
serializer_class = OrderSerializer
|
||||
# permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
# TODO: IsStoreOwner
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
}
|
||||
serializer_class = OrderSerializer
|
||||
filterset_fields = ['status']
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
return Order.objects.none()
|
||||
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
|
||||
return Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items')
|
||||
|
||||
def _apply_transition(self, request, to_status):
|
||||
order = self.get_object()
|
||||
|
|
@ -207,12 +218,12 @@ class NotificationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
|
|||
"""Shared notification feed (S16 for sellers; same model serves the customer app)."""
|
||||
|
||||
schema_tags = ['Notifications']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
}
|
||||
serializer_class = NotificationSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
|
|||
11
apps/reviews/filters.py
Normal file
11
apps/reviews/filters.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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']
|
||||
|
|
@ -3,8 +3,10 @@ from rest_framework.decorators import action
|
|||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from apps.core.permissions import IsStoreOwner
|
||||
|
||||
from .filters import ReviewFilter
|
||||
from .models import Review
|
||||
from .serializers import ReviewSerializer, SellerReplySerializer
|
||||
|
||||
|
|
@ -14,33 +16,31 @@ class ReviewViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.Gen
|
|||
|
||||
schema_tags = ['Reviews']
|
||||
serializer_class = ReviewSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"POST": [[]],
|
||||
}
|
||||
filterset_class = ReviewFilter
|
||||
queryset = Review.objects.select_related('customer', 'store')
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'create':
|
||||
return [IsAuthenticated()]
|
||||
return [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
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):
|
||||
"""Reviews left for the authenticated seller's store, with reply support."""
|
||||
|
||||
schema_tags = ['Seller · Reviews']
|
||||
permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
serializer_class = ReviewSerializer
|
||||
# permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
# TODO:
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
}
|
||||
serializer_class = ReviewSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
|
|
|
|||
19
apps/stores/filters.py
Normal file
19
apps/stores/filters.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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))
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 6.0.2 on 2026-08-18 07:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stores', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='store',
|
||||
name='cover_image',
|
||||
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='store',
|
||||
name='logo',
|
||||
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='storecategory',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, help_text='MinIO object key.', max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# 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شود.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -9,7 +9,7 @@ from utils.models import BaseModel
|
|||
|
||||
class StoreCategory(BaseModel):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
icon = models.ImageField(upload_to='store_categories/', null=True, blank=True)
|
||||
icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||
order = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -31,8 +31,8 @@ class Store(BaseModel):
|
|||
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
|
||||
name = models.CharField(max_length=150)
|
||||
description = models.TextField(blank=True)
|
||||
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True)
|
||||
cover_image = models.ImageField(upload_to='store_covers/', null=True, blank=True)
|
||||
logo = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||
cover_image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
|
||||
phone_number = models.CharField(max_length=20, blank=True)
|
||||
|
||||
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
|
||||
|
|
@ -43,6 +43,9 @@ class Store(BaseModel):
|
|||
help_text='محلههایی که این فروشگاه به آنها ارسال دارد.',
|
||||
)
|
||||
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)
|
||||
delivery_fee = models.PositiveBigIntegerField(default=0)
|
||||
|
|
@ -77,6 +80,14 @@ class Store(BaseModel):
|
|||
from django.conf import settings as dj_settings
|
||||
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 Weekday(models.IntegerChoices):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from django.contrib.gis.geos import Point
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.media import presigned_media_url
|
||||
from apps.locations.models import City, Neighborhood
|
||||
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
|
||||
|
||||
|
|
@ -8,9 +10,15 @@ from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
|
|||
|
||||
|
||||
class StoreCategorySerializer(serializers.ModelSerializer):
|
||||
icon_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StoreCategory
|
||||
fields = ('uuid', 'name', 'icon', 'order')
|
||||
fields = ('uuid', 'name', 'icon', 'icon_url', 'order')
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_icon_url(self, obj):
|
||||
return presigned_media_url(obj.icon)
|
||||
|
||||
|
||||
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -21,22 +29,42 @@ class StoreWorkingHoursSerializer(serializers.ModelSerializer):
|
|||
|
||||
class StoreListSerializer(serializers.ModelSerializer):
|
||||
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:
|
||||
model = Store
|
||||
fields = (
|
||||
'uuid', 'name', 'category', 'logo', 'cover_image',
|
||||
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
|
||||
'free_delivery_threshold', 'is_open',
|
||||
'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
|
||||
'phone_number', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
|
||||
'free_delivery_threshold', 'is_open', 'distance_km', 'latitude', 'longitude',
|
||||
'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):
|
||||
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(StoreListSerializer.Meta):
|
||||
fields = StoreListSerializer.Meta.fields + (
|
||||
'description', 'address', 'phone_number',
|
||||
'description', 'address',
|
||||
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
|
||||
)
|
||||
|
||||
|
|
@ -84,22 +112,34 @@ class SellerStoreSerializer(serializers.ModelSerializer):
|
|||
source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True),
|
||||
write_only=True, many=True, required=False,
|
||||
)
|
||||
latitude = serializers.FloatField(write_only=True, required=False)
|
||||
longitude = serializers.FloatField(write_only=True, required=False)
|
||||
latitude = serializers.FloatField(required=False)
|
||||
longitude = serializers.FloatField(required=False)
|
||||
bank_account = StoreBankAccountSerializer(read_only=True)
|
||||
logo_url = serializers.SerializerMethodField()
|
||||
cover_image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Store
|
||||
fields = (
|
||||
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'cover_image',
|
||||
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'logo_url',
|
||||
'cover_image', 'cover_image_url',
|
||||
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
|
||||
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
|
||||
'max_delivery_time_minutes',
|
||||
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
|
||||
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery',
|
||||
'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at',
|
||||
)
|
||||
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):
|
||||
lat = validated_data.pop('latitude', None)
|
||||
lng = validated_data.pop('longitude', None)
|
||||
|
|
|
|||
|
|
@ -19,9 +19,26 @@ class SellerStoreTests(OrdersTestCase):
|
|||
'category_uuid': str(self.category.uuid),
|
||||
'city_uuid': str(self.city.uuid),
|
||||
'address': 'خیابان ولیعصر',
|
||||
'latitude': 35.75,
|
||||
'longitude': 51.4,
|
||||
'max_delivery_time_minutes': 40,
|
||||
})
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
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):
|
||||
self.client.force_authenticate(user=self.seller1)
|
||||
|
|
|
|||
88
apps/stores/tests/test_store_list.py
Normal file
88
apps/stores/tests/test_store_list.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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)
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
from django.contrib.gis.db.models.functions import Distance
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import D
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
|
||||
from apps.core.permissions import IsStoreOwner
|
||||
|
||||
from .filters import StoreFilter
|
||||
from .models import Store, StoreCategory, StoreWorkingHours
|
||||
from .serializers import (
|
||||
SellerStoreSerializer,
|
||||
|
|
@ -28,12 +30,21 @@ class StoreCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
|||
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):
|
||||
"""Customer-facing store browsing (home feed, store page)."""
|
||||
|
||||
schema_tags = ['Stores']
|
||||
permission_classes = [AllowAny]
|
||||
queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city')
|
||||
queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city').distinct()
|
||||
filterset_class = StoreFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
|
|
@ -43,18 +54,6 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||
def get_queryset(self):
|
||||
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')
|
||||
lng = self.request.query_params.get('lng')
|
||||
if lat and lng:
|
||||
|
|
@ -63,20 +62,20 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||
distance=Distance('location', point)
|
||||
).order_by('distance')
|
||||
|
||||
return queryset.distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
class SellerStoreView(APIView):
|
||||
"""The authenticated seller's own store — GET/PATCH to manage it, POST to create it."""
|
||||
|
||||
schema_tags = ['Seller · Store']
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = SellerStoreSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"POST": [[]],
|
||||
"PATCH": [[]],
|
||||
}
|
||||
serializer_class = SellerStoreSerializer
|
||||
|
||||
def get(self, request):
|
||||
store = get_object_or_404(Store, owner=request.user)
|
||||
|
|
@ -107,12 +106,14 @@ class SellerStoreWorkingHoursView(APIView):
|
|||
"""Bulk get/set the authenticated seller's weekly working hours (S14)."""
|
||||
|
||||
schema_tags = ['Seller · Store']
|
||||
permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
serializer_class = StoreWorkingHoursSerializer
|
||||
# permission_classes = [IsAuthenticated, IsStoreOwner]
|
||||
# TODO:
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
"PUT": [[]],
|
||||
}
|
||||
serializer_class = StoreWorkingHoursSerializer
|
||||
|
||||
def get(self, request):
|
||||
hours = StoreWorkingHours.objects.filter(store=request.user.store)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
|
@ -28,6 +29,7 @@ INSTALLED_APPS = [
|
|||
'rest_framework_gis',
|
||||
'django_filters',
|
||||
'corsheaders',
|
||||
'django_minio_backend.apps.DjangoMinioBackendConfig',
|
||||
|
||||
# local apps
|
||||
'apps.core',
|
||||
|
|
@ -137,9 +139,50 @@ STATIC_URL = "{}static/".format(API_BASE_PATH)
|
|||
STATIC_ROOT = BASE_DIR / "static"
|
||||
MEDIA_URL = "{}media/".format(API_BASE_PATH)
|
||||
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 = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
"BACKEND": "django_minio_backend.models.MinioBackend",
|
||||
"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": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
|
|
@ -185,6 +228,7 @@ SPECTACULAR_SETTINGS = {
|
|||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"TAGS": [
|
||||
{"name": "Media", "description": "Presigned MinIO upload URLs for product/store images."},
|
||||
{"name": "Locations", "description": "Cities and neighborhoods (public reference data)."},
|
||||
{"name": "Addresses", "description": "The authenticated customer's saved addresses."},
|
||||
{"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ services:
|
|||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- app
|
||||
- monitoring
|
||||
|
|
@ -32,6 +33,20 @@ services:
|
|||
networks:
|
||||
- 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:
|
||||
image: grafana/promtail:2.8.0
|
||||
volumes:
|
||||
|
|
@ -43,6 +58,7 @@ services:
|
|||
|
||||
volumes:
|
||||
pgdata:
|
||||
miniodata:
|
||||
|
||||
networks:
|
||||
app:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ python-dateutil==2.9.0.post0
|
|||
whitenoise==6.9.0
|
||||
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
|
||||
gunicorn==26.0.0
|
||||
gevent==26.7.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
anyio==4.14.2
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
asgiref==3.11.1
|
||||
attrs==25.4.0
|
||||
certifi==2026.7.22
|
||||
|
|
@ -9,6 +11,7 @@ Django==6.0.2
|
|||
django-cors-headers==4.9.0
|
||||
django-environ==0.13.0
|
||||
django-filter==25.2
|
||||
django-minio-backend==4.5.0
|
||||
django-oauth-toolkit==3.2.0
|
||||
django-redis==6.0.0
|
||||
djangorestframework==3.16.1
|
||||
|
|
@ -23,12 +26,14 @@ inflection==0.5.1
|
|||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
jwcrypto==1.5.8
|
||||
minio==7.2.20
|
||||
oauthlib==3.3.1
|
||||
pillow==12.3.0
|
||||
psycopg==3.3.3
|
||||
psycopg-binary==3.3.3
|
||||
psycopg-pool==3.3.1
|
||||
pycparser==3.0
|
||||
pycryptodome==3.23.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-decouple==3.8
|
||||
PyYAML==6.0.3
|
||||
|
|
|
|||
9
utils/clients/minio_client.py
Normal file
9
utils/clients/minio_client.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django.conf import settings
|
||||
from minio import Minio
|
||||
|
||||
minio_client = Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_USE_HTTPS,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue