Compare commits

...
Sign in to create a new pull request.

33 commits

Author SHA1 Message Date
3deb360acb Merge pull request 'feature/selective-checkout' (#10) from feature/selective-checkout into master
Reviewed-on: #10
2026-09-05 08:16:46 -04:00
aff0e77339 Merge pull request 'Restock product inventory when an order is cancelled' (#9) from fix/restock-on-cancel into master
Reviewed-on: #9
2026-09-05 08:16:24 -04:00
ea9474633b Merge pull request 'Add Store.max_delivery_time_minutes, return it wherever store info appears' (#8) from feature/delivery-time into master
Reviewed-on: #8
2026-09-05 08:15:58 -04:00
85fe57d1ca Allow checkout to target selected stores instead of the whole cart
POST /api/v1/checkout/ always converted the customer's entire
multi-store cart into orders. Add an optional store_uuids field to
CheckoutSerializer — when given, checkout() only processes those
stores' cart groups and only clears their items, leaving the rest of
the cart intact for a later checkout. Omitted, behavior is unchanged
(whole cart checks out).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:26:16 +03:30
6431c28e0f Restock product inventory when an order is cancelled
checkout() decrements stock_quantity/increments sold_count per order
item, but cancellation never reversed it, so a cancelled order's stock
stayed permanently reduced. Add _restock_order_items(), called from
_transition() on the CANCELLED transition, using F() expressions to
reverse both counters atomically; wrap _transition() itself in
@transaction.atomic so the status change and restock can't partially
apply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:08:02 +03:30
5117fe9b05 Add Store.max_delivery_time_minutes, return it wherever store info appears
Seller sets this at registration (default 60 min). Exposed on store
list/detail, the seller's own store, cart groups (GET /api/v1/cart/),
and order/order-group responses, so the checkout page's estimated
delivery time no longer needs a frontend mock.

Also document CartSerializer's groups/grand_total for swagger (were
untyped SerializerMethodFields showing as opaque strings) since that's
the endpoint carrying this new field to the checkout page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:15:19 +03:30
dcf63952b5 Merge pull request 'Return store latitude/longitude in every store response' (#7) from feature/lat-long into master
Reviewed-on: #7
2026-08-31 08:51:01 -04:00
f7a65cb902 Return store latitude/longitude in every store response
Add Store.latitude/Store.longitude properties derived from the
location PointField, and expose them read-only on StoreListSerializer
(inherited by StoreDetailSerializer). Also make them readable on
SellerStoreSerializer, which previously only accepted them as
write-only input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 16:14:33 +03:30
1d58c52a0d Merge pull request 'Add missing required_alternate_scopes across OAS-gated views' (#6) from fix/required-scopes into master
Reviewed-on: #6
2026-08-31 07:23:26 -04:00
674ca34041 Add missing required_alternate_scopes across OAS-gated views
Several views using IsAuthenticatedOrTokenMatchesOASRequirements were
missing scope entries for methods they actually expose (GET on
list/retrieve-only viewsets, PUT/PATCH/DELETE on ModelViewSets), and
two had a stale POST entry for a method that doesn't exist
(SellerStoreWorkingHoursView, OrderGroupViewSet). Fixes:
- SellerStoreView, SellerStoreWorkingHoursView (apps/stores/views.py)
- AddressViewSet (apps/locations/views.py)
- SellerReviewViewSet (apps/reviews/views.py)
- SellerProductViewSet (apps/catalog/views.py)
- OrderGroupViewSet, OrderViewSet, SellerOrderViewSet,
  NotificationViewSet (apps/orders/views.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 14:46:02 +03:30
3d10c897af FIX(winofy): fix required scopes
fix required scopes
2026-08-31 14:16:50 +03:30
ce4fe5a2c9 FIX(winofy): fix required scopes
fix required scopes
2026-08-31 14:11:38 +03:30
dfbbbb2b22 Merge pull request 'Split CartItemView so swagger stops showing broken cart endpoints' (#5) from fix/card-urls into master
Reviewed-on: #5
2026-08-31 05:05:12 -04:00
d4254ac086 Split CartItemView so swagger stops showing broken cart endpoints
CartItemView was registered on both /cart/items/ and
/cart/items/{item_uuid}/, so swagger listed POST/PATCH/DELETE on both
paths even though 3 of those 6 combinations raised a TypeError at
runtime (missing/unexpected item_uuid). Split into CartItemView (POST,
list path) and CartItemDetailView (PATCH/DELETE, detail path).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 12:01:38 +03:30
6796922fe6 Merge pull request 'Add search to city/neighborhood endpoints; document filters for swagger' (#4) from feature/filter-stores into master
Reviewed-on: #4
2026-08-23 02:47:04 -04:00
eb5eb38665 Migrate manual query-param filtering to django-filter
django-filter was already installed and set as DEFAULT_FILTER_BACKENDS
but unused everywhere. Replace hand-rolled get_queryset filtering with
FilterSet classes (stores/catalog/locations/reviews) and
filterset_fields (orders status). drf-spectacular auto-documents these
for swagger, so the manual OpenApiParameter declarations for the
migrated fields are removed (stores keeps lat/lng manual since
geo-distance isn't a plain filter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:31:01 +03:30
755d74a9ff Return phone_number and distance_km on store list
phone_number was only exposed on the store detail serializer; add it
to the list too. Also surface the distance annotation (already
computed when lat/lng are passed) as distance_km on both, instead of
using it only for ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:04:21 +03:30
fa9a69fff3 Add search to city/neighborhood endpoints; document filters for swagger
Neighborhood filtering was UUID-only; add name-based search on both
GET /api/v1/cities/ and GET /api/v1/neighborhoods/ (the latter also
matching by city name), and document all params for drf-spectacular.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:25:45 +03:30
2b3fd0fc28 Merge pull request 'Document store list query params for swagger; add filter test coverage' (#3) from feature/filter-stores into master
Reviewed-on: #3
2026-08-19 07:05:21 -04:00
661129d0a1 Document product list query params for swagger; add filter test coverage
Same gap as the stores endpoint: store/category/search filters on
GET /api/v1/products/ already worked but weren't declared to
drf-spectacular.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:34:15 +03:30
4073081c25 Document store list query params for swagger; add filter test coverage
Category/neighborhood/search/lat/lng filters on GET /api/v1/stores/
already worked but weren't declared to drf-spectacular, so they never
showed up in swagger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:19:48 +03:30
4aacaabdeb Merge pull request 'Seed demo stores/products with a placeholder image key; doc cleanup' (#2) from feature/minio-integration into master
Reviewed-on: #2
2026-08-18 09:34:03 -04:00
e85b4f19ad merge master 2026-08-18 17:02:18 +03:30
56c07c3678 Seed demo stores/products with a placeholder image key; doc cleanup
- seed_demo_data: every seeded Store.logo/cover_image and Product.image
  now points at a shared placeholder MinIO object_key
  (uploads/c1508b9e-c01f-4892-b24c-c1a949b5b5f5.png) instead of null, so
  demo data exercises the image_url/logo_url/etc. fields end-to-end.
  Note: <field>_url will 404 until that object is actually uploaded to
  the bucket.
- .env.example: drop the redundant MINIO_EXTERNAL_ENDPOINT* lines —
  they already default to MINIO_ENDPOINT/MINIO_USE_HTTPS, and aren't
  read anywhere in this app's actual presign/read path.
- Add MEDIA_INTEGRATION.md: reference doc for the MinIO/presigned-media
  work on this branch (architecture, settings, API shape, existing-data
  caveats, what was verified).
2026-08-18 16:51:19 +03:30
b162a9f31c FIX(winofy): fix MINIO_BUCKET_NAME
fix MINIO_BUCKET_NAME
2026-08-18 13:58:54 +03:30
7ed94df6b3 Merge pull request 'feature/minio-integration' (#1) from feature/minio-integration into master
Reviewed-on: #1
2026-08-18 03:38:48 -04:00
3748da1af7 Add explicit schema types for presigned media URL fields
Swagger inferred these SerializerMethodFields as untyped strings and
warned on generation; annotate each with @extend_schema_field so the
generated OpenAPI schema declares them as nullable URLs.
2026-08-18 10:48:36 +03:30
85598d347a Switch media uploads to presigned MinIO, matching the rest of Winsoo
The prior commit wired MinIO as Django's default storage backend but
kept plain ImageField multipart uploads through the Django backend and
public-bucket direct URLs — not how campaign/advertising/promotions do
it. Rework to match: image fields (ProductCategory.icon, Product.image,
StoreCategory.icon, Store.logo, Store.cover_image) now store an opaque
MinIO object_key (CharField) instead of a Django-managed file.

- utils/clients/minio_client.py — raw Minio SDK client, same shape as
  the other services.
- apps/core/views/media.py — POST /api/media/presign/ mints a 30-minute
  presigned PUT URL + object_key; the client uploads bytes directly to
  MinIO, then submits the object_key on the owning resource.
- apps/core/media.py — presigned_media_url() helper; every serializer
  with an image field now also exposes a `<field>_url` computed from a
  fresh 1-hour presigned GET, rather than trusting a stored/direct URL.
- FRONTEND_GUIDE.md updated to document the new upload flow and field
  shapes (object_key vs `_url`), replacing the stale "no upload
  endpoint yet" note.

Verified against a live local MinIO container: presign → direct PUT
upload → object_key stored on the model → serializer mints a working
presigned GET URL → URL fetches the uploaded bytes. Also exercised the
actual DRF view (POST /api/media/presign/) end-to-end, including the
401 on an unauthenticated request.
2026-08-18 10:42:44 +03:30
c4f15d9c80 Integrate MinIO for media storage
Swaps default media storage from local FileSystemStorage to MinIO via
django-minio-backend, matching the rest of the Winsoo ecosystem
(advertising, campaign, wallet). Points at a local MinIO container
(added to docker-compose.yml) by default since no shared bucket is
provisioned yet. Existing ImageFields pick up the new storage backend
automatically — no model changes needed.

Verified against a live local MinIO container: bucket auto-creates on
first save, object lands under the expected key, URL resolves.
2026-08-17 18:01:29 +03:30
c9b617483d FIX(winofy): add gooyal auth
add gooyal auth
2026-08-15 10:15:28 +03:30
63a116c2b2 Merge branch 'master' of https://git.addwin.ir/addwin/winofy-backend 2026-08-15 09:45:59 +03:30
1409a9cddf FIX(winofy): add gooyal auth
add gooyal auth
2026-08-15 09:44:49 +03:30
d2cc4c2ec6 FIX(winofy): add gooyal auth
add gooyal auth
2026-08-15 09:43:47 +03:30
42 changed files with 1051 additions and 106 deletions

View file

@ -1,12 +1,11 @@
SECRET_KEY=change-me SECRET_KEY=change-me
DEBUG=True 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_NAME=winofy_dev
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432
REDIS_URL=redis://localhost:6379/1 REDIS_URL=redis://localhost:6379/1
@ -15,6 +14,20 @@ CSRF_TRUSTED_ORIGINS=
NEARBY_RADIUS_METERS=5000 NEARBY_RADIUS_METERS=5000
DEFAULT_COMMISSION_PERCENT=4.0 DEFAULT_COMMISSION_PERCENT=4.0
# MinIO — object storage for media (product/store images), same pattern as the
# rest of the Winsoo ecosystem. Defaults point at the local `minio` container
# from docker-compose.yml. No bucket has been provisioned centrally yet —
# django-minio-backend will create MINIO_MEDIA_FILES_BUCKET on first save.
# MINIO_ENDPOINT is used both for Django's own connection to MinIO and for
# building the presigned URLs returned to clients — leave MINIO_EXTERNAL_ENDPOINT
# unset unless Django reaches MinIO through a different address than clients do
# (e.g. an internal Docker/VPC hostname vs. a public domain).
MINIO_ENDPOINT=localhost:9000
MINIO_USE_HTTPS=False
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_NAME=winofy-media
# OAuth2 provider (Gooyal accounts) — resource-server introspection + this # OAuth2 provider (Gooyal accounts) — resource-server introspection + this
# service's own client-credentials grant for outbound calls to Gooyal services. # service's own client-credentials grant for outbound calls to Gooyal services.
OAUTH2_PROVIDER_BASE_PUBLIC_URL= OAUTH2_PROVIDER_BASE_PUBLIC_URL=

24
CHANGELOG.md Normal file
View 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).

View file

@ -44,6 +44,7 @@ Key `.env` variables (see `.env.example`):
- `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists - `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists
- `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below) - `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below)
- `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`) - `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`)
- `MINIO_*` — MinIO object storage for media (`STORAGES['default']`), same pattern as the rest of the Winsoo ecosystem; defaults target the local `minio` container in `docker-compose.yml`
## Authentication — Gooyal accounts, resource-server pattern ## Authentication — Gooyal accounts, resource-server pattern

View file

@ -1,10 +1,10 @@
# Winofy API — Frontend Integration Guide # Winofy API — Frontend Integration Guide
This document is written for whoever (human or AI assistant) is building the two Winofy clients — **اپ مشتری** (customer app) and **پنل فروشنده** (seller panel) — against this backend. It covers auth, conventions, every endpoint, and the end-to-end flows that tie them together. Read §3 (Authentication) and §4 (Conventions) first — everything else assumes them. 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/`. 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 ## 1. Base URL
@ -97,7 +97,7 @@ Default page size is 50 if `limit` is omitted.
- Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`. - Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`.
- All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string. - All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string.
- All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...). - All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...).
- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set. - Image fields (`image`, `logo`, `cover_image`, `icon`) hold an opaque MinIO **object key** (or `null`), not a URL — read the sibling `<field>_url` (e.g. `logo_url`) for a fetchable, presigned, time-limited URL. See §4.7.
### 4.6 Enums ### 4.6 Enums
@ -115,6 +115,18 @@ Default page size is 50 if `limit` is omitted.
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز | | `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش | | `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3. `SellerWithdrawalRequest`/`WalletTransactionRef` only exist on `feature/payment`.
### 4.7 Image uploads (presigned MinIO)
Every image field (`ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image`) is a **MinIO object key**, not a Django-served file. Uploading is a two-step flow — the backend never receives the file bytes:
1. `POST /api/media/presign/` (auth required) with `{"filename": "photo.jpg"}` → `201 {"upload_url": "...", "object_key": "uploads/<uuid>.jpg"}`. `upload_url` is a presigned MinIO `PUT` URL valid for 30 minutes.
2. Upload the raw file bytes directly to `upload_url` (a plain `PUT`, no auth header, no JSON body — just the file as the request body).
3. Send the `object_key` from step 1 as the field's value in the resource's create/update body (e.g. `{"logo": "uploads/<uuid>.jpg", ...}` on `PATCH /api/v1/seller/store/`).
On read, every serializer that has an image field also returns a `<field>_url` sibling (`icon_url`, `image_url`, `logo_url`, `cover_image_url`) — a freshly presigned `GET` URL valid for 1 hour. Always re-fetch the resource (or re-request) rather than caching these URLs past an hour; they expire.
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3. `Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3.
## 5. Customer app — endpoint reference ## 5. Customer app — endpoint reference
@ -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/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. |
| GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). | | GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). |
Store list item shape: `uuid, name, category{uuid,name,icon,order}, logo, cover_image, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. Store list item shape: `uuid, name, category{uuid,name,icon,icon_url,order}, logo, logo_url, cover_image, cover_image_url, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`. See §4.7 for `icon`/`logo`/`cover_image` vs. their `_url` siblings.
### Catalog (`Catalog` tag) ### Catalog (`Catalog` tag)
@ -230,7 +242,7 @@ Response = an `OrderGroup` object (§5, Orders below):
"created_at": "..." "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. - `wallet`: `payment` = `{"method": "wallet", "status": "paid"}` — synchronous, done.
- `online`: `payment` = `{"method": "online", "status": "pending", "payment_url": "..."}` — **redirect/open a webview at `payment_url`**; the gateway calls Winofy back and payment_status updates asynchronously (poll `GET /api/v1/order-groups/{uuid}/` or `GET /api/v1/orders/{uuid}/` to see it flip to `paid`). - `online`: `payment` = `{"method": "online", "status": "pending", "payment_url": "..."}` — **redirect/open a webview at `payment_url`**; the gateway calls Winofy back and payment_status updates asynchronously (poll `GET /api/v1/order-groups/{uuid}/` or `GET /api/v1/orders/{uuid}/` to see it flip to `paid`).
- `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery. - `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery.
@ -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`). 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) ### 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 | | `payment_method` | What happens | Client follow-up |
|---|---|---| |---|---|---|
@ -363,7 +375,7 @@ Store create/update body:
"is_open": true "is_open": true
} }
``` ```
`rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). `logo`/`cover_image` aren't settable through this JSON body — that needs a multipart upload endpoint, which doesn't exist yet; flag this to backend if the store-branding screen needs it. `rating_avg`, `rating_count`, `status` are read-only (server-computed; `status` starts `pending` — there's no admin-approval endpoint in this API yet, that's a manual/admin-panel step today). To set `logo`/`cover_image`, run the presign flow (§4.7) first and include the resulting `object_key`s in this JSON body — e.g. add `"logo": "uploads/<uuid>.png"`.
### Seller · Products ### Seller · Products
@ -374,7 +386,7 @@ Store create/update body:
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | | | GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
| PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. | | PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. |
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. Response adds `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`). Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. `image` is a MinIO object key from the presign flow (§4.7), not a file upload. Response adds `image_url` (presigned, §4.7), `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
### Seller · Orders ### Seller · Orders
@ -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. 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 | | Method | Path | Notes |
|---|---|---| |---|---|---|
@ -425,19 +437,57 @@ Response:
| GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. | | GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. |
| POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. | | POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. |
### Payments webhook (`feature/payment` only — not on `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. `GET`/`POST /api/v1/payments/ipg/callback/` is hit by the `ipg` payment gateway service itself after a customer completes an online payment, not by the frontend. Don't call it directly; it's documented here only so you know it exists and isn't a client-facing endpoint.
## 8. Local development / testing ## 8. Local development / testing
- The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures. - The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures.
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token. - Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Media, Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
- `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check. - `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check.
## 9. Known gaps to flag back to backend if you hit them ## 9. Known gaps to flag back to backend if you hit them
- No multipart/image-upload endpoint yet for store logo/cover or product images (JSON `PATCH` bodies can't set binary fields).
- No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin. - No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin.
- No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1). - No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1).
- The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check. - The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check.
## 10. Quick reference — screen → endpoint
The same information as §5/§7, but organized by Figma screen code instead of by resource, for wiring up one screen at a time. "Auth" endpoints need a bearer token (§3); "Public" ones don't.
### اپ مشتری (Customer app)
| Screen | Endpoint(s) |
|---|---|
| C01/C02 — Login, OTP | *None* — Gooyal accounts OAuth2 handles this entirely; see §3. |
| C03 / guest-home — Home feed | `GET /api/v1/cities/`, `GET /api/v1/neighborhoods/?city=`, `GET /api/v1/store-categories/`, `GET /api/v1/stores/?neighborhood=&category=&search=&lat=&lng=` |
| Address list / map picker / address form | `GET/POST /api/v1/addresses/`, `GET/PUT/PATCH/DELETE /api/v1/addresses/{uuid}/` |
| C04 — Store page | `GET /api/v1/stores/{uuid}/`, `GET /api/v1/product-categories/`, `GET /api/v1/products/?store={uuid}&category=` |
| C05 — Product detail | `GET /api/v1/products/{uuid}/` |
| C06 — Search | `GET /api/v1/products/?search=`, `GET /api/v1/stores/?search=` |
| C07 — Cart | `GET/DELETE /api/v1/cart/`, `POST /api/v1/cart/items/`, `PATCH/DELETE /api/v1/cart/items/{item_uuid}/` |
| C08 — Checkout | `POST /api/v1/checkout/` (§6.2, §6.4) |
| C09 — Order tracking / history | `GET /api/v1/order-groups/` (history), `GET /api/v1/order-groups/{uuid}/`, `GET /api/v1/orders/{uuid}/`, `POST /api/v1/orders/{uuid}/cancel/` |
| C10 — Post-delivery / reviews | `POST /api/v1/reviews/` (after `delivered`), `GET /api/v1/reviews/?store=` (store page's review list) |
| Notifications (bell icon) | `GET /api/v1/notifications/`, `POST /api/v1/notifications/{uuid}/mark-read/`, `POST /api/v1/notifications/mark-all-read/` |
### پنل فروشنده (Seller panel)
| Screen | Endpoint(s) |
|---|---|
| S01/S02 — Seller login, OTP | *None* — Gooyal accounts OAuth2; see §3. |
| S04 — Create store | `POST /api/v1/seller/store/` |
| S05 — Dashboard | `GET /api/v1/seller/store/`, `GET /api/v1/seller/analytics/?period=today`, `GET /api/v1/seller/orders/?status=placed` |
| S06 — Product management (list) | `GET /api/v1/seller/products/` |
| S07 — Add product | `POST /api/media/presign/` (§4.7) → upload the file → `POST /api/v1/seller/products/` with `image` = the returned `object_key` |
| S08 — Inventory | `GET /api/v1/seller/products/`, `PATCH /api/v1/seller/products/{uuid}/stock/` |
| S09 — Order management (status tabs) | `GET /api/v1/seller/orders/?status=` |
| S10 — Order detail + stepper | `GET /api/v1/seller/orders/{uuid}/`, then `POST .../confirm/`, `.../mark-ready/`, `.../mark-shipped/`, `.../mark-delivered/`, `.../cancel/` |
| S11/S12 — Wallet, withdrawal | **Not on `master`** — `feature/payment` only (§7 Seller · Wallet). |
| S14 — Store settings | `GET/PATCH /api/v1/seller/store/` (logo/cover uploads use the same presign flow as S07), `GET/PUT /api/v1/seller/store/working-hours/` |
| S15 — Seller profile | `GET /api/v1/seller/store/`, `GET /api/v1/seller/reviews/` (rating summary) |
| S16 — Notifications | Same shared endpoints as the customer app, above. |
| S17 — Analytics | `GET /api/v1/seller/analytics/?period=today\|week\|month` |
| Reviews & replies | `GET /api/v1/seller/reviews/`, `POST /api/v1/seller/reviews/{uuid}/reply/` |

138
MEDIA_INTEGRATION.md Normal file
View file

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

17
apps/catalog/filters.py Normal file
View 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))

View file

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

View file

@ -9,7 +9,7 @@ class ProductCategory(BaseModel):
parent = models.ForeignKey( parent = models.ForeignKey(
'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True, 'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True,
) )
icon = models.ImageField(upload_to='product_categories/', null=True, blank=True) icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
order = models.PositiveSmallIntegerField(default=0) order = models.PositiveSmallIntegerField(default=0)
class Meta: class Meta:
@ -31,7 +31,7 @@ class Product(BaseModel):
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products') category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
description = models.TextField(blank=True) description = models.TextField(blank=True)
image = models.ImageField(upload_to='products/', null=True, blank=True) image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
price = models.PositiveBigIntegerField() price = models.PositiveBigIntegerField()
unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE) unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE)

View file

@ -1,26 +1,39 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from apps.core.media import presigned_media_url
from apps.stores.serializers import StoreListSerializer from apps.stores.serializers import StoreListSerializer
from .models import Product, ProductCategory from .models import Product, ProductCategory
class ProductCategorySerializer(serializers.ModelSerializer): class ProductCategorySerializer(serializers.ModelSerializer):
icon_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = ProductCategory model = ProductCategory
fields = ('uuid', 'name', 'parent', 'icon', '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): class ProductListSerializer(serializers.ModelSerializer):
is_out_of_stock = serializers.BooleanField(read_only=True) is_out_of_stock = serializers.BooleanField(read_only=True)
image_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Product model = Product
fields = ( fields = (
'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value', 'uuid', 'name', 'image', 'image_url', 'price', 'unit_type', 'unit_value',
'is_active', 'is_out_of_stock', 'is_active', 'is_out_of_stock',
) )
@extend_schema_field(serializers.URLField(allow_null=True))
def get_image_url(self, obj):
return presigned_media_url(obj.image)
class ProductDetailSerializer(ProductListSerializer): class ProductDetailSerializer(ProductListSerializer):
category = ProductCategorySerializer(read_only=True) category = ProductCategorySerializer(read_only=True)
@ -39,16 +52,21 @@ class SellerProductSerializer(serializers.ModelSerializer):
) )
is_out_of_stock = serializers.BooleanField(read_only=True) is_out_of_stock = serializers.BooleanField(read_only=True)
is_low_stock = serializers.BooleanField(read_only=True) is_low_stock = serializers.BooleanField(read_only=True)
image_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Product model = Product
fields = ( fields = (
'uuid', 'name', 'description', 'image', 'category', 'category_uuid', 'uuid', 'name', 'description', 'image', 'image_url', 'category', 'category_uuid',
'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold', 'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold',
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at', 'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
) )
read_only_fields = ('sold_count',) read_only_fields = ('sold_count',)
@extend_schema_field(serializers.URLField(allow_null=True))
def get_image_url(self, obj):
return presigned_media_url(obj.image)
def create(self, validated_data): def create(self, validated_data):
validated_data['store'] = self.context['request'].user.store validated_data['store'] = self.context['request'].user.store
return super().create(validated_data) return super().create(validated_data)

View file

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

View file

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

View file

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

21
apps/core/media.py Normal file
View 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
View 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()

View file

@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from .views import HealthCheckView from .views import HealthCheckView, MediaPresignView
app_name = "core" app_name = "core"
urlpatterns = [ urlpatterns = [
path("health/", HealthCheckView.as_view(), name="health"), path("health/", HealthCheckView.as_view(), name="health"),
path("media/presign/", MediaPresignView.as_view(), name="media-presign"),
] ]

View file

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

51
apps/core/views/media.py Normal file
View 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
View 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))

View 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)})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

11
apps/reviews/filters.py Normal file
View 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']

View file

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

19
apps/stores/filters.py Normal file
View 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))

View file

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

View file

@ -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شود.'),
),
]

View file

@ -9,7 +9,7 @@ from utils.models import BaseModel
class StoreCategory(BaseModel): class StoreCategory(BaseModel):
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)
icon = models.ImageField(upload_to='store_categories/', null=True, blank=True) icon = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
order = models.PositiveSmallIntegerField(default=0) order = models.PositiveSmallIntegerField(default=0)
class Meta: class Meta:
@ -31,8 +31,8 @@ class Store(BaseModel):
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores') category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
name = models.CharField(max_length=150) name = models.CharField(max_length=150)
description = models.TextField(blank=True) description = models.TextField(blank=True)
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True) logo = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
cover_image = models.ImageField(upload_to='store_covers/', null=True, blank=True) cover_image = models.CharField(max_length=500, null=True, blank=True, help_text='MinIO object key.')
phone_number = models.CharField(max_length=20, blank=True) phone_number = models.CharField(max_length=20, blank=True)
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores') city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
@ -43,6 +43,9 @@ class Store(BaseModel):
help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.', help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.',
) )
delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5) delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5)
max_delivery_time_minutes = models.PositiveSmallIntegerField(
default=60, help_text='حداکثر زمان ارسال (دقیقه)، هنگام ثبت فروشگاه توسط فروشنده مشخص می‌شود.',
)
min_order_amount = models.PositiveBigIntegerField(default=0) min_order_amount = models.PositiveBigIntegerField(default=0)
delivery_fee = models.PositiveBigIntegerField(default=0) delivery_fee = models.PositiveBigIntegerField(default=0)
@ -77,6 +80,14 @@ class Store(BaseModel):
from django.conf import settings as dj_settings from django.conf import settings as dj_settings
return dj_settings.DEFAULT_COMMISSION_PERCENT return dj_settings.DEFAULT_COMMISSION_PERCENT
@property
def latitude(self):
return self.location.y if self.location else None
@property
def longitude(self):
return self.location.x if self.location else None
class StoreWorkingHours(BaseModel): class StoreWorkingHours(BaseModel):
class Weekday(models.IntegerChoices): class Weekday(models.IntegerChoices):

View file

@ -1,6 +1,8 @@
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from apps.core.media import presigned_media_url
from apps.locations.models import City, Neighborhood from apps.locations.models import City, Neighborhood
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
@ -8,9 +10,15 @@ from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
class StoreCategorySerializer(serializers.ModelSerializer): class StoreCategorySerializer(serializers.ModelSerializer):
icon_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = StoreCategory model = StoreCategory
fields = ('uuid', 'name', 'icon', 'order') fields = ('uuid', 'name', 'icon', 'icon_url', 'order')
@extend_schema_field(serializers.URLField(allow_null=True))
def get_icon_url(self, obj):
return presigned_media_url(obj.icon)
class StoreWorkingHoursSerializer(serializers.ModelSerializer): class StoreWorkingHoursSerializer(serializers.ModelSerializer):
@ -21,22 +29,42 @@ class StoreWorkingHoursSerializer(serializers.ModelSerializer):
class StoreListSerializer(serializers.ModelSerializer): class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True) category = StoreCategorySerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
distance_km = serializers.SerializerMethodField()
latitude = serializers.FloatField(read_only=True)
longitude = serializers.FloatField(read_only=True)
class Meta: class Meta:
model = Store model = Store
fields = ( fields = (
'uuid', 'name', 'category', 'logo', 'cover_image', 'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee', 'phone_number', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open', '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): class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True) working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta): class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + ( fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'phone_number', 'description', 'address',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours', '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), source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True),
write_only=True, many=True, required=False, write_only=True, many=True, required=False,
) )
latitude = serializers.FloatField(write_only=True, required=False) latitude = serializers.FloatField(required=False)
longitude = serializers.FloatField(write_only=True, required=False) longitude = serializers.FloatField(required=False)
bank_account = StoreBankAccountSerializer(read_only=True) bank_account = StoreBankAccountSerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = Store model = Store
fields = ( fields = (
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'cover_image', 'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'logo_url',
'cover_image', 'cover_image_url',
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude', 'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km', 'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
'max_delivery_time_minutes',
'min_order_amount', 'delivery_fee', 'free_delivery_threshold', 'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery',
'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at', 'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at',
) )
read_only_fields = ('rating_avg', 'rating_count', 'status') read_only_fields = ('rating_avg', 'rating_count', 'status')
@extend_schema_field(serializers.URLField(allow_null=True))
def get_logo_url(self, obj):
return presigned_media_url(obj.logo)
@extend_schema_field(serializers.URLField(allow_null=True))
def get_cover_image_url(self, obj):
return presigned_media_url(obj.cover_image)
def _pop_location(self, validated_data): def _pop_location(self, validated_data):
lat = validated_data.pop('latitude', None) lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None) lng = validated_data.pop('longitude', None)

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
from datetime import timedelta
from pathlib import Path from pathlib import Path
import environ import environ
@ -28,6 +29,7 @@ INSTALLED_APPS = [
'rest_framework_gis', 'rest_framework_gis',
'django_filters', 'django_filters',
'corsheaders', 'corsheaders',
'django_minio_backend.apps.DjangoMinioBackendConfig',
# local apps # local apps
'apps.core', 'apps.core',
@ -137,9 +139,50 @@ STATIC_URL = "{}static/".format(API_BASE_PATH)
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = BASE_DIR / "static"
MEDIA_URL = "{}media/".format(API_BASE_PATH) MEDIA_URL = "{}media/".format(API_BASE_PATH)
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
# MinIO — same object-storage pattern as the rest of the Winsoo ecosystem
# (campaign, wallet, advertising, ...). Points at a local MinIO instance by
# default; no bucket has been provisioned centrally yet, so MINIO_BUCKET_NAME
# just needs to be set to whatever this service's bucket ends up being called.
MINIO_ENDPOINT = env("MINIO_ENDPOINT", default="localhost:9000")
MINIO_USE_HTTPS = env.bool("MINIO_USE_HTTPS", default=False)
MINIO_EXTERNAL_ENDPOINT = env("MINIO_EXTERNAL_ENDPOINT", default=MINIO_ENDPOINT)
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = env.bool("MINIO_EXTERNAL_ENDPOINT_USE_HTTPS", default=MINIO_USE_HTTPS)
MINIO_REGION = None
MINIO_ACCESS_KEY = env("MINIO_ACCESS_KEY", default="minioadmin")
MINIO_SECRET_KEY = env("MINIO_SECRET_KEY", default="minioadmin")
MINIO_URL_EXPIRY_HOURS = timedelta(days=1)
MINIO_CONSISTENCY_CHECK_ON_START = False
MINIO_BUCKET_NAME = env("MINIO_BUCKET_NAME", default="winofy-media") # replacement for MEDIA_ROOT
MINIO_PRIVATE_BUCKETS = [
'default',
]
MINIO_PUBLIC_BUCKETS = [
'default-public',
MINIO_BUCKET_NAME,
]
MINIO_POLICY_HOOKS = []
MINIO_BUCKET_CHECK_ON_SAVE = True # creates the bucket if missing, then saves
STORAGES = { STORAGES = {
"default": { "default": {
"BACKEND": "django.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": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
@ -185,6 +228,7 @@ SPECTACULAR_SETTINGS = {
"VERSION": "1.0.0", "VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False, "SERVE_INCLUDE_SCHEMA": False,
"TAGS": [ "TAGS": [
{"name": "Media", "description": "Presigned MinIO upload URLs for product/store images."},
{"name": "Locations", "description": "Cities and neighborhoods (public reference data)."}, {"name": "Locations", "description": "Cities and neighborhoods (public reference data)."},
{"name": "Addresses", "description": "The authenticated customer's saved addresses."}, {"name": "Addresses", "description": "The authenticated customer's saved addresses."},
{"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."}, {"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."},

View file

@ -12,6 +12,7 @@ services:
depends_on: depends_on:
- db - db
- redis - redis
- minio
networks: networks:
- app - app
- monitoring - monitoring
@ -32,6 +33,20 @@ services:
networks: networks:
- app - app
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
networks:
- app
promtail: promtail:
image: grafana/promtail:2.8.0 image: grafana/promtail:2.8.0
volumes: volumes:
@ -43,6 +58,7 @@ services:
volumes: volumes:
pgdata: pgdata:
miniodata:
networks: networks:
app: app:

View file

@ -41,6 +41,10 @@ python-dateutil==2.9.0.post0
whitenoise==6.9.0 whitenoise==6.9.0
Pillow==12.3.0 # ImageField support (product/store images) Pillow==12.3.0 # ImageField support (product/store images)
# Media storage — MinIO, same pattern as the rest of the Winsoo ecosystem
django-minio-backend==4.5.0
minio==7.2.20
# App server # App server
gunicorn==26.0.0 gunicorn==26.0.0
gevent==26.7.0 gevent==26.7.0

View file

@ -1,4 +1,6 @@
anyio==4.14.2 anyio==4.14.2
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
asgiref==3.11.1 asgiref==3.11.1
attrs==25.4.0 attrs==25.4.0
certifi==2026.7.22 certifi==2026.7.22
@ -9,6 +11,7 @@ Django==6.0.2
django-cors-headers==4.9.0 django-cors-headers==4.9.0
django-environ==0.13.0 django-environ==0.13.0
django-filter==25.2 django-filter==25.2
django-minio-backend==4.5.0
django-oauth-toolkit==3.2.0 django-oauth-toolkit==3.2.0
django-redis==6.0.0 django-redis==6.0.0
djangorestframework==3.16.1 djangorestframework==3.16.1
@ -23,12 +26,14 @@ inflection==0.5.1
jsonschema==4.26.0 jsonschema==4.26.0
jsonschema-specifications==2025.9.1 jsonschema-specifications==2025.9.1
jwcrypto==1.5.8 jwcrypto==1.5.8
minio==7.2.20
oauthlib==3.3.1 oauthlib==3.3.1
pillow==12.3.0 pillow==12.3.0
psycopg==3.3.3 psycopg==3.3.3
psycopg-binary==3.3.3 psycopg-binary==3.3.3
psycopg-pool==3.3.1 psycopg-pool==3.3.1
pycparser==3.0 pycparser==3.0
pycryptodome==3.23.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-decouple==3.8 python-decouple==3.8
PyYAML==6.0.3 PyYAML==6.0.3

View file

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