Initial Winofy backend: marketplace core (no payment integration)

Django 6 + DRF resource server against the Gooyal accounts OAuth2 service,
matching the Winsoo ecosystem's conventions. Covers locations, stores,
catalog, cart, checkout/orders (with the multi-store-cart split and the
status stepper), reviews, and notifications, plus a demo-data seed command.

Payment integration (wallet debits, online gateway, seller payouts) is
intentionally left out here — see feature/payment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ali Asadi 2026-08-10 14:03:15 +03:30
commit 52adc732fa
153 changed files with 9333 additions and 0 deletions

28
.env.example Normal file
View file

@ -0,0 +1,28 @@
SECRET_KEY=change-me
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
DB_NAME=winofy_dev
DB_USER=
DB_PASSWORD=
DB_HOST=localhost
DB_PORT=5432
REDIS_URL=redis://localhost:6379/1
CORS_ALLOWED_ORIGINS=
CSRF_TRUSTED_ORIGINS=
NEARBY_RADIUS_METERS=5000
DEFAULT_COMMISSION_PERCENT=4.0
# OAuth2 provider (Gooyal accounts) — resource-server introspection + this
# service's own client-credentials grant for outbound calls to Gooyal services.
OAUTH2_PROVIDER_BASE_PUBLIC_URL=
OAUTH2_PROVIDER_BASE_PRIVATE_URL=
OAUTH2_PROVIDER_CLIENT_ID=
OAUTH2_PROVIDER_CLIENT_SECRET=
OAUTH2_PROVIDER_SCOPES=
# macOS only — not needed on Linux where GDAL is on the system path
# GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
# GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.env
db.sqlite3
/static/
/media/
/log/
/logs/
.DS_Store
*.log

66
CLAUDE.md Normal file
View file

@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
Django 6 + Django REST Framework on Python 3.13, with GeoDjango (PostGIS) for location/coverage data. Part of the **Winsoo** ecosystem (`~/Projects/Winsoo/`) — same conventions as `campaign`, `wallet`, `advertising`, `promotions`, `settlement`.
Winofy is a hyperlocal multi-vendor marketplace serving two clients: the customer app (اپ مشتری) and the seller panel (پنل فروشنده).
## Local setup
```bash
python3.13 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then set SECRET_KEY and DB_USER (postgres role with CREATEDB/superuser or ownership of the DB below)
createdb winofy_dev && psql -d winofy_dev -c "CREATE EXTENSION postgis;"
python manage.py migrate
python manage.py runserver
```
On macOS, uncomment `GDAL_LIBRARY_PATH` / `GEOS_LIBRARY_PATH` in `.env` (Homebrew paths) — GDAL isn't on the default library search path there.
## Common commands
```bash
python manage.py runserver
python manage.py startapp <name> # then move it under apps/ and fix apps.py's `name`
python manage.py makemigrations
python manage.py migrate
python manage.py test
python manage.py shell
```
## Settings
Single file: `config/settings.py`. Config comes from `.env` via `django-environ` (`env(...)`) with a few OAuth2 vars still read through `python-decouple`'s `config()` — matching the mixed style already in use across the ecosystem. `LOGGING` is built in `config/other_settings/logging.py` and imported at the very **bottom** of `config/settings.py` (it reads `BASE_DIR` back off `django.conf.settings`, which only works once `BASE_DIR` has already been assigned earlier in the same module — don't move that import up).
Key `.env` variables (see `.env.example`):
- `SECRET_KEY` — required
- `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_HOST` / `DB_PORT` — Postgres+PostGIS connection
- `REDIS_URL` — used for caching outbound OAuth2 client-credentials tokens
- `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` — comma-separated lists
- `OAUTH2_PROVIDER_*` — Gooyal accounts OAuth2 resource-server + this app's own client credentials (see below)
- `DEFAULT_COMMISSION_PERCENT` — platform commission on delivered orders (overridable per `Store`)
## Authentication — Gooyal accounts, resource-server pattern
This service does **not** implement login/OTP/password endpoints. Every Winsoo service is an OAuth2 *resource server*: client apps authenticate directly against the central Gooyal accounts service's `/oauth2/token/` and pass the resulting bearer token to every microservice, including this one. `apps/gooyal_oauth2` (copied verbatim from the ecosystem) introspects incoming tokens against Gooyal's `/introspect/` endpoint and lazily creates a local shadow `apps.users.User` row (UUID PK = the Gooyal user's UUID) — see `apps/gooyal_oauth2/validators.py::OAuth2Validator`.
For calls this service makes *outward* to other Gooyal services (currently just accounts), it authenticates itself via its own `client_credentials` grant, cached in Redis — see `utils/accounts_client.py`.
> **No payment integration on this branch.** Wallet debits, online-gateway charges, seller payouts/withdrawals, and the `apps.payments` app all live on `feature/payment`, not here. `Order.commission_amount`/`seller_payout_amount` are still computed at checkout time (pure numbers, no external call), but nothing actually moves money yet — `OrderGroup.payment_status` just stays `pending` regardless of the chosen `payment_method`. See `feature/payment` for `utils/wallet_client.py`, `utils/ipg_client.py`, and the vendored `utils/clients/gooyal_wallet_client/` SDK.
## App layout
Domain apps live under `apps/`; `apps.py`'s `name` must be the dotted path including the `apps.` prefix (e.g. `apps.stores`) to match `INSTALLED_APPS`. Each app follows: `models.py`, `admin.py`, `serializers.py`, `views.py` (`GenericViewSet` + explicit actions, not `ModelViewSet`), `urls.py` (`DefaultRouter`), `tests/`. All domain models inherit `utils.models.BaseModel` (UUID PK + `created_at`/`updated_at`).
`apps/orders` is intentionally the biggest app — `Cart`/`CartItem` and `Notification` live there too rather than in their own apps. Cart is just pre-order state (it's cleared into `Order`s by `services.checkout()`), and every `Notification.type` this app fires (`new_order`, `order_cancelled`, `settlement_done`, `new_review`) is order-triggered, so both would've been single-model apps whose only real dependency was `orders` anyway. `reviews` stays separate since it touches `orders`/`stores`/`catalog` about equally and doesn't belong to any one of them. `payments` (wallet + ipg integration) lives on `feature/payment` only, for the same "earns its own boundary" reasoning — see above.
Seller-panel-only endpoints are gated with `apps.core.permissions.IsStoreOwner` (checks `request.user.store` exists — one seller owns exactly one `Store`).
## Errors
`utils.exceptions.exception_handler` (wired as `REST_FRAMEWORK['EXCEPTION_HANDLER']`) plus `utils.exceptions.ErrorMiddleware` for non-DRF 500s. User-facing error strings are in **Persian**; code/comments stay in English. Wraps every response as `{success, status_code, status_message, details}`.

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM debian:13
ENV PYTHONUNBUFFERED 1
WORKDIR /app
RUN apt update
RUN apt install -y python3 python3-pip binutils libproj-dev gdal-bin netcat-openbsd
COPY requirements.txt /app/requirements.txt
RUN pip3 install --break-system-packages -r requirements.txt
RUN pip3 install --break-system-packages setuptools
COPY . /app
ENTRYPOINT ["./run.sh"]

443
FRONTEND_GUIDE.md Normal file
View file

@ -0,0 +1,443 @@
# 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.
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.
## 1. Base URL
All endpoints below are relative to `/api/`. Domain apps live under `/api/v1/`; health check and OAuth2 live directly under `/api/`.
```
{API_BASE_URL}/api/v1/...
```
## 2. Two clients, one API, one role model
There is no separate "seller API" service — it's the same backend, same auth, same `User`. Whether a logged-in user is a "customer" or a "seller" is determined entirely by **whether they own a `Store`**:
- Any authenticated user can call the customer-facing endpoints (browse, cart, checkout, etc).
- `POST /api/v1/seller/store/` lets **any** authenticated user become a seller by creating their store (this is the "ایجاد فروشگاه" screen). One user → at most one store.
- Every endpoint under `seller/...` requires the user to already own a store (`403`/`404` otherwise — see §4.3).
So: the seller panel app should call `GET /api/v1/seller/store/` right after login; a `404` means "show the create-store onboarding flow," anything else means "show the dashboard."
## 3. Authentication
**This backend does not implement login, OTP, or signup endpoints.** Like every other Winsoo/Gooyal service, it's an OAuth2 *resource server*: your client authenticates directly against the central **Gooyal accounts** service and sends the resulting bearer token to Winofy.
```
Authorization: Bearer <access_token issued by Gooyal accounts>
```
- The phone-number + OTP screens in the Figma file (C01/C02 for customers, S01/S02 for sellers) are a UI over Gooyal's own `/oauth2/token/` endpoint (password/OTP grant) — **not** a Winofy endpoint. Get the exact request shape and base URL from whoever owns the Gooyal accounts service integration for this app.
- Winofy will lazily create a local shadow user (keyed by the same UUID Gooyal uses) the first time it sees a valid token for that user — the frontend doesn't need to do anything to "register" a user with Winofy.
- There is no refresh-token handling documented here; follow the standard OAuth2 refresh flow against Gooyal accounts.
### 3.1 Local development without real Gooyal credentials
If you're integrating against a local instance that doesn't have real Gooyal OAuth2 credentials wired up yet, the two workarounds are:
1. **Django session auth** (also enabled): log into `/api/admin/` with a superuser account in a browser, then a browser-based dev client sharing cookies can call the API using that session. Not viable for a mobile app or a separate origin without CORS+CSRF setup.
2. Ask backend/DevOps for a real Gooyal `client_id`/`client_secret` and a test phone number — this is the only realistic path for end-to-end testing outside a browser.
There is **no dev-mode bypass endpoint** in this API — don't build against one that doesn't exist.
## 4. Conventions
### 4.1 Success responses — plain, not wrapped
Successful responses are the serializer's JSON directly. There is **no `{success: true, data: ...}` envelope** — that only applies to errors (§4.2). Don't unwrap a `data` key that isn't there.
### 4.2 Error responses — always wrapped, Persian messages
Every non-2xx response has this shape:
```json
{
"success": false,
"status_code": 400,
"status_message": "Bad Request",
"details": {
"message": { "field_name": ["پیام خطا به فارسی"] },
"error": "invalid",
"timestamp": "2026-08-09T12:00:00.000000+00:00"
}
}
```
- `details.message` is either a string, or an object keyed by field name (validation errors) — check which shape you got before rendering.
- All user-facing error text is in **Persian** — show `details.message` directly to the user, don't re-translate.
- Common status codes you'll see: `400` (validation), `401` (missing/invalid token), `403` (authenticated but not allowed — e.g. non-seller hitting a seller endpoint's permission check), `404` (not found / not yours), `409` (conflict — e.g. re-creating a store, invalid status transition), `503` (an upstream Gooyal/payment-gateway call failed).
### 4.3 Seller-endpoint permission errors
`seller/*` endpoints use two different failure modes depending on the view — be ready for either:
- `403 Forbidden` (from the `IsStoreOwner` permission check) on most `seller/*` list/action endpoints.
- `404 Not Found` specifically from `GET/PATCH /seller/store/` before a store exists (it does a plain `get_object_or_404`, not a permission check, since "you don't have a store yet" is the expected first-run state).
### 4.4 Pagination
List endpoints use limit/offset pagination:
```
GET /api/v1/stores/?limit=20&offset=40
```
```json
{ "count": 132, "next": "http://.../stores/?limit=20&offset=60", "previous": "http://.../stores/?limit=20&offset=20", "results": [ ... ] }
```
Default page size is 50 if `limit` is omitted.
### 4.5 Identifiers, money, dates
- Every object's primary key is a **UUID string** (field name `uuid`), not an integer. Use it in URLs: `/api/v1/products/{uuid}/`.
- All money fields (`price`, `delivery_fee`, `items_subtotal`, `total_amount`, `amount`, etc.) are **integers in Toman**, no decimals, no currency string.
- All timestamps are ISO 8601 with timezone (`created_at`, `placed_at`, `scheduled_at`, ...).
- Image fields (`image`, `logo`, `cover_image`, `icon`) are either `null` or an absolute/relative media URL — never assume they're set.
### 4.6 Enums
| Field | Values | Persian label |
|---|---|---|
| `Address.label` | `home`, `work`, `other` | خانه, محل کار, سایر |
| `Product.unit_type` | `gram`, `ml`, `piece` | گرم, میلی‌لیتر, عدد |
| `Store.status` | `pending`, `approved`, `suspended` | در انتظار تایید, تایید شده, معلق شده |
| `StoreWorkingHours.weekday` | `0`–`6` | شنبه=0 … جمعه=6 (Iranian week, **not** Sun-Sat) |
| `OrderGroup.delivery_type` | `express`, `scheduled` | ارسال فوری, زمان‌بندی شده |
| `OrderGroup.payment_method` | `wallet`, `online`, `cash_on_delivery` | کیف پول وینسو, درگاه بانکی, پرداخت در محل |
| `OrderGroup.payment_status` | `pending`, `paid`, `failed` | در انتظار پرداخت, پرداخت شده, ناموفق |
| `Order.status` | `placed`, `preparing`, `ready_to_ship`, `handed_to_courier`, `delivered`, `cancelled` | سفارش ثبت شد → در حال آماده‌سازی → آماده ارسال → تحویل سفیر شد → تحویل داده شد (+ لغو شده) |
| `SellerWithdrawalRequest.status` | `pending`, `processing`, `paid`, `rejected` | در انتظار بررسی, در حال پردازش, واریز شده, رد شده |
| `WalletTransactionRef.direction` | `debit`, `credit` | برداشت, واریز |
| `Notification.type` | `new_order`, `settlement_done`, `new_review`, `order_cancelled` | سفارش جدید, تسویه حساب, نظر جدید, لغو سفارش |
`Order.status` only moves forward through that exact sequence (or to `cancelled` from `placed`/`preparing` only) — see §6.3.
## 5. Customer app — endpoint reference
Auth column: **Public** = no token needed, **Auth** = any logged-in user, all scoped to the requester unless noted.
### Locations (`Locations` / `Addresses` in swagger)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/api/v1/cities/` | Public | List active cities. |
| GET | `/api/v1/neighborhoods/?city={uuid}` | Public | List neighborhoods, optionally filtered by city. |
| GET | `/api/v1/addresses/` | Auth | The caller's own saved addresses. |
| POST | `/api/v1/addresses/` | Auth | Create an address (see body below). |
| GET/PATCH/DELETE | `/api/v1/addresses/{uuid}/` | Auth | |
**Address create/update body:**
```json
{
"label": "home",
"title": "",
"full_address": "خیابان ولیعصر، کوچه ۱۲",
"plaque": "15", "floor": "2", "unit": "3",
"recipient_name": "سارا احمدی", "recipient_phone": "09123456789",
"city_uuid": "<city uuid>",
"neighborhood_uuid": "<neighborhood uuid or omit>",
"latitude": 35.7595, "longitude": 51.4088,
"is_default": true
}
```
Response mirrors this but with `city`/`neighborhood` as nested objects (not `*_uuid`), plus `uuid` and `created_at`.
### Stores (`Stores` tag)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/api/v1/store-categories/` | Public | سوپرمارکت / کافه / رستوران / ... |
| GET | `/api/v1/stores/?neighborhood={uuid}&category={uuid}&search=text&lat=..&lng=..` | Public | Home feed / search. `lat`+`lng` sorts by distance (15 km radius). Only `status=approved` stores are ever returned. |
| GET | `/api/v1/stores/{uuid}/` | Public | Store page — includes `working_hours`, `accepts_wallet/online/cash_on_delivery`, `description`, `address`, `phone_number` (fields the list endpoint omits). |
Store list item shape: `uuid, name, category{uuid,name,icon,order}, logo, cover_image, rating_avg, rating_count, min_order_amount, delivery_fee, free_delivery_threshold, is_open`.
### Catalog (`Catalog` tag)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/api/v1/product-categories/` | Public | Flat shared taxonomy (`parent` nullable for subcategories). |
| GET | `/api/v1/products/?store={uuid}&category={uuid}&search=text` | Public | Store page's product grid / search. Only active products of approved stores. |
| GET | `/api/v1/products/{uuid}/` | Public | Product detail — adds `description`, `category`, `store` (a `StoreListSerializer`). |
Product list item shape: `uuid, name, image, price, unit_type, unit_value, is_active, is_out_of_stock`.
### Cart (`Cart` tag) — Auth required for all
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/cart/` | Returns the cart **grouped by store** — see shape below. |
| DELETE | `/api/v1/cart/` | Empties the whole cart. |
| POST | `/api/v1/cart/items/` | Add a product; if it's already in the cart, **quantities add together** (not replaced). Body: `{"product_uuid": "...", "quantity": 2}`. |
| PATCH | `/api/v1/cart/items/{item_uuid}/` | Set an absolute quantity: `{"quantity": 5}`. |
| DELETE | `/api/v1/cart/items/{item_uuid}/` | Remove one line. |
**`GET /api/v1/cart/` response** — this is the shape behind the multi-store-cart UI (C07):
```json
{
"groups": [
{
"store": { "uuid": "...", "name": "سوپرمارکت پارسیان", "...": "..." },
"items": [
{ "uuid": "<cart item uuid>", "product": { "uuid": "...", "name": "شیر کاله", "price": 28500, "...": "..." }, "quantity": 2, "line_total": 57000 }
],
"items_subtotal": 57000,
"delivery_fee": 15000,
"total": 72000,
"meets_minimum_order": true
}
],
"grand_total": 72000
}
```
- `meets_minimum_order: false` means this store's slice is below `store.min_order_amount` — checkout will reject it (§6.2). Surface this in the cart UI per-store, same as the mock.
- `delivery_fee` is already `0` here if the store's `free_delivery_threshold` was met — don't recompute it client-side.
### Checkout (`Checkout` tag) — Auth required
| Method | Path |
|---|---|
| POST | `/api/v1/checkout/` |
Body:
```json
{
"address_uuid": "<address uuid>",
"delivery_type": "express",
"scheduled_at": null,
"payment_method": "wallet",
"notes": ""
}
```
- `scheduled_at` is **required** (ISO datetime) when `delivery_type` is `"scheduled"` — omitting it is a `400`.
- `address_uuid` must belong to the caller, or `400`.
- The cart is split into **one `Order` per store** and cleared on success. See §6.1–§6.2 for the full flow including coverage checks and payment-method branching.
Response = an `OrderGroup` object (§5, Orders below):
```json
{
"uuid": "<order group uuid>",
"recipient_name": "...", "recipient_phone": "...", "full_address": "...",
"delivery_type": "express", "scheduled_at": null,
"payment_method": "online", "payment_status": "pending",
"notes": "", "total_amount": 178500,
"orders": [ /* array of Order objects, one per store — see Orders below */ ],
"created_at": "..."
}
```
**On `main`**, that's it — `payment_status` stays `"pending"` no matter which `payment_method` you send; nothing external is contacted. **On `feature/payment`**, the response also includes a `payment` key and `payment_status` actually reflects what happened:
- `wallet`: `payment` = `{"method": "wallet", "status": "paid"}` — synchronous, done.
- `online`: `payment` = `{"method": "online", "status": "pending", "payment_url": "..."}` — **redirect/open a webview at `payment_url`**; the gateway calls Winofy back and payment_status updates asynchronously (poll `GET /api/v1/order-groups/{uuid}/` or `GET /api/v1/orders/{uuid}/` to see it flip to `paid`).
- `cash_on_delivery`: `payment` = `{"method": "cash_on_delivery", "status": "pending"}` — nothing to do, collected on delivery.
### Orders (`Orders` tag) — Auth required, scoped to the caller
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/order-groups/` | Order history — each entry may bundle several stores' orders from one checkout. |
| GET | `/api/v1/order-groups/{uuid}/` | |
| GET | `/api/v1/orders/{uuid}/` | Single-order tracking (C09) — one store's slice. |
| POST | `/api/v1/orders/{uuid}/cancel/` | Body: `{"reason": ""}` (optional). Only works while `is_cancellable` is true. |
**`Order` object shape** (nested inside `OrderGroup.orders`, or standalone from `GET /orders/{uuid}/`):
```json
{
"uuid": "...", "store": { "...": "StoreListSerializer" }, "status": "preparing",
"items": [
{ "uuid": "...", "product": "<product uuid>", "product_name_snapshot": "شیر کاله", "unit_price_snapshot": 28500, "quantity": 2, "line_total": 57000 }
],
"items_subtotal": 57000, "delivery_fee": 15000,
"commission_amount": 2280, "seller_payout_amount": 54720,
"total_amount": 72000,
"cancel_reason": "", "courier_name": "", "courier_phone": "",
"placed_at": "...", "delivered_at": null,
"status_logs": [ { "from_status": "", "to_status": "placed", "note": "", "created_at": "..." } ],
"is_cancellable": true
}
```
- `items[].product` is just the product UUID (not expanded) — use `product_name_snapshot`/`unit_price_snapshot` for display; they're frozen at order time and won't change even if the product is later edited or deleted.
- Drive the order-tracking stepper UI off `status_logs` (ordered oldest→newest) or just off `status` directly — both are provided.
- `commission_amount`/`seller_payout_amount` are platform-internal — fine to show in a seller-facing context, not normally shown to the customer.
### Reviews (`Reviews` tag)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/api/v1/reviews/?store={uuid}` | Public | Reviews for a store's page. |
| POST | `/api/v1/reviews/` | Auth | Body: `{"order_uuid": "...", "rating": 5, "comment": "..."}`. Only allowed once `order.status == "delivered"`, and only once per order (`400` otherwise — see error messages in §4.2). |
Response adds `customer_name`, `store` (uuid), `product` (uuid, nullable), `seller_reply` (null until the seller replies).
### Notifications (`Notifications` tag) — Auth required, shared by both apps
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/notifications/` | The caller's notifications (works the same whether they're a customer or a seller — it's just "notifications for this user"). |
| GET | `/api/v1/notifications/{uuid}/` | |
| POST | `/api/v1/notifications/{uuid}/mark-read/` | |
| POST | `/api/v1/notifications/mark-all-read/` | Returns `204`. |
Shape: `uuid, type, title, body, related_order (uuid, nullable), is_read, created_at`. All fields are read-only from the client's perspective (server-generated).
## 6. Key flows
### 6.1 Location & delivery-coverage flow (the neighborhood-confirmation-sheet)
Stores don't deliver everywhere — each `Store` has a `service_neighborhoods` list. The Figma "محله‌ت رو انتخاب کن" sheet exists because of this. The API enforces it **at checkout time**, not earlier — the client's job is to react to the failure, not to pre-validate:
1. Customer picks/has an `Address` (with a `neighborhood_uuid`) — via the address list, or the map picker + `POST /api/v1/addresses/`.
2. Customer browses/adds to cart freely — `GET /api/v1/stores/` and cart endpoints **do not check coverage**.
3. On `POST /api/v1/checkout/`, if any store in the cart doesn't serve the address's neighborhood, the whole checkout fails with `400` and a message like *"فروشگاه «X» به محله انتخابی شما ارسال ندارد."* — show the confirmation sheet / prompt the user to pick a different address at that point.
If you want to warn the customer earlier (e.g. on the store page), you'd need to compare the store's `service_neighborhoods` (not currently exposed on the customer-facing store serializer — ask backend to add it if you need this pre-check) against the customer's selected/default address neighborhood.
### 6.2 Multi-store cart → checkout
1. `GET /api/v1/cart/` → render one card per `groups[]` entry (matches C07).
2. Block the "ادامه و ثبت سفارش" button per-store (or overall) if any group has `meets_minimum_order: false`.
3. `POST /api/v1/checkout/` once. The backend, inside one transaction:
- re-validates minimum order, stock, and neighborhood coverage per store (can still fail here even if the cart screen looked fine, e.g. a race with another customer depleting stock) — handle the `400` gracefully;
- creates one `OrderGroup` + one `Order` per store, snapshots the address into `recipient_name/recipient_phone/full_address` on the group (so later address edits don't retroactively change past orders);
- decrements `Product.stock_quantity` and clears the cart;
- branches on `payment_method` (§5 Checkout).
4. Route based on `payment.method`:
- `wallet`/`cash_on_delivery` → go straight to an order-confirmation screen.
- `online` → open `payment.payment_url`, then poll or listen for the order group to reach `payment_status: "paid"`.
### 6.3 Order status stepper (seller side drives it, customer side reads it)
The seller panel is the only side that advances status. Valid transitions:
```
placed → preparing → ready_to_ship → handed_to_courier → delivered
placed → cancelled
preparing → cancelled
```
No other transition is allowed — the backend returns `409` for anything else (e.g. trying to jump from `placed` straight to `delivered`, or cancelling after `handed_to_courier`). See §7 for the seller-side action endpoints. The customer app's `POST /orders/{uuid}/cancel/` uses the same rule (only while `placed`/`preparing`).
Commission (`Order.commission_amount`) is fixed at checkout time from the store's rate (4% by default) applied to `items_subtotal` only — `delivery_fee` is excluded from the split and goes to the seller, on both branches. **On `feature/payment` only**, the `delivered` transition additionally credits the seller's Gooyal wallet and fires a `settlement_done` notification — none of this needs client involvement either way. **On `main`**, `delivered` just marks the order delivered; no money moves yet.
### 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:
| `payment_method` | What happens | Client follow-up |
|---|---|---|
| `wallet` | Debits the customer's Gooyal wallet synchronously during checkout. | None — `payment_status` is already `paid` in the checkout response. |
| `online` | Opens a charge request on the internal `ipg` gateway. | Redirect to `payment.payment_url`; the gateway calls Winofy back asynchronously. Poll the order/order-group for `payment_status`. |
| `cash_on_delivery` | Nothing charged now. | Show "پرداخت در محل" confirmation; money changes hands at delivery, outside the API. |
## 7. Seller panel — endpoint reference
Every endpoint below requires the caller to own a `Store` (`IsStoreOwner`), except store creation itself.
### Seller · Store
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/store/` | `404` if the user hasn't created a store yet → show onboarding. |
| POST | `/api/v1/seller/store/` | Creates the store (one per user, `409` on a second attempt). Body below. |
| PATCH | `/api/v1/seller/store/` | Partial update, same body shape. |
| GET | `/api/v1/seller/store/working-hours/` | Returns array of `{weekday, opens_at, closes_at, is_closed}`. |
| PUT | `/api/v1/seller/store/working-hours/` | **Full replace** — send all 7 days each time; body is a bare array (or `{"working_hours": [...]}`). |
Store create/update body:
```json
{
"name": "سوپرمارکت پارسیان",
"category_uuid": "<store category uuid>",
"description": "...",
"phone_number": "09166352131",
"city_uuid": "<city uuid>",
"address": "خیابان آزادی، نبش کوچه مریم",
"latitude": 35.71, "longitude": 51.35,
"service_neighborhood_uuids": ["<neighborhood uuid>", "..."],
"delivery_radius_km": 5,
"min_order_amount": 50000, "delivery_fee": 25000, "free_delivery_threshold": null,
"accepts_wallet": true, "accepts_online": true, "accepts_cash_on_delivery": 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.
### Seller · Products
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/products/` | Only the caller's own products. |
| POST | `/api/v1/seller/products/` | Create (S07 "افزودن محصول"). |
| GET/PATCH/DELETE | `/api/v1/seller/products/{uuid}/` | |
| PATCH | `/api/v1/seller/products/{uuid}/stock/` | Inventory-only quick update (S08): `{"stock_quantity": 12}`. |
Product body: `name, description, image, category_uuid, price, unit_type, unit_value, stock_quantity, low_stock_threshold, is_active`. Response adds `sold_count` (read-only), `is_out_of_stock`, `is_low_stock` (both computed: `stock_quantity <= 0`, and `0 < stock_quantity <= low_stock_threshold`).
### Seller · Orders
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/orders/?status=placed` | `status` filter matches the S09 tabs; omit for all. |
| GET | `/api/v1/seller/orders/{uuid}/` | S10 detail. |
| POST | `/api/v1/seller/orders/{uuid}/confirm/` | `placed → preparing`. |
| POST | `/api/v1/seller/orders/{uuid}/mark-ready/` | `preparing → ready_to_ship`. |
| POST | `/api/v1/seller/orders/{uuid}/mark-shipped/` | `ready_to_ship → handed_to_courier`. Body may include `courier_name`/`courier_phone`. |
| POST | `/api/v1/seller/orders/{uuid}/mark-delivered/` | `handed_to_courier → delivered`. Triggers settlement (§6.3). |
| POST | `/api/v1/seller/orders/{uuid}/cancel/` | Only from `placed`/`preparing`. Body: `{"reason": ""}`. |
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`)
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/wallet/` | `{"withdrawable_balance": 12500000, "pending_settlement": 350000, "recent_transactions": [...]}` (S11). |
| GET | `/api/v1/seller/wallet/withdrawals/` | The seller's withdrawal request history. |
| POST | `/api/v1/seller/wallet/withdrawals/` | S12 form: `{"amount": 500000, "iban": "IR120120000000012345678", "account_holder_name": "سارا احمدی"}`. `iban` must match `^IR\d{24}$` or `400`. |
`recent_transactions[]` items: `uuid, direction ("debit"/"credit"), amount, purpose, status, created_at`.
### Seller · Analytics
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/analytics/?period=today` | `period` ∈ `today`/`week`/`month` (default `today`). S17 dashboard. |
Response:
```json
{
"period": "today",
"order_count": 12, "total_sales": 2450000,
"cancellation_rate": 4.5, "average_order_value": 204000,
"chart": [ { "bucket": "2026-08-09T09:00:00Z", "sales": 320000 } ],
"top_products": [ { "name": "شیر کم چرب کاله 1 لیتری", "units_sold": 44, "revenue": 1680000 } ]
}
```
`chart[].bucket` is hourly when `period=today`, daily otherwise. `top_products` is capped at 5, sorted by revenue.
### Seller · Reviews
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/seller/reviews/` | Reviews on the caller's store. |
| POST | `/api/v1/seller/reviews/{uuid}/reply/` | `{"seller_reply": "ممنون از خرید شما"}`. |
### Payments webhook (`feature/payment` only — not on `main`, and not called by either client app)
`GET`/`POST /api/v1/payments/ipg/callback/` is hit by the `ipg` payment gateway service itself after a customer completes an online payment, not by the frontend. Don't call it directly; it's documented here only so you know it exists and isn't a client-facing endpoint.
## 8. Local development / testing
- The backend ships a management command that seeds a realistic dataset: 3 cities, 13 neighborhoods, ~19 stores across 6 categories, ~370 products, 30 customers with addresses, 150+ historical orders in every status (placed/preparing/delivered/cancelled), and reviews. Ask backend to run `python manage.py seed_demo_data` (or `--flush` to reset it) against your dev environment before you start wiring up screens — there's no need to hand-create fixtures.
- Swagger UI (`/api/swagger/swagger-ui/`) is grouped into the same sections as this doc (Locations, Stores, Catalog, Cart, Checkout, Orders, Reviews, Notifications, and the `Seller · *` groups — `Seller · Wallet` and `Payments` only appear on `feature/payment`) — use it to try requests once you have a token.
- `GET /api/health/` needs no auth and is useful as a "is the backend even up" smoke check.
## 9. Known gaps to flag back to backend if you hit them
- No multipart/image-upload endpoint yet for store logo/cover or product images (JSON `PATCH` bodies can't set binary fields).
- No store-approval endpoint — new stores sit in `status: "pending"` until changed via Django admin.
- No admin/public endpoint to see a store's `service_neighborhoods` from the customer-facing store serializer (needed if you want to pre-warn about delivery coverage before checkout — see §6.1).
- The `ipg` online-payment callback contract (`/api/v1/payments/ipg/callback/`) is a best-effort integration pending confirmation against a live `ipg` environment — if online payments seem to hang in `pending`, that's the first place to check.

0
apps/__init__.py Normal file
View file

0
apps/catalog/__init__.py Normal file
View file

17
apps/catalog/admin.py Normal file
View file

@ -0,0 +1,17 @@
from django.contrib import admin
from .models import Product, ProductCategory
@admin.register(ProductCategory)
class ProductCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent', 'order')
list_filter = ('parent',)
search_fields = ('name',)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('name', 'store', 'category', 'price', 'stock_quantity', 'is_active', 'sold_count')
list_filter = ('is_active', 'category', 'store')
search_fields = ('name', 'store__name')

6
apps/catalog/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CatalogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.catalog'

View file

@ -0,0 +1,59 @@
# Generated by Django 6.0.2 on 2026-08-09 10:29
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('stores', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ProductCategory',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=100)),
('icon', models.ImageField(blank=True, null=True, upload_to='product_categories/')),
('order', models.PositiveSmallIntegerField(default=0)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='catalog.productcategory')),
],
options={
'verbose_name': 'دسته\u200cبندی محصول',
'verbose_name_plural': 'دسته\u200cبندی\u200cهای محصول',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('image', models.ImageField(blank=True, null=True, upload_to='products/')),
('price', models.PositiveBigIntegerField()),
('unit_type', models.CharField(choices=[('gram', 'گرم'), ('ml', 'میلی\u200cلیتر'), ('piece', 'عدد')], default='piece', max_length=10)),
('unit_value', models.PositiveIntegerField(blank=True, null=True)),
('stock_quantity', models.PositiveIntegerField(default=0)),
('low_stock_threshold', models.PositiveIntegerField(default=5)),
('is_active', models.BooleanField(default=True)),
('sold_count', models.PositiveIntegerField(default=0)),
('store', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='stores.store')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='catalog.productcategory')),
],
options={
'verbose_name': 'محصول',
'verbose_name_plural': 'محصولات',
'ordering': ['-created_at'],
},
),
]

View file

60
apps/catalog/models.py Normal file
View file

@ -0,0 +1,60 @@
from django.db import models
from apps.stores.models import Store
from utils.models import BaseModel
class ProductCategory(BaseModel):
name = models.CharField(max_length=100)
parent = models.ForeignKey(
'self', on_delete=models.CASCADE, related_name='children', null=True, blank=True,
)
icon = models.ImageField(upload_to='product_categories/', null=True, blank=True)
order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['order', 'name']
verbose_name = 'دسته‌بندی محصول'
verbose_name_plural = 'دسته‌بندی‌های محصول'
def __str__(self):
return self.name
class Product(BaseModel):
class UnitType(models.TextChoices):
GRAM = 'gram', 'گرم'
MILLILITER = 'ml', 'میلی‌لیتر'
PIECE = 'piece', 'عدد'
store = models.ForeignKey(Store, on_delete=models.CASCADE, related_name='products')
category = models.ForeignKey(ProductCategory, on_delete=models.PROTECT, related_name='products')
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
image = models.ImageField(upload_to='products/', null=True, blank=True)
price = models.PositiveBigIntegerField()
unit_type = models.CharField(max_length=10, choices=UnitType.choices, default=UnitType.PIECE)
unit_value = models.PositiveIntegerField(null=True, blank=True)
stock_quantity = models.PositiveIntegerField(default=0)
low_stock_threshold = models.PositiveIntegerField(default=5)
is_active = models.BooleanField(default=True)
sold_count = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['-created_at']
verbose_name = 'محصول'
verbose_name_plural = 'محصولات'
def __str__(self):
return f'{self.name} - {self.store.name}'
@property
def is_out_of_stock(self):
return self.stock_quantity <= 0
@property
def is_low_stock(self):
return 0 < self.stock_quantity <= self.low_stock_threshold

View file

@ -0,0 +1,58 @@
from rest_framework import serializers
from apps.stores.serializers import StoreListSerializer
from .models import Product, ProductCategory
class ProductCategorySerializer(serializers.ModelSerializer):
class Meta:
model = ProductCategory
fields = ('uuid', 'name', 'parent', 'icon', 'order')
class ProductListSerializer(serializers.ModelSerializer):
is_out_of_stock = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = (
'uuid', 'name', 'image', 'price', 'unit_type', 'unit_value',
'is_active', 'is_out_of_stock',
)
class ProductDetailSerializer(ProductListSerializer):
category = ProductCategorySerializer(read_only=True)
store = StoreListSerializer(read_only=True)
class Meta(ProductListSerializer.Meta):
fields = ProductListSerializer.Meta.fields + ('description', 'category', 'store')
class SellerProductSerializer(serializers.ModelSerializer):
"""Read/write serializer for the seller's own products (S06/S07/S08)."""
category = ProductCategorySerializer(read_only=True)
category_uuid = serializers.PrimaryKeyRelatedField(
source='category', queryset=ProductCategory.objects.all(), write_only=True,
)
is_out_of_stock = serializers.BooleanField(read_only=True)
is_low_stock = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = (
'uuid', 'name', 'description', 'image', 'category', 'category_uuid',
'price', 'unit_type', 'unit_value', 'stock_quantity', 'low_stock_threshold',
'is_active', 'sold_count', 'is_out_of_stock', 'is_low_stock', 'created_at',
)
read_only_fields = ('sold_count',)
def create(self, validated_data):
validated_data['store'] = self.context['request'].user.store
return super().create(validated_data)
class StockAdjustSerializer(serializers.Serializer):
stock_quantity = serializers.IntegerField(min_value=0)

View file

View file

@ -0,0 +1,33 @@
from apps.catalog.models import Product
from apps.orders.tests.base import OrdersTestCase
class SellerProductTests(OrdersTestCase):
def setUp(self):
self.client.force_authenticate(user=self.seller1)
def test_seller_sees_only_their_own_products(self):
response = self.client.get('/api/v1/seller/products/')
self.assertEqual(response.status_code, 200)
uuids = {item['uuid'] for item in response.data['results']}
self.assertIn(str(self.product1.uuid), uuids)
self.assertNotIn(str(self.product2.uuid), uuids)
def test_stock_adjust_action(self):
response = self.client.patch(f'/api/v1/seller/products/{self.product1.uuid}/stock/', {'stock_quantity': 3})
self.assertEqual(response.status_code, 200, response.data)
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 3)
def test_seller_cannot_edit_another_sellers_product(self):
response = self.client.patch(f'/api/v1/seller/products/{self.product2.uuid}/stock/', {'stock_quantity': 3})
self.assertEqual(response.status_code, 404)
def test_customer_facing_list_hides_inactive_products(self):
self.product1.is_active = False
self.product1.save()
self.client.force_authenticate(user=self.customer)
response = self.client.get('/api/v1/products/', {'store': str(self.store1.uuid)})
uuids = {item['uuid'] for item in response.data['results']}
self.assertNotIn(str(self.product1.uuid), uuids)

12
apps/catalog/urls.py Normal file
View file

@ -0,0 +1,12 @@
from rest_framework.routers import DefaultRouter
from .views import ProductCategoryViewSet, ProductViewSet, SellerProductViewSet
app_name = "catalog"
router = DefaultRouter()
router.register('products', ProductViewSet, basename='product')
router.register('product-categories', ProductCategoryViewSet, basename='product-category')
router.register('seller/products', SellerProductViewSet, basename='seller-product')
urlpatterns = router.urls

78
apps/catalog/views.py Normal file
View file

@ -0,0 +1,78 @@
from django.db.models import Q
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from apps.core.permissions import IsStoreOwner
from apps.stores.models import Store
from .models import Product, ProductCategory
from .serializers import (
ProductCategorySerializer,
ProductDetailSerializer,
ProductListSerializer,
SellerProductSerializer,
StockAdjustSerializer,
)
class ProductCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
schema_tags = ['Catalog']
permission_classes = [AllowAny]
serializer_class = ProductCategorySerializer
queryset = ProductCategory.objects.all()
class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing product browsing (store page, product detail, search)."""
schema_tags = ['Catalog']
permission_classes = [AllowAny]
queryset = Product.objects.filter(
is_active=True, store__status=Store.Status.APPROVED,
).select_related('store', 'category')
def get_serializer_class(self):
if self.action == 'retrieve':
return ProductDetailSerializer
return ProductListSerializer
def get_queryset(self):
queryset = super().get_queryset()
store_uuid = self.request.query_params.get('store')
if store_uuid:
queryset = queryset.filter(store__uuid=store_uuid)
category_uuid = self.request.query_params.get('category')
if category_uuid:
queryset = queryset.filter(category__uuid=category_uuid)
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search))
return queryset
class SellerProductViewSet(viewsets.ModelViewSet):
"""Seller's own product management (S06 list, S07 add, S08 inventory)."""
schema_tags = ['Seller · Products']
permission_classes = [IsAuthenticated, IsStoreOwner]
serializer_class = SellerProductSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Product.objects.none()
return Product.objects.filter(store=self.request.user.store).select_related('category')
@action(detail=True, methods=['patch'], url_path='stock')
def adjust_stock(self, request, pk=None):
product = self.get_object()
serializer = StockAdjustSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
product.stock_quantity = serializer.validated_data['stock_quantity']
product.save(update_fields=['stock_quantity', 'updated_at'])
return Response(SellerProductSerializer(product).data)

0
apps/core/__init__.py Normal file
View file

5
apps/core/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'apps.core'

View file

View file

@ -0,0 +1,430 @@
import random
import uuid
from django.contrib.gis.geos import Point
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from apps.catalog.models import Product, ProductCategory
from apps.locations.models import Address, City, Neighborhood
from apps.orders import services as order_services
from apps.orders.models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
from apps.reviews.models import Review
from apps.reviews.services import refresh_store_rating
from apps.stores.models import Store, StoreCategory, StoreWorkingHours
from apps.users.models import User
CITIES = {
'تهران': {
'slug': 'tehran',
'center': (51.3890, 35.6892),
'neighborhoods': ['ونک', 'تجریش', 'یوسف‌آباد', 'سعادت‌آباد', 'نارمک', 'پونک', 'شهرک غرب', 'کشاورز'],
},
'مشهد': {
'slug': 'mashhad',
'center': (59.6062, 36.2970),
'neighborhoods': ['احمدآباد', 'وکیل‌آباد', 'بلوار سجاد'],
},
'اصفهان': {
'slug': 'isfahan',
'center': (51.6776, 32.6546),
'neighborhoods': ['چهارباغ', 'نظرشرقی'],
},
}
STORE_CATEGORIES = ['سوپرمارکت', 'کافه', 'رستوران', 'نانوایی', 'میوه و تره‌بار', 'قصابی']
STORE_NAMES = {
'سوپرمارکت': ['سوپرمارکت پارسیان', 'سوپرمارکت خلج', 'سوپرمارکت گلدن', 'سوپرمارکت رفاه', 'سوپرمارکت مریم', 'سوپرمارکت هفت‌تیر'],
'کافه': ['کافه کاژری گلها', 'کافه لمیز', 'کافه رعنا', 'کافه بن‌مانو'],
'رستوران': ['رستوران ایتال', 'رستوران سنتی دربند', 'رستوران کباب همت'],
'نانوایی': ['نان سنگک محمدی', 'نانوایی تافتون طلایی', 'نانوایی بربری رضایی'],
'میوه و تره‌بار': ['میوه‌فروشی باغ بهشت', 'میوه و تره‌بار ونک'],
'قصابی': ['قصابی برادران احمدی'],
}
# {product category: [(name, base_price, unit_type, unit_value)]}
PRODUCT_CATEGORIES = {
'لبنیات': [
('شیر پرچرب کاله', 38500, Product.UnitType.MILLILITER, 1000),
('شیر کم‌چرب کاله', 36000, Product.UnitType.MILLILITER, 1000),
('ماست موسیر کاله', 28000, Product.UnitType.GRAM, 900),
('پنیر سفید ایرانی روزانه', 45000, Product.UnitType.GRAM, 400),
('پنیر لیقوان', 85000, Product.UnitType.GRAM, 200),
('کره حیوانی هراز', 95000, Product.UnitType.GRAM, 100),
('دوغ کوهستان', 15000, Product.UnitType.MILLILITER, 700),
('خامه پاستوریزه میهن', 32000, Product.UnitType.GRAM, 200),
],
'نوشیدنی': [
('نوشابه خانواده کوکاکولا', 27000, Product.UnitType.MILLILITER, 1500),
('نوشابه قوطی فانتا', 12000, Product.UnitType.MILLILITER, 330),
('آب معدنی دماوند', 8000, Product.UnitType.MILLILITER, 1500),
('دلستر هی‌دی', 14000, Product.UnitType.MILLILITER, 330),
('چای سرد زمزم', 13000, Product.UnitType.MILLILITER, 300),
],
'تنقلات': [
('چیپس سرکه‌ای تمکی مینو', 31000, Product.UnitType.GRAM, 150),
('پفک نمکی چی‌توز', 18000, Product.UnitType.GRAM, 100),
('بادام زمینی نمکی', 42000, Product.UnitType.GRAM, 250),
('چیپس پیتزا مزمز', 29000, Product.UnitType.GRAM, 150),
],
'میوه و سبزیجات': [
('سیب قرمز درجه یک', 65000, Product.UnitType.GRAM, 1000),
('موز', 58000, Product.UnitType.GRAM, 1000),
('گوجه فرنگی', 34000, Product.UnitType.GRAM, 1000),
('خیار', 28000, Product.UnitType.GRAM, 1000),
('پرتقال تامسون', 44000, Product.UnitType.GRAM, 1000),
],
'نان و شیرینی': [
('نان سنگک تنجدی', 15000, Product.UnitType.PIECE, None),
('نان بربری', 12000, Product.UnitType.PIECE, None),
('کیک یزدی', 25000, Product.UnitType.PIECE, None),
('شیرینی دانمارکی', 35000, Product.UnitType.GRAM, 400),
],
'بهداشتی و آرایشی': [
('مایع ظرفشویی گلرنگ', 48000, Product.UnitType.MILLILITER, 900),
('شامپو سر و بدن جانسون', 65000, Product.UnitType.MILLILITER, 400),
('دستمال کاغذی نازلی', 22000, Product.UnitType.PIECE, None),
],
'گوشت و پروتئین': [
('مرغ کامل بی‌پوست', 145000, Product.UnitType.GRAM, 1000),
('گوشت چرخ‌کرده گوسفندی', 280000, Product.UnitType.GRAM, 500),
('تخم‌مرغ ۳۰ عددی', 95000, Product.UnitType.PIECE, 30),
],
'ادویه و چاشنی': [
('رب گوجه فرنگی', 38000, Product.UnitType.GRAM, 800),
('سرکه سیب تاژ', 22000, Product.UnitType.MILLILITER, 470),
('نمک یددار', 9000, Product.UnitType.GRAM, 700),
],
'کنسرو و خشکبار': [
('کنسرو تن ماهی شیلتون', 55000, Product.UnitType.GRAM, 180),
('کنسرو لوبیا چیتی', 32000, Product.UnitType.GRAM, 380),
('پسته اکبری', 350000, Product.UnitType.GRAM, 500),
],
'غذای یخی و فریزری': [
('ناگت مرغ سولیکو', 85000, Product.UnitType.GRAM, 500),
('سیب‌زمینی سرخ‌کرده منجمد', 60000, Product.UnitType.GRAM, 750),
],
'قهوه و دمنوش': [
('قهوه فوری نسکافه', 95000, Product.UnitType.GRAM, 100),
('چای احمد', 48000, Product.UnitType.GRAM, 450),
('دمنوش گل گاوزبان', 25000, Product.UnitType.GRAM, 100),
],
'غذای آماده': [
('پیتزا مینی مارگاریتا', 78000, Product.UnitType.PIECE, None),
('ساندویچ کالباس', 65000, Product.UnitType.PIECE, None),
],
}
STORE_CATEGORY_TO_PRODUCT_CATEGORIES = {
'سوپرمارکت': list(PRODUCT_CATEGORIES.keys()),
'کافه': ['نوشیدنی', 'قهوه و دمنوش', 'تنقلات', 'غذای آماده'],
'رستوران': ['غذای آماده', 'نوشیدنی'],
'نانوایی': ['نان و شیرینی'],
'میوه و تره‌بار': ['میوه و سبزیجات'],
'قصابی': ['گوشت و پروتئین'],
}
FIRST_NAMES = ['علی', 'سارا', 'محمد', 'زهرا', 'رضا', 'مریم', 'حسین', 'فاطمه', 'امیر', 'نگار', 'کیانا', 'آرمان']
LAST_NAMES = ['احمدی', 'محمدی', 'رضایی', 'کریمی', 'حسینی', 'صادقی', 'قاسمی', 'نوری', 'یوسفی', 'رحیمی']
STREETS = ['خیابان ولیعصر', 'خیابان شریعتی', 'بلوار کشاورز', 'خیابان آزادی', 'خیابان انقلاب', 'بزرگراه همت']
class Command(BaseCommand):
help = 'Seeds the database with realistic demo data (cities, stores, products, customers, orders).'
def add_arguments(self, parser):
parser.add_argument(
'--flush', action='store_true',
help='Delete all previously seeded demo data before generating new data.',
)
parser.add_argument('--orders', type=int, default=150, help='Number of historical orders to generate.')
parser.add_argument('--customers', type=int, default=30, help='Number of customer users to generate.')
def handle(self, *args, **options):
random.seed(42)
if options['flush']:
self._flush()
with transaction.atomic():
cities = self._seed_locations()
store_categories = self._seed_store_categories()
product_categories = self._seed_product_categories()
stores = self._seed_stores(cities, store_categories)
products = self._seed_products(stores, product_categories)
customers = self._seed_customers(options['customers'], cities)
if Order.objects.exists() and not options['flush']:
self.stdout.write(self.style.WARNING(
'Orders already exist — skipping order generation (pass --flush to regenerate everything).'
))
else:
self._seed_orders(customers, stores, products, options['orders'])
self.stdout.write(self.style.SUCCESS(
f'Seeded {City.objects.count()} cities, {Neighborhood.objects.count()} neighborhoods, '
f'{Store.objects.count()} stores, {Product.objects.count()} products, '
f'{User.objects.filter(store__isnull=True).count()} customers, {Order.objects.count()} orders.'
))
def _flush(self):
self.stdout.write('Flushing previously seeded data...')
Notification.objects.all().delete()
Review.objects.all().delete()
OrderStatusLog.objects.all().delete()
OrderItem.objects.all().delete()
Order.objects.all().delete()
OrderGroup.objects.all().delete()
Cart.objects.all().delete()
Product.objects.all().delete()
ProductCategory.objects.all().delete()
StoreWorkingHours.objects.all().delete()
Store.objects.all().delete()
StoreCategory.objects.all().delete()
Address.objects.all().delete()
Neighborhood.objects.all().delete()
City.objects.all().delete()
User.objects.filter(is_superuser=False).delete()
# ── Reference data ───────────────────────────────────────────────
def _seed_locations(self):
cities = {}
for name, info in CITIES.items():
city, _ = City.objects.get_or_create(slug=info['slug'], defaults={'name': name})
cities[name] = {'city': city, 'neighborhoods': []}
lng, lat = info['center']
for n_name in info['neighborhoods']:
jitter_lng = lng + random.uniform(-0.05, 0.05)
jitter_lat = lat + random.uniform(-0.05, 0.05)
neighborhood, _ = Neighborhood.objects.get_or_create(
city=city, slug=self._slugify(n_name),
defaults={'name': n_name, 'center': Point(jitter_lng, jitter_lat, srid=4326)},
)
cities[name]['neighborhoods'].append(neighborhood)
return cities
def _seed_store_categories(self):
return {
name: StoreCategory.objects.get_or_create(name=name, defaults={'order': i})[0]
for i, name in enumerate(STORE_CATEGORIES)
}
def _seed_product_categories(self):
return {
name: ProductCategory.objects.get_or_create(name=name, defaults={'order': i})[0]
for i, name in enumerate(PRODUCT_CATEGORIES)
}
# ── Stores & products ────────────────────────────────────────────
def _seed_stores(self, cities, store_categories):
stores = []
for store_category_name, names in STORE_NAMES.items():
for name in names:
city_name = random.choice(list(cities.keys()))
city_info = cities[city_name]
neighborhood = random.choice(city_info['neighborhoods'])
username = f'seller_{self._slugify(name)}'
owner, _ = User.objects.get_or_create(
username=username, defaults={'pk': uuid.uuid4(), 'first_name': random.choice(FIRST_NAMES)},
)
store, created = Store.objects.get_or_create(
owner=owner,
defaults={
'name': name,
'category': store_categories[store_category_name],
'description': f'عرضه انواع کالاهای با کیفیت در {city_name}.',
'city': city_info['city'],
'address': f'{random.choice(STREETS)}، {neighborhood.name}',
'location': neighborhood.center,
'min_order_amount': random.choice([30_000, 50_000, 80_000]),
'delivery_fee': random.choice([0, 15_000, 20_000, 25_000]),
'free_delivery_threshold': random.choice([None, 150_000, 200_000]),
'commission_percent': 4,
'rating_avg': round(random.uniform(3.5, 5.0), 1),
'rating_count': random.randint(5, 300),
'status': Store.Status.APPROVED,
},
)
if created:
store.service_neighborhoods.add(neighborhood, *random.sample(
city_info['neighborhoods'], k=min(2, len(city_info['neighborhoods'])),
))
for weekday, _ in StoreWorkingHours.Weekday.choices:
StoreWorkingHours.objects.create(
store=store, weekday=weekday,
opens_at='08:00', closes_at='22:00', is_closed=(weekday == StoreWorkingHours.Weekday.FRIDAY),
)
stores.append((store, store_category_name))
return stores
def _seed_products(self, stores, product_categories):
products = []
for store, store_category_name in stores:
available_categories = STORE_CATEGORY_TO_PRODUCT_CATEGORIES[store_category_name]
for category_name in available_categories:
for name, base_price, unit_type, unit_value in PRODUCT_CATEGORIES[category_name]:
price = base_price + random.randint(-2000, 5000)
product, _ = Product.objects.get_or_create(
store=store, name=name,
defaults={
'category': product_categories[category_name],
'description': f'{name} با بهترین کیفیت و قیمت مناسب.',
'price': max(price, 1000),
'unit_type': unit_type,
'unit_value': unit_value,
'stock_quantity': random.randint(0, 60),
'low_stock_threshold': 5,
'sold_count': random.randint(0, 200),
},
)
products.append(product)
return products
# ── Customers ────────────────────────────────────────────────────
def _seed_customers(self, count, cities):
customers = []
for i in range(count):
username = f'customer_{i + 1}'
first = random.choice(FIRST_NAMES)
last = random.choice(LAST_NAMES)
customer, _ = User.objects.get_or_create(
username=username, defaults={'pk': uuid.uuid4(), 'first_name': first, 'last_name': last},
)
if not customer.addresses.exists():
city_name = random.choice(list(cities.keys()))
city_info = cities[city_name]
neighborhood = random.choice(city_info['neighborhoods'])
Address.objects.create(
user=customer, city=city_info['city'], neighborhood=neighborhood,
label=Address.Label.HOME,
full_address=f'{random.choice(STREETS)}، کوچه {random.randint(1, 40)}',
plaque=str(random.randint(1, 60)), unit=str(random.randint(1, 8)),
recipient_name=f'{first} {last}', recipient_phone=f'0912{random.randint(1000000, 9999999)}',
location=neighborhood.center, is_default=True,
)
customers.append(customer)
return customers
# ── Orders (exercises the real checkout/status services, COD only — no network) ──
def _seed_orders(self, customers, stores, products, count):
self.stdout.write(f'Generating {count} demo orders...')
created = 0
attempts = 0
while created < count and attempts < count * 3:
attempts += 1
customer = random.choice(customers)
address = customer.addresses.first()
if not address or not address.neighborhood_id:
continue
candidate_stores = [
s for s, _ in stores
if s.service_neighborhoods.filter(uuid=address.neighborhood_id).exists()
and s.products.filter(stock_quantity__gt=0).exists()
]
if not candidate_stores:
continue
cart, _ = Cart.objects.get_or_create(user=customer)
cart.items.all().delete()
chosen_stores = random.sample(candidate_stores, k=min(random.randint(1, 2), len(candidate_stores)))
for store in chosen_stores:
store_products = list(store.products.filter(stock_quantity__gt=0))
if not store_products:
continue
for product in random.sample(store_products, k=min(random.randint(1, 3), len(store_products))):
cart.items.create(product=product, quantity=random.randint(1, 3))
if not cart.items.exists():
continue
try:
order_group = order_services.checkout(
user=customer, address=address,
delivery_type=random.choice([OrderGroup.DeliveryType.EXPRESS, OrderGroup.DeliveryType.SCHEDULED]),
scheduled_at=timezone.now() + timezone.timedelta(hours=2) if random.random() < 0.3 else None,
payment_method=OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
notes='',
)
except Exception:
continue
for order in order_group.orders.all():
self._randomize_order_age_and_status(order)
created += 1
for store, _ in stores:
if store.reviews.exists():
refresh_store_rating(store)
self.stdout.write(self.style.SUCCESS(f'Created {created} order groups.'))
def _randomize_order_age_and_status(self, order):
"""Backdates the order and fast-forwards it through a random point in its
lifecycle — done directly via the ORM (not the services layer) so seeding
never makes real calls out to the Gooyal wallet service."""
days_ago = random.randint(0, 45)
created_at = timezone.now() - timezone.timedelta(days=days_ago, hours=random.randint(0, 23))
Order.objects.filter(pk=order.pk).update(created_at=created_at, placed_at=created_at)
OrderStatusLog.objects.filter(order=order).update(created_at=created_at)
order.refresh_from_db()
roll = random.random()
if roll < 0.08:
target_status = Order.Status.CANCELLED
elif days_ago >= 1:
target_status = Order.Status.DELIVERED
else:
target_status = random.choice([
Order.Status.PLACED, Order.Status.PREPARING,
Order.Status.READY_TO_SHIP, Order.Status.HANDED_TO_COURIER,
])
if target_status == Order.Status.CANCELLED:
order.status = Order.Status.CANCELLED
order.cancel_reason = 'عدم پاسخگویی مشتری'
order.save(update_fields=['status', 'cancel_reason'])
OrderStatusLog.objects.create(
order=order, from_status=Order.Status.PLACED, to_status=Order.Status.CANCELLED,
note=order.cancel_reason, created_at=created_at + timezone.timedelta(hours=1),
)
return
path = [Order.Status.PREPARING, Order.Status.READY_TO_SHIP, Order.Status.HANDED_TO_COURIER, Order.Status.DELIVERED]
target_index = path.index(target_status) if target_status in path else -1
applied_path = path[:target_index + 1] if target_index >= 0 else []
step_time = created_at
for step_status in applied_path:
step_time += timezone.timedelta(minutes=random.randint(10, 90))
OrderStatusLog.objects.create(
order=order, from_status=order.status, to_status=step_status, created_at=step_time,
)
order.status = step_status
if target_status == Order.Status.DELIVERED:
order.delivered_at = step_time
order.save(update_fields=['status', 'delivered_at'])
if random.random() < 0.4:
Review.objects.get_or_create(
order=order, defaults={
'customer': order.customer, 'store': order.store,
'product': order.items.first().product if order.items.exists() else None,
'rating': random.randint(3, 5),
'comment': random.choice(['عالی بود', 'راضی بودم', 'ارسال سریع بود', 'کیفیت خوبی داشت', '']),
},
)
else:
order.save(update_fields=['status'])
@staticmethod
def _slugify(text):
translation = str.maketrans({' ': '-', '‌': '-'})
return text.translate(translation)

View file

10
apps/core/permissions.py Normal file
View file

@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
class IsStoreOwner(BasePermission):
"""Restricts seller-panel endpoints to authenticated users who own a Store."""
message = "شما فروشگاهی ثبت نکرده‌اید."
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated and hasattr(request.user, 'store'))

15
apps/core/schema.py Normal file
View file

@ -0,0 +1,15 @@
from drf_spectacular.openapi import AutoSchema
class TaggedAutoSchema(AutoSchema):
"""
Groups swagger operations by a `schema_tags` list set on the view, instead of
drf-spectacular's default (first URL path segment — which is "v1" for every
endpoint here, since everything lives under /api/v1/).
"""
def get_tags(self):
tags = getattr(self.view, 'schema_tags', None)
if tags:
return tags
return super().get_tags()

9
apps/core/urls.py Normal file
View file

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

View file

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

12
apps/core/views/health.py Normal file
View file

@ -0,0 +1,12 @@
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
@extend_schema(exclude=True)
class HealthCheckView(APIView):
permission_classes = [AllowAny]
def get(self, request):
return Response({"status": "ok"})

View file

View file

@ -0,0 +1,12 @@
"""
Django admin configuration for the gooyal-restrict-scopes package.
"""
from django.contrib import admin
from django.contrib.admin.sites import NotRegistered
from oauth2_provider.admin import ApplicationAdmin
# The restricted application is registered by Django OAuth Toolkit, but we want
# to provide our own admin that uses our form

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GooyalOauthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.gooyal_oauth2'

View file

@ -0,0 +1,7 @@
"""
Django forms for use with the gooyal-restrict-scopes package.
"""
from django import forms
from oauth2_provider.scopes import get_scopes_backend

View file

@ -0,0 +1,103 @@
# Generated by Django 6.0.2 on 2026-08-09 10:12
import oauth2_provider.generators
import oauth2_provider.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AccessToken',
fields=[
('token', models.TextField()),
('token_checksum', oauth2_provider.models.TokenChecksumField(db_index=True, max_length=64, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('detail', models.JSONField(blank=True, null=True)),
('client_id', models.CharField(blank=True, max_length=255, null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Application',
fields=[
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')),
('post_logout_redirect_uris', models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated')),
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44)),
('client_secret', oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)),
('hash_client_secret', models.BooleanField(default=True)),
('name', models.CharField(blank=True, max_length=255)),
('skip_authorization', models.BooleanField(default=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)),
('allowed_origins', models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Grant',
fields=[
('code', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('redirect_uri', models.TextField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('code_challenge', models.CharField(blank=True, default='', max_length=128)),
('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)),
('nonce', models.CharField(blank=True, default='', max_length=255)),
('claims', models.TextField(blank=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='IDToken',
fields=[
('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RefreshToken',
fields=[
('token', models.CharField(max_length=255)),
('token_family', models.UUIDField(blank=True, editable=False, null=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,83 @@
# Generated by Django 6.0.2 on 2026-08-09 10:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('gooyal_oauth2', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='accesstoken',
name='client_owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='application',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='grant',
name='application',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='grant',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='idtoken',
name='application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='idtoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='id_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='access_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='application',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='refreshtoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accesstoken',
name='source_refresh_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
),
]

View file

@ -0,0 +1,48 @@
# models
import uuid
from django.db import models
from oauth2_provider.models import AbstractApplication, AbstractAccessToken, AbstractGrant, AbstractRefreshToken, \
AbstractIDToken
class AccessToken(AbstractAccessToken):
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
detail = models.JSONField(null=True, blank=True)
client_id = models.CharField(max_length=255, null=True, blank=True)
client_owner = models.ForeignKey('users.User', on_delete=models.PROTECT, null=True, blank=True)
class Meta:
abstract = False
class Application(AbstractApplication):
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
class Meta:
abstract = False
class Grant(AbstractGrant):
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
class Meta:
abstract = False
class RefreshToken(AbstractRefreshToken):
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
class Meta:
abstract = False
class IDToken(AbstractIDToken):
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
class Meta:
abstract = False

View file

@ -0,0 +1,19 @@
import logging
from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements, OAuth2Authentication
from rest_framework.permissions import (
IsAuthenticated
)
loger = logging.getLogger("oauth2_provider")
class IsAuthenticatedOrTokenMatchesOASRequirements(TokenMatchesOASRequirements):
def has_permission(self, request, view):
is_authenticated = IsAuthenticated().has_permission(request, view)
oauth2authenticated = False
if is_authenticated:
oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication)
token_has_scope = TokenMatchesOASRequirements()
return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view)

View file

@ -0,0 +1,6 @@
def get_application(request):
try:
application = request.auth.application
except:
application = None
return application

View file

@ -0,0 +1,169 @@
# import service_clients
import base64
import http.client
import logging
from datetime import datetime, timedelta
import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.timezone import make_aware
from oauth2_provider.models import (
get_access_token_model,
get_application_model,
get_grant_model,
get_id_token_model,
get_refresh_token_model,
)
from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.utils import get_timezone
Application = get_application_model()
AccessToken = get_access_token_model()
IDToken = get_id_token_model()
Grant = get_grant_model()
RefreshToken = get_refresh_token_model()
UserModel = get_user_model()
loger = logging.getLogger("oauth2_provider")
class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
def get_or_create_user_from_content(self, content):
"""
An optional layer to define where to store the profile in `UserModel` or a separate model.
For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` .
The function is called after checking that username is in the content.
Returns an UserModel instance;
"""
user, _ = UserModel.objects.get_or_create(pk=content["username"])
return user
def _get_token_from_authentication_server(
self, token, introspection_url, introspection_token, introspection_credentials
):
# NOTICE: onlu change from orginal method is that we create application here
"""Use external introspection endpoint to "crack open" the token.
:param introspection_url: introspection endpoint URL
:param introspection_token: Bearer token
:param introspection_credentials: Basic Auth credentials (id,secret)
:return: :class:`models.AccessToken`
Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic
Auth. Depending on the external AS's implementation, provide either the introspection_token
or the introspection_credentials.
If the resulting access_token identifies a username (e.g. Authorization Code grant), add
that user to the UserModel. Also cache the access_token up until its expiry time or a
configured maximum time.
"""
headers = None
if introspection_token:
headers = {"Authorization": "Bearer {}".format(introspection_token)}
elif introspection_credentials:
client_id = introspection_credentials[0].encode("utf-8")
client_secret = introspection_credentials[1].encode("utf-8")
basic_auth = base64.b64encode(client_id + b":" + client_secret)
headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))}
try:
response = requests.post(introspection_url, data={"token": token}, headers=headers)
except requests.exceptions.RequestException:
loger.exception("Introspection: Failed POST to %r in token lookup", introspection_url)
return None
# Log an exception when response from auth server is not successful
if response.status_code != http.client.OK:
loger.exception(
"Introspection: Failed to get a valid response "
"from authentication server. Status code: {}, "
"Reason: {}.".format(response.status_code, response.reason)
)
return None
try:
content = response.json()
except ValueError:
loger.exception("Introspection: Failed to parse response as json")
return None
if "active" in content and content["active"] is True:
try:
application_introspection_url = introspection_url.rstrip("/") + "_application/"
response = requests.post(application_introspection_url, data={"client_id": content['client_id']}, headers=headers)
except requests.exceptions.RequestException:
loger.exception("Introspection: Failed POST to %r in token lookup", introspection_url)
return None
if response.status_code != http.client.OK:
loger.exception(
"Application introspection: Failed to get a valid response "
"from authentication server. Status code: {}, "
"Reason: {}.".format(response.status_code, response.reason)
)
return None
try:
application_introspection_content = response.json()
except ValueError:
loger.exception("Introspection: Failed to parse response as json")
return None
owner_value = None
application_uuid = None
if "active" in application_introspection_content and application_introspection_content["active"] is True:
if "owner" in application_introspection_content:
owner_value = application_introspection_content["owner"]
application_uuid = application_introspection_content.get("uuid")
if owner_value:
owner, _ = UserModel.objects.get_or_create(pk=owner_value)
else:
owner = None
if "username" in content:
user = self.get_or_create_user_from_content(content)
else:
user = None
max_caching_time = datetime.now() + timedelta(
seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS
)
if "exp" in content:
expires = datetime.utcfromtimestamp(content["exp"])
if expires > max_caching_time:
expires = max_caching_time
else:
expires = max_caching_time
scope = content.get("scope", "")
if settings.USE_TZ:
expires = make_aware(
expires, timezone=get_timezone(oauth2_settings.AUTHENTICATION_SERVER_EXP_TIME_ZONE)
)
# TODO: get application owner and put it here
application, _created = Application.objects.get_or_create(
client_id=content["client_id"],
uuid=application_introspection_content["uuid"]
)
access_token, _created = AccessToken.objects.update_or_create(
token=token,
defaults={
"user": user,
"client_id": content["client_id"],
"client_owner": owner,
"application_id": application_uuid,
"scope": scope,
"expires": expires,
},
)
return access_token

View file

23
apps/locations/admin.py Normal file
View file

@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Address, City, Neighborhood
@admin.register(City)
class CityAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'is_active')
search_fields = ('name',)
@admin.register(Neighborhood)
class NeighborhoodAdmin(admin.ModelAdmin):
list_display = ('name', 'city', 'is_active')
list_filter = ('city',)
search_fields = ('name',)
@admin.register(Address)
class AddressAdmin(admin.ModelAdmin):
list_display = ('user', 'label', 'city', 'neighborhood', 'is_default')
list_filter = ('label', 'city')
search_fields = ('full_address', 'recipient_name', 'recipient_phone')

6
apps/locations/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.locations'

View file

@ -0,0 +1,80 @@
# Generated by Django 6.0.2 on 2026-08-09 10:29
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='City',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'شهر',
'verbose_name_plural': 'شهرها',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Neighborhood',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
('center', django.contrib.gis.db.models.fields.PointField(blank=True, geography=True, null=True, srid=4326)),
('is_active', models.BooleanField(default=True)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='neighborhoods', to='locations.city')),
],
options={
'verbose_name': 'محله',
'verbose_name_plural': 'محله\u200cها',
'ordering': ['name'],
'unique_together': {('city', 'slug')},
},
),
migrations.CreateModel(
name='Address',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('label', models.CharField(choices=[('home', 'خانه'), ('work', 'محل کار'), ('other', 'سایر')], default='home', max_length=10)),
('title', models.CharField(blank=True, max_length=100)),
('full_address', models.TextField()),
('plaque', models.CharField(blank=True, max_length=20)),
('floor', models.CharField(blank=True, max_length=20)),
('unit', models.CharField(blank=True, max_length=20)),
('recipient_name', models.CharField(max_length=150)),
('recipient_phone', models.CharField(max_length=20)),
('location', django.contrib.gis.db.models.fields.PointField(blank=True, geography=True, null=True, srid=4326)),
('is_default', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to=settings.AUTH_USER_MODEL)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='addresses', to='locations.city')),
('neighborhood', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addresses', to='locations.neighborhood')),
],
options={
'verbose_name': 'آدرس',
'verbose_name_plural': 'آدرس\u200cها',
'ordering': ['-is_default', '-created_at'],
},
),
]

View file

72
apps/locations/models.py Normal file
View file

@ -0,0 +1,72 @@
from django.conf import settings
from django.contrib.gis.db import models as gis_models
from django.db import models
from utils.models import BaseModel
class City(BaseModel):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['name']
verbose_name = 'شهر'
verbose_name_plural = 'شهرها'
def __str__(self):
return self.name
class Neighborhood(BaseModel):
city = models.ForeignKey(City, on_delete=models.CASCADE, related_name='neighborhoods')
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100)
center = gis_models.PointField(geography=True, null=True, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['name']
unique_together = ('city', 'slug')
verbose_name = 'محله'
verbose_name_plural = 'محله‌ها'
def __str__(self):
return f'{self.name} ({self.city.name})'
class Address(BaseModel):
class Label(models.TextChoices):
HOME = 'home', 'خانه'
WORK = 'work', 'محل کار'
OTHER = 'other', 'سایر'
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='addresses')
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='addresses')
neighborhood = models.ForeignKey(
Neighborhood, on_delete=models.PROTECT, related_name='addresses', null=True, blank=True,
)
label = models.CharField(max_length=10, choices=Label.choices, default=Label.HOME)
title = models.CharField(max_length=100, blank=True)
full_address = models.TextField()
plaque = models.CharField(max_length=20, blank=True)
floor = models.CharField(max_length=20, blank=True)
unit = models.CharField(max_length=20, blank=True)
recipient_name = models.CharField(max_length=150)
recipient_phone = models.CharField(max_length=20)
location = gis_models.PointField(geography=True, null=True, blank=True)
is_default = models.BooleanField(default=False)
class Meta:
ordering = ['-is_default', '-created_at']
verbose_name = 'آدرس'
verbose_name_plural = 'آدرس‌ها'
def __str__(self):
return f'{self.get_label_display()} - {self.full_address[:30]}'
def save(self, *args, **kwargs):
if self.is_default:
Address.objects.filter(user=self.user).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)

View file

@ -0,0 +1,60 @@
from rest_framework import serializers
from .models import Address, City, Neighborhood
class CitySerializer(serializers.ModelSerializer):
class Meta:
model = City
fields = ('uuid', 'name', 'slug')
class NeighborhoodSerializer(serializers.ModelSerializer):
city = CitySerializer(read_only=True)
city_uuid = serializers.PrimaryKeyRelatedField(
source='city', queryset=City.objects.filter(is_active=True), write_only=True,
)
class Meta:
model = Neighborhood
fields = ('uuid', 'name', 'slug', 'city', 'city_uuid')
class AddressSerializer(serializers.ModelSerializer):
neighborhood = NeighborhoodSerializer(read_only=True)
neighborhood_uuid = serializers.PrimaryKeyRelatedField(
source='neighborhood', queryset=Neighborhood.objects.filter(is_active=True),
write_only=True, required=False, allow_null=True,
)
city_uuid = serializers.PrimaryKeyRelatedField(
source='city', queryset=City.objects.filter(is_active=True), write_only=True,
)
city = CitySerializer(read_only=True)
latitude = serializers.FloatField(write_only=True, required=False)
longitude = serializers.FloatField(write_only=True, required=False)
class Meta:
model = Address
fields = (
'uuid', 'label', 'title', 'full_address', 'plaque', 'floor', 'unit',
'recipient_name', 'recipient_phone', 'is_default',
'city', 'city_uuid', 'neighborhood', 'neighborhood_uuid',
'latitude', 'longitude', 'created_at',
)
def create(self, validated_data):
lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None)
if lat is not None and lng is not None:
from django.contrib.gis.geos import Point
validated_data['location'] = Point(lng, lat, srid=4326)
validated_data['user'] = self.context['request'].user
return super().create(validated_data)
def update(self, instance, validated_data):
lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None)
if lat is not None and lng is not None:
from django.contrib.gis.geos import Point
validated_data['location'] = Point(lng, lat, srid=4326)
return super().update(instance, validated_data)

View file

12
apps/locations/urls.py Normal file
View file

@ -0,0 +1,12 @@
from rest_framework.routers import DefaultRouter
from .views import AddressViewSet, CityViewSet, NeighborhoodViewSet
app_name = "locations"
router = DefaultRouter()
router.register('addresses', AddressViewSet, basename='address')
router.register('cities', CityViewSet, basename='city')
router.register('neighborhoods', NeighborhoodViewSet, basename='neighborhood')
urlpatterns = router.urls

37
apps/locations/views.py Normal file
View file

@ -0,0 +1,37 @@
from rest_framework import mixins, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated
from .models import Address, City, Neighborhood
from .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer
class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
schema_tags = ['Locations']
permission_classes = [AllowAny]
serializer_class = CitySerializer
queryset = City.objects.filter(is_active=True)
class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
schema_tags = ['Locations']
permission_classes = [AllowAny]
serializer_class = NeighborhoodSerializer
queryset = Neighborhood.objects.filter(is_active=True).select_related('city')
def get_queryset(self):
queryset = super().get_queryset()
city_uuid = self.request.query_params.get('city')
if city_uuid:
queryset = queryset.filter(city__uuid=city_uuid)
return queryset
class AddressViewSet(viewsets.ModelViewSet):
schema_tags = ['Addresses']
permission_classes = [IsAuthenticated]
serializer_class = AddressSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Address.objects.none()
return Address.objects.filter(user=self.request.user).select_related('city', 'neighborhood')

0
apps/orders/__init__.py Normal file
View file

47
apps/orders/admin.py Normal file
View file

@ -0,0 +1,47 @@
from django.contrib import admin
from .models import Cart, CartItem, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
class OrderStatusLogInline(admin.TabularInline):
model = OrderStatusLog
extra = 0
@admin.register(OrderGroup)
class OrderGroupAdmin(admin.ModelAdmin):
list_display = ('uuid', 'user', 'payment_method', 'payment_status', 'total_amount', 'created_at')
list_filter = ('payment_method', 'payment_status', 'delivery_type')
search_fields = ('user__username', 'recipient_name', 'recipient_phone')
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('uuid', 'store', 'customer', 'status', 'items_subtotal', 'seller_payout_amount', 'created_at')
list_filter = ('status', 'store')
search_fields = ('uuid', 'store__name', 'customer__username')
inlines = [OrderItemInline, OrderStatusLogInline]
class CartItemInline(admin.TabularInline):
model = CartItem
extra = 0
@admin.register(Cart)
class CartAdmin(admin.ModelAdmin):
list_display = ('user', 'created_at')
search_fields = ('user__username',)
inlines = [CartItemInline]
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ('title', 'recipient', 'type', 'is_read', 'created_at')
list_filter = ('type', 'is_read')
search_fields = ('title', 'body', 'recipient__username')

6
apps/orders/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.orders'

View file

@ -0,0 +1,156 @@
# Generated by Django 6.0.2 on 2026-08-10 10:30
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('catalog', '0001_initial'),
('stores', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cart',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'سبد خرید',
'verbose_name_plural': 'سبدهای خرید',
},
),
migrations.CreateModel(
name='Order',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('status', models.CharField(choices=[('placed', 'سفارش ثبت شد'), ('preparing', 'در حال آماده\u200cسازی'), ('ready_to_ship', 'آماده ارسال'), ('handed_to_courier', 'تحویل سفیر شد'), ('delivered', 'تحویل داده شد'), ('cancelled', 'لغو شده')], default='placed', max_length=20)),
('items_subtotal', models.PositiveBigIntegerField()),
('delivery_fee', models.PositiveBigIntegerField(default=0)),
('commission_amount', models.PositiveBigIntegerField(default=0)),
('seller_payout_amount', models.PositiveBigIntegerField(default=0)),
('cancel_reason', models.TextField(blank=True)),
('courier_name', models.CharField(blank=True, max_length=150)),
('courier_phone', models.CharField(blank=True, max_length=20)),
('placed_at', models.DateTimeField(auto_now_add=True)),
('delivered_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to=settings.AUTH_USER_MODEL)),
('store', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='stores.store')),
],
options={
'verbose_name': 'سفارش',
'verbose_name_plural': 'سفارش\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Notification',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('type', models.CharField(choices=[('new_order', 'سفارش جدید'), ('settlement_done', 'تسویه حساب'), ('new_review', 'نظر جدید'), ('order_cancelled', 'لغو سفارش')], max_length=20)),
('title', models.CharField(max_length=200)),
('body', models.TextField(blank=True)),
('is_read', models.BooleanField(default=False)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
('related_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications', to='orders.order')),
],
options={
'verbose_name': 'اعلان',
'verbose_name_plural': 'اعلان\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='OrderGroup',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('recipient_name', models.CharField(max_length=150)),
('recipient_phone', models.CharField(max_length=20)),
('full_address', models.TextField()),
('delivery_type', models.CharField(choices=[('express', 'ارسال فوری'), ('scheduled', 'زمان\u200cبندی شده')], default='express', max_length=10)),
('scheduled_at', models.DateTimeField(blank=True, null=True)),
('payment_method', models.CharField(choices=[('wallet', 'کیف پول وینسو'), ('online', 'درگاه بانکی'), ('cash_on_delivery', 'پرداخت در محل')], max_length=20)),
('payment_status', models.CharField(choices=[('pending', 'در انتظار پرداخت'), ('paid', 'پرداخت شده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('notes', models.TextField(blank=True)),
('total_amount', models.PositiveBigIntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_groups', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'گروه سفارش',
'verbose_name_plural': 'گروه\u200cهای سفارش',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='order',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='orders.ordergroup'),
),
migrations.CreateModel(
name='OrderItem',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('product_name_snapshot', models.CharField(max_length=200)),
('unit_price_snapshot', models.PositiveBigIntegerField()),
('quantity', models.PositiveIntegerField()),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order')),
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='catalog.product')),
],
options={
'verbose_name': 'قلم سفارش',
'verbose_name_plural': 'اقلام سفارش',
},
),
migrations.CreateModel(
name='OrderStatusLog',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('from_status', models.CharField(blank=True, max_length=20)),
('to_status', models.CharField(max_length=20)),
('note', models.TextField(blank=True)),
('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='orders.order')),
],
options={
'verbose_name': 'تاریخچه وضعیت سفارش',
'verbose_name_plural': 'تاریخچه وضعیت سفارش\u200cها',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='CartItem',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('quantity', models.PositiveIntegerField(default=1)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='catalog.product')),
],
options={
'verbose_name': 'قلم سبد خرید',
'verbose_name_plural': 'اقلام سبد خرید',
'unique_together': {('cart', 'product')},
},
),
]

View file

182
apps/orders/models.py Normal file
View file

@ -0,0 +1,182 @@
from django.conf import settings
from django.db import models
from apps.catalog.models import Product
from apps.stores.models import Store
from utils.models import BaseModel
class Cart(BaseModel):
"""Pre-checkout state — one per user, may hold items from multiple stores."""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cart')
class Meta:
verbose_name = 'سبد خرید'
verbose_name_plural = 'سبدهای خرید'
def __str__(self):
return f'سبد خرید {self.user}'
class CartItem(BaseModel):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='+')
quantity = models.PositiveIntegerField(default=1)
class Meta:
unique_together = ('cart', 'product')
verbose_name = 'قلم سبد خرید'
verbose_name_plural = 'اقلام سبد خرید'
def __str__(self):
return f'{self.product.name} x{self.quantity}'
@property
def line_total(self):
return self.product.price * self.quantity
class OrderGroup(BaseModel):
"""One checkout/payment event — fans out into one Order per store (multi-store-cart)."""
class DeliveryType(models.TextChoices):
EXPRESS = 'express', 'ارسال فوری'
SCHEDULED = 'scheduled', 'زمان‌بندی شده'
class PaymentMethod(models.TextChoices):
WALLET = 'wallet', 'کیف پول وینسو'
ONLINE = 'online', 'درگاه بانکی'
CASH_ON_DELIVERY = 'cash_on_delivery', 'پرداخت در محل'
class PaymentStatus(models.TextChoices):
PENDING = 'pending', 'در انتظار پرداخت'
PAID = 'paid', 'پرداخت شده'
FAILED = 'failed', 'ناموفق'
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='order_groups')
recipient_name = models.CharField(max_length=150)
recipient_phone = models.CharField(max_length=20)
full_address = models.TextField()
delivery_type = models.CharField(max_length=10, choices=DeliveryType.choices, default=DeliveryType.EXPRESS)
scheduled_at = models.DateTimeField(null=True, blank=True)
payment_method = models.CharField(max_length=20, choices=PaymentMethod.choices)
payment_status = models.CharField(max_length=10, choices=PaymentStatus.choices, default=PaymentStatus.PENDING)
notes = models.TextField(blank=True)
total_amount = models.PositiveBigIntegerField()
class Meta:
ordering = ['-created_at']
verbose_name = 'گروه سفارش'
verbose_name_plural = 'گروه‌های سفارش'
def __str__(self):
return f'گروه سفارش {self.uuid} - {self.user}'
class Order(BaseModel):
class Status(models.TextChoices):
PLACED = 'placed', 'سفارش ثبت شد'
PREPARING = 'preparing', 'در حال آماده‌سازی'
READY_TO_SHIP = 'ready_to_ship', 'آماده ارسال'
HANDED_TO_COURIER = 'handed_to_courier', 'تحویل سفیر شد'
DELIVERED = 'delivered', 'تحویل داده شد'
CANCELLED = 'cancelled', 'لغو شده'
CANCELLABLE_STATUSES = (Status.PLACED, Status.PREPARING)
group = models.ForeignKey(OrderGroup, on_delete=models.PROTECT, related_name='orders')
store = models.ForeignKey(Store, on_delete=models.PROTECT, related_name='orders')
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='orders')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PLACED)
items_subtotal = models.PositiveBigIntegerField()
delivery_fee = models.PositiveBigIntegerField(default=0)
commission_amount = models.PositiveBigIntegerField(default=0)
seller_payout_amount = models.PositiveBigIntegerField(default=0)
cancel_reason = models.TextField(blank=True)
courier_name = models.CharField(max_length=150, blank=True)
courier_phone = models.CharField(max_length=20, blank=True)
placed_at = models.DateTimeField(auto_now_add=True)
delivered_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'سفارش'
verbose_name_plural = 'سفارش‌ها'
def __str__(self):
return f'سفارش {self.uuid} - {self.store.name}'
@property
def total_amount(self):
return self.items_subtotal + self.delivery_fee
class OrderItem(BaseModel):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, related_name='+')
product_name_snapshot = models.CharField(max_length=200)
unit_price_snapshot = models.PositiveBigIntegerField()
quantity = models.PositiveIntegerField()
class Meta:
verbose_name = 'قلم سفارش'
verbose_name_plural = 'اقلام سفارش'
def __str__(self):
return f'{self.product_name_snapshot} x{self.quantity}'
@property
def line_total(self):
return self.unit_price_snapshot * self.quantity
class OrderStatusLog(BaseModel):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='status_logs')
from_status = models.CharField(max_length=20, blank=True)
to_status = models.CharField(max_length=20)
changed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='+',
)
note = models.TextField(blank=True)
class Meta:
ordering = ['created_at']
verbose_name = 'تاریخچه وضعیت سفارش'
verbose_name_plural = 'تاریخچه وضعیت سفارش‌ها'
def __str__(self):
return f'{self.order_id}: {self.from_status} -> {self.to_status}'
class Notification(BaseModel):
class Type(models.TextChoices):
NEW_ORDER = 'new_order', 'سفارش جدید'
SETTLEMENT_DONE = 'settlement_done', 'تسویه حساب'
NEW_REVIEW = 'new_review', 'نظر جدید'
ORDER_CANCELLED = 'order_cancelled', 'لغو سفارش'
recipient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications')
type = models.CharField(max_length=20, choices=Type.choices)
title = models.CharField(max_length=200)
body = models.TextField(blank=True)
related_order = models.ForeignKey(
Order, on_delete=models.SET_NULL, null=True, blank=True, related_name='notifications',
)
is_read = models.BooleanField(default=False)
class Meta:
ordering = ['-created_at']
verbose_name = 'اعلان'
verbose_name_plural = 'اعلان‌ها'
def __str__(self):
return f'{self.title} - {self.recipient}'

134
apps/orders/serializers.py Normal file
View file

@ -0,0 +1,134 @@
from rest_framework import serializers
from apps.catalog.models import Product
from apps.catalog.serializers import ProductListSerializer
from apps.locations.models import Address
from apps.stores.serializers import StoreListSerializer
from .models import CartItem, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
class CartItemSerializer(serializers.ModelSerializer):
product = ProductListSerializer(read_only=True)
line_total = serializers.IntegerField(read_only=True)
class Meta:
model = CartItem
fields = ('uuid', 'product', 'quantity', 'line_total')
class CartItemWriteSerializer(serializers.Serializer):
product_uuid = serializers.PrimaryKeyRelatedField(
source='product', queryset=Product.objects.filter(is_active=True),
)
quantity = serializers.IntegerField(min_value=1, default=1)
class StoreCartGroupSerializer(serializers.Serializer):
"""One store's slice of a multi-store cart (see the C07 mock)."""
store = StoreListSerializer(read_only=True)
items = CartItemSerializer(many=True, read_only=True)
items_subtotal = serializers.IntegerField(read_only=True)
delivery_fee = serializers.IntegerField(read_only=True)
total = serializers.IntegerField(read_only=True)
meets_minimum_order = serializers.BooleanField(read_only=True)
class CartSerializer(serializers.Serializer):
"""Groups the authenticated user's cart items by store for the multi-store-cart UI."""
groups = serializers.SerializerMethodField()
grand_total = serializers.SerializerMethodField()
def _build_groups(self, cart):
if not hasattr(self, '_groups_cache'):
from .services import build_cart_groups
self._groups_cache = build_cart_groups(cart)
return self._groups_cache
def get_groups(self, cart):
return StoreCartGroupSerializer(self._build_groups(cart), many=True, context=self.context).data
def get_grand_total(self, cart):
return sum(group['total'] for group in self._build_groups(cart))
class OrderItemSerializer(serializers.ModelSerializer):
line_total = serializers.IntegerField(read_only=True)
class Meta:
model = OrderItem
fields = ('uuid', 'product', 'product_name_snapshot', 'unit_price_snapshot', 'quantity', 'line_total')
class OrderStatusLogSerializer(serializers.ModelSerializer):
class Meta:
model = OrderStatusLog
fields = ('from_status', 'to_status', 'note', 'created_at')
class OrderSerializer(serializers.ModelSerializer):
store = StoreListSerializer(read_only=True)
items = OrderItemSerializer(many=True, read_only=True)
status_logs = OrderStatusLogSerializer(many=True, read_only=True)
total_amount = serializers.IntegerField(read_only=True)
is_cancellable = serializers.SerializerMethodField()
class Meta:
model = Order
fields = (
'uuid', 'store', 'status', 'items', 'items_subtotal', 'delivery_fee',
'commission_amount', 'seller_payout_amount', 'total_amount', 'cancel_reason',
'courier_name', 'courier_phone', 'placed_at', 'delivered_at', 'status_logs',
'is_cancellable',
)
def get_is_cancellable(self, order):
return order.status in Order.CANCELLABLE_STATUSES
class OrderGroupSerializer(serializers.ModelSerializer):
orders = OrderSerializer(many=True, read_only=True)
class Meta:
model = OrderGroup
fields = (
'uuid', 'recipient_name', 'recipient_phone', 'full_address',
'delivery_type', 'scheduled_at', 'payment_method', 'payment_status',
'notes', 'total_amount', 'orders', 'created_at',
)
class CheckoutSerializer(serializers.Serializer):
address_uuid = serializers.PrimaryKeyRelatedField(source='address', queryset=Address.objects.all())
delivery_type = serializers.ChoiceField(choices=OrderGroup.DeliveryType.choices, default=OrderGroup.DeliveryType.EXPRESS)
scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None)
payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices)
notes = serializers.CharField(required=False, allow_blank=True, default='')
def validate(self, attrs):
if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'):
raise serializers.ValidationError({'scheduled_at': 'برای ارسال زمان‌بندی شده، زمان را مشخص کنید.'})
address = attrs['address']
request = self.context['request']
if address.user_id != request.user.pk:
raise serializers.ValidationError({'address_uuid': 'این آدرس متعلق به شما نیست.'})
return attrs
class OrderCancelSerializer(serializers.Serializer):
reason = serializers.CharField(required=False, allow_blank=True, default='')
class OrderStatusActionSerializer(serializers.Serializer):
note = serializers.CharField(required=False, allow_blank=True, default='')
courier_name = serializers.CharField(required=False, allow_blank=True, default='')
courier_phone = serializers.CharField(required=False, allow_blank=True, default='')
class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = ('uuid', 'type', 'title', 'body', 'related_order', 'is_read', 'created_at')
read_only_fields = fields

179
apps/orders/services.py Normal file
View file

@ -0,0 +1,179 @@
from itertools import groupby
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from utils.exceptions import Conflict
from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
def build_cart_groups(cart):
"""Groups a Cart's items by store, computing each store's subtotal/delivery fee/total.
Shared by the cart-retrieve serializer and checkout, since checkout must split a
multi-store cart into one Order per store using the same numbers the customer saw
in their cart.
"""
items = list(
cart.items.select_related('product', 'product__store').order_by('product__store_id')
)
groups = []
for store, store_items in groupby(items, key=lambda item: item.product.store):
store_items = list(store_items)
items_subtotal = sum(item.line_total for item in store_items)
meets_minimum = items_subtotal >= store.min_order_amount
delivery_fee = 0 if (
store.free_delivery_threshold and items_subtotal >= store.free_delivery_threshold
) else store.delivery_fee
groups.append({
'store': store,
'items': store_items,
'items_subtotal': items_subtotal,
'delivery_fee': delivery_fee,
'total': items_subtotal + delivery_fee,
'meets_minimum_order': meets_minimum,
})
return groups
def notify(recipient, type, title, body='', related_order=None):
return Notification.objects.create(
recipient=recipient, type=type, title=title, body=body, related_order=related_order,
)
def _build_full_address(address):
parts = [address.city.name]
if address.neighborhood:
parts.append(address.neighborhood.name)
parts.append(address.full_address)
for label, value in (('پلاک', address.plaque), ('طبقه', address.floor), ('واحد', address.unit)):
if value:
parts.append(f'{label} {value}')
return '، '.join(parts)
@transaction.atomic
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
"""Splits the user's multi-store cart into one Order per store."""
cart = Cart.objects.filter(user=user).first()
if not cart or not cart.items.exists():
raise ValidationError({'cart': 'سبد خرید شما خالی است.'})
groups = build_cart_groups(cart)
for group in groups:
store = group['store']
if not group['meets_minimum_order']:
raise ValidationError({
'cart': f'حداقل مبلغ سفارش از فروشگاه «{store.name}» {store.min_order_amount} تومان است.',
})
if store.service_neighborhoods.exists() and address.neighborhood_id and not store.service_neighborhoods.filter(
uuid=address.neighborhood_id,
).exists():
raise ValidationError({
'address_uuid': f'فروشگاه «{store.name}» به محله انتخابی شما ارسال ندارد.',
})
for item in group['items']:
if item.quantity > item.product.stock_quantity:
raise ValidationError({'cart': f'موجودی «{item.product.name}» کافی نیست.'})
total_amount = sum(group['total'] for group in groups)
order_group = OrderGroup.objects.create(
user=user,
recipient_name=address.recipient_name,
recipient_phone=address.recipient_phone,
full_address=_build_full_address(address),
delivery_type=delivery_type,
scheduled_at=scheduled_at,
payment_method=payment_method,
notes=notes,
total_amount=total_amount,
)
for group in groups:
store = group['store']
commission_amount = round(group['items_subtotal'] * float(store.commission_percent_effective) / 100)
order = Order.objects.create(
group=order_group,
store=store,
customer=user,
items_subtotal=group['items_subtotal'],
delivery_fee=group['delivery_fee'],
commission_amount=commission_amount,
seller_payout_amount=group['items_subtotal'] - commission_amount,
)
OrderItem.objects.bulk_create([
OrderItem(
order=order,
product=item.product,
product_name_snapshot=item.product.name,
unit_price_snapshot=item.product.price,
quantity=item.quantity,
)
for item in group['items']
])
OrderStatusLog.objects.create(order=order, from_status='', to_status=Order.Status.PLACED)
notify(
recipient=store.owner,
type=Notification.Type.NEW_ORDER,
title='سفارش جدید دریافت شد',
body=f'سفارش شماره {order.uuid} ثبت شد. لطفاً برای آماده‌سازی اقدام کنید.',
related_order=order,
)
for item in group['items']:
item.product.stock_quantity -= item.quantity
item.product.sold_count += item.quantity
item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at'])
cart.items.all().delete()
return order_group
def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
from_status = order.status
order.status = to_status
for field, value in extra_fields.items():
setattr(order, field, value)
if to_status == Order.Status.DELIVERED:
order.delivered_at = timezone.now()
order.save()
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:
notify(
recipient=order.store.owner,
type=Notification.Type.ORDER_CANCELLED,
title='سفارش لغو شد',
body=f'سفارش شماره {order.uuid} لغو شد.' + (f' دلیل: {note}' if note else ''),
related_order=order,
)
return order
_ALLOWED_TRANSITIONS = {
Order.Status.PLACED: {Order.Status.PREPARING, Order.Status.CANCELLED},
Order.Status.PREPARING: {Order.Status.READY_TO_SHIP, Order.Status.CANCELLED},
Order.Status.READY_TO_SHIP: {Order.Status.HANDED_TO_COURIER},
Order.Status.HANDED_TO_COURIER: {Order.Status.DELIVERED},
}
def advance_status(order, to_status, *, changed_by=None, **kwargs):
allowed = _ALLOWED_TRANSITIONS.get(order.status, set())
if to_status not in allowed:
raise Conflict(f'امکان تغییر وضعیت سفارش از «{order.get_status_display()}» به این حالت وجود ندارد.')
return _transition(order, to_status, changed_by=changed_by, **kwargs)
def cancel_order(order, *, changed_by=None, reason=''):
if order.status not in Order.CANCELLABLE_STATUSES:
raise Conflict('این سفارش دیگر قابل لغو نیست.')
return _transition(order, Order.Status.CANCELLED, changed_by=changed_by, note=reason, cancel_reason=reason)

View file

63
apps/orders/tests/base.py Normal file
View file

@ -0,0 +1,63 @@
import uuid
from rest_framework.test import APITestCase
from apps.catalog.models import Product, ProductCategory
from apps.locations.models import Address, City, Neighborhood
from apps.stores.models import Store, StoreCategory
from apps.users.models import User
class OrdersTestCase(APITestCase):
@classmethod
def setUpTestData(cls):
cls.customer = User.objects.create_user(pk=uuid.uuid4(), username='customer1')
cls.seller1 = User.objects.create_user(pk=uuid.uuid4(), username='seller1')
cls.seller2 = User.objects.create_user(pk=uuid.uuid4(), username='seller2')
cls.city = City.objects.create(name='تهران', slug='tehran')
cls.neighborhood = Neighborhood.objects.create(city=cls.city, name='ونک', slug='vanak')
cls.other_neighborhood = Neighborhood.objects.create(city=cls.city, name='تجریش', slug='tajrish')
cls.address = Address.objects.create(
user=cls.customer,
city=cls.city,
neighborhood=cls.neighborhood,
full_address='خیابان ولیعصر',
recipient_name='مشتری تست',
recipient_phone='09120000000',
)
store_category = StoreCategory.objects.create(name='سوپرمارکت')
product_category = ProductCategory.objects.create(name='لبنیات')
cls.store1 = Store.objects.create(
owner=cls.seller1, category=store_category, name='فروشگاه یک',
city=cls.city, address='آدرس فروشگاه یک', status=Store.Status.APPROVED,
min_order_amount=10_000, delivery_fee=15_000, commission_percent=4,
)
cls.store1.service_neighborhoods.add(cls.neighborhood)
cls.store2 = Store.objects.create(
owner=cls.seller2, category=store_category, name='فروشگاه دو',
city=cls.city, address='آدرس فروشگاه دو', status=Store.Status.APPROVED,
min_order_amount=10_000, delivery_fee=20_000, commission_percent=4,
)
cls.store2.service_neighborhoods.add(cls.neighborhood)
cls.product1 = Product.objects.create(
store=cls.store1, category=product_category, name='شیر کاله',
price=28_500, stock_quantity=50,
)
cls.product2 = Product.objects.create(
store=cls.store2, category=product_category, name='پنیر لیقوان',
price=85_000, stock_quantity=50,
)
def add_to_cart(self, product, quantity=1):
return self.client.post('/api/v1/cart/items/', {'product_uuid': str(product.uuid), 'quantity': quantity})
def mock_external_clients(self):
"""No-op on this branch — payment integration (and its outbound Gooyal/ipg
calls) lives on feature/payment, not here."""
pass

View file

@ -0,0 +1,43 @@
from .base import OrdersTestCase
class CartTests(OrdersTestCase):
def setUp(self):
self.client.force_authenticate(user=self.customer)
def test_cart_groups_items_by_store(self):
self.add_to_cart(self.product1, quantity=2)
self.add_to_cart(self.product2, quantity=1)
response = self.client.get('/api/v1/cart/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data['groups']), 2)
self.assertEqual(response.data['grand_total'], (28_500 * 2 + 15_000) + (85_000 + 20_000))
def test_adding_same_product_twice_increments_quantity(self):
self.add_to_cart(self.product1, quantity=1)
self.add_to_cart(self.product1, quantity=2)
response = self.client.get('/api/v1/cart/')
items = response.data['groups'][0]['items']
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['quantity'], 3)
def test_free_delivery_threshold_zeroes_delivery_fee(self):
self.store1.free_delivery_threshold = 50_000
self.store1.save()
self.add_to_cart(self.product1, quantity=2) # 57,000 >= 50,000 threshold
response = self.client.get('/api/v1/cart/')
self.assertEqual(response.data['groups'][0]['delivery_fee'], 0)
def test_remove_cart_item(self):
add_response = self.add_to_cart(self.product1, quantity=1)
item_uuid = add_response.data['uuid']
response = self.client.delete(f'/api/v1/cart/items/{item_uuid}/')
self.assertEqual(response.status_code, 204)
cart_response = self.client.get('/api/v1/cart/')
self.assertEqual(cart_response.data['groups'], [])

View file

@ -0,0 +1,69 @@
from apps.orders.models import Cart, Order, OrderGroup
from .base import OrdersTestCase
class CheckoutTests(OrdersTestCase):
def setUp(self):
self.mock_external_clients()
self.client.force_authenticate(user=self.customer)
def test_checkout_splits_multi_store_cart_into_one_order_per_store(self):
self.add_to_cart(self.product1, quantity=2)
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,
})
self.assertEqual(response.status_code, 201, response.data)
group = OrderGroup.objects.get(uuid=response.data['uuid'])
self.assertEqual(group.orders.count(), 2)
order1 = group.orders.get(store=self.store1)
self.assertEqual(order1.items_subtotal, 28_500 * 2)
self.assertEqual(order1.delivery_fee, 15_000)
self.assertEqual(order1.commission_amount, round(57_000 * 0.04))
self.assertEqual(order1.seller_payout_amount, 57_000 - order1.commission_amount)
self.assertEqual(order1.status, Order.Status.PLACED)
# Cart is cleared and stock decremented after a successful checkout.
self.assertFalse(Cart.objects.get(user=self.customer).items.exists())
self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 48)
def test_checkout_rejects_empty_cart(self):
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
})
self.assertEqual(response.status_code, 400)
def test_checkout_rejects_insufficient_stock(self):
self.add_to_cart(self.product1, quantity=999)
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
})
self.assertEqual(response.status_code, 400)
def test_checkout_rejects_address_outside_store_coverage(self):
self.store1.service_neighborhoods.set([self.other_neighborhood])
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,
})
self.assertEqual(response.status_code, 400)
def test_checkout_leaves_payment_status_pending_regardless_of_method(self):
"""No payment integration on this branch — see feature/payment."""
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.WALLET,
})
self.assertEqual(response.status_code, 201, response.data)
group = OrderGroup.objects.get(uuid=response.data['uuid'])
self.assertEqual(group.payment_status, OrderGroup.PaymentStatus.PENDING)

View file

@ -0,0 +1,60 @@
from apps.orders.models import Order, OrderGroup
from .base import OrdersTestCase
class StatusTransitionTests(OrdersTestCase):
def setUp(self):
self.mock_external_clients()
self.client.force_authenticate(user=self.customer)
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,
})
self.order = Order.objects.get(uuid=response.data['orders'][0]['uuid'])
def _as_seller(self):
self.client.force_authenticate(user=self.seller1)
def test_seller_can_walk_the_full_status_stepper(self):
self._as_seller()
for action, expected_status in (
('confirm', Order.Status.PREPARING),
('mark-ready', Order.Status.READY_TO_SHIP),
('mark-shipped', Order.Status.HANDED_TO_COURIER),
('mark-delivered', Order.Status.DELIVERED),
):
response = self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/{action}/')
self.assertEqual(response.status_code, 200, response.data)
self.order.refresh_from_db()
self.assertEqual(self.order.status, expected_status)
self.assertEqual(self.order.status_logs.count(), 5) # placed + 4 transitions
def test_cannot_skip_a_status(self):
self._as_seller()
response = self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/mark-delivered/')
self.assertEqual(response.status_code, 409)
def test_other_seller_cannot_act_on_this_order(self):
self.client.force_authenticate(user=self.seller2)
response = self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/confirm/')
self.assertEqual(response.status_code, 404)
def test_customer_can_cancel_before_preparing(self):
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.order.refresh_from_db()
self.assertEqual(self.order.status, Order.Status.CANCELLED)
def test_cannot_cancel_after_handed_to_courier(self):
self._as_seller()
for action in ('confirm', 'mark-ready', 'mark-shipped'):
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/{action}/')
self.client.force_authenticate(user=self.customer)
response = self.client.post(f'/api/v1/orders/{self.order.uuid}/cancel/')
self.assertEqual(response.status_code, 409)

27
apps/orders/urls.py Normal file
View file

@ -0,0 +1,27 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import (
CartItemView,
CartView,
CheckoutView,
NotificationViewSet,
OrderGroupViewSet,
OrderViewSet,
SellerOrderViewSet,
)
app_name = "orders"
router = DefaultRouter()
router.register('order-groups', OrderGroupViewSet, basename='order-group')
router.register('orders', OrderViewSet, basename='order')
router.register('seller/orders', SellerOrderViewSet, basename='seller-order')
router.register('notifications', NotificationViewSet, basename='notification')
urlpatterns = router.urls + [
path('checkout/', CheckoutView.as_view(), name='checkout'),
path('cart/', CartView.as_view(), name='cart'),
path('cart/items/', CartItemView.as_view(), name='cart-items'),
path('cart/items/<uuid:item_uuid>/', CartItemView.as_view(), name='cart-item-detail'),
]

205
apps/orders/views.py Normal file
View file

@ -0,0 +1,205 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.permissions import IsStoreOwner
from . import services
from .models import Cart, CartItem, Notification, Order, OrderGroup
from .serializers import (
CartItemSerializer,
CartItemWriteSerializer,
CartSerializer,
CheckoutSerializer,
NotificationSerializer,
OrderCancelSerializer,
OrderGroupSerializer,
OrderSerializer,
OrderStatusActionSerializer,
)
class CartView(APIView):
"""The authenticated user's cart, grouped by store (C07)."""
schema_tags = ['Cart']
permission_classes = [IsAuthenticated]
serializer_class = CartSerializer
def get(self, request):
cart, _ = Cart.objects.get_or_create(user=request.user)
return Response(CartSerializer(cart, context={'request': request}).data)
@extend_schema(responses=None)
def delete(self, request):
cart, _ = Cart.objects.get_or_create(user=request.user)
cart.items.all().delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CartItemView(APIView):
"""Add/update/remove a single product line in the authenticated user's cart."""
schema_tags = ['Cart']
permission_classes = [IsAuthenticated]
serializer_class = CartItemWriteSerializer
@extend_schema(request=CartItemWriteSerializer, responses=CartItemSerializer)
def post(self, request):
serializer = CartItemWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
cart, _ = Cart.objects.get_or_create(user=request.user)
product = serializer.validated_data['product']
quantity = serializer.validated_data['quantity']
item, created = CartItem.objects.get_or_create(
cart=cart, product=product, defaults={'quantity': quantity},
)
if not created:
item.quantity += quantity
item.save(update_fields=['quantity', 'updated_at'])
return Response(CartItemSerializer(item).data, status=status.HTTP_201_CREATED)
@extend_schema(responses=CartItemSerializer)
def patch(self, request, item_uuid):
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
quantity = request.data.get('quantity')
if quantity is None or int(quantity) < 1:
return Response({'quantity': 'مقدار باید حداقل ۱ باشد.'}, status=status.HTTP_400_BAD_REQUEST)
item.quantity = int(quantity)
item.save(update_fields=['quantity', 'updated_at'])
return Response(CartItemSerializer(item).data)
@extend_schema(responses=None)
def delete(self, request, item_uuid):
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
item.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CheckoutView(APIView):
"""Splits the authenticated customer's multi-store cart into per-store orders (C08)."""
schema_tags = ['Checkout']
permission_classes = [IsAuthenticated]
serializer_class = CheckoutSerializer
@extend_schema(request=CheckoutSerializer, responses=OrderGroupSerializer)
def post(self, request):
serializer = CheckoutSerializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
order_group = services.checkout(user=request.user, **serializer.validated_data)
data = OrderGroupSerializer(order_group).data
return Response(data, status=status.HTTP_201_CREATED)
class OrderGroupViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer order history — each group may contain orders from several stores."""
schema_tags = ['Orders']
permission_classes = [IsAuthenticated]
serializer_class = OrderGroupSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return OrderGroup.objects.none()
return OrderGroup.objects.filter(user=self.request.user).prefetch_related('orders__items')
class OrderViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing single-order tracking (C09) + cancel."""
schema_tags = ['Orders']
permission_classes = [IsAuthenticated]
serializer_class = OrderSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Order.objects.none()
return Order.objects.filter(customer=self.request.user).select_related('store')
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
order = self.get_object()
serializer = OrderCancelSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
services.cancel_order(order, changed_by=request.user, reason=serializer.validated_data['reason'])
return Response(OrderSerializer(order).data)
class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Seller order management (S09 list w/ status tabs, S10 detail + stepper actions)."""
schema_tags = ['Seller · Orders']
permission_classes = [IsAuthenticated, IsStoreOwner]
serializer_class = OrderSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Order.objects.none()
queryset = Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items')
status_param = self.request.query_params.get('status')
if status_param:
queryset = queryset.filter(status=status_param)
return queryset
def _apply_transition(self, request, to_status):
order = self.get_object()
serializer = OrderStatusActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
services.advance_status(order, to_status, changed_by=request.user, **serializer.validated_data)
return Response(OrderSerializer(order).data)
@action(detail=True, methods=['post'])
def confirm(self, request, pk=None):
return self._apply_transition(request, Order.Status.PREPARING)
@action(detail=True, methods=['post'], url_path='mark-ready')
def mark_ready(self, request, pk=None):
return self._apply_transition(request, Order.Status.READY_TO_SHIP)
@action(detail=True, methods=['post'], url_path='mark-shipped')
def mark_shipped(self, request, pk=None):
return self._apply_transition(request, Order.Status.HANDED_TO_COURIER)
@action(detail=True, methods=['post'], url_path='mark-delivered')
def mark_delivered(self, request, pk=None):
return self._apply_transition(request, Order.Status.DELIVERED)
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
order = self.get_object()
serializer = OrderCancelSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
services.cancel_order(order, changed_by=request.user, reason=serializer.validated_data['reason'])
return Response(OrderSerializer(order).data)
class NotificationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Shared notification feed (S16 for sellers; same model serves the customer app)."""
schema_tags = ['Notifications']
permission_classes = [IsAuthenticated]
serializer_class = NotificationSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Notification.objects.none()
return Notification.objects.filter(recipient=self.request.user)
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save(update_fields=['is_read', 'updated_at'])
return Response(NotificationSerializer(notification).data)
@action(detail=False, methods=['post'], url_path='mark-all-read')
def mark_all_read(self, request):
self.get_queryset().filter(is_read=False).update(is_read=True)
return Response(status=204)

0
apps/reviews/__init__.py Normal file
View file

10
apps/reviews/admin.py Normal file
View file

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Review
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ('store', 'customer', 'rating', 'created_at')
list_filter = ('rating', 'store')
search_fields = ('comment', 'customer__username', 'store__name')

6
apps/reviews/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.reviews'

View file

@ -0,0 +1,42 @@
# Generated by Django 6.0.2 on 2026-08-10 10:30
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('catalog', '0001_initial'),
('orders', '0001_initial'),
('stores', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('rating', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
('comment', models.TextField(blank=True)),
('seller_reply', models.TextField(blank=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='orders.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviews', to='catalog.product')),
('store', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='stores.store')),
],
options={
'verbose_name': 'نظر',
'verbose_name_plural': 'نظرات',
'ordering': ['-created_at'],
},
),
]

View file

28
apps/reviews/models.py Normal file
View file

@ -0,0 +1,28 @@
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from apps.catalog.models import Product
from apps.orders.models import Order
from apps.stores.models import Store
from utils.models import BaseModel
class Review(BaseModel):
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='review')
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='reviews')
store = models.ForeignKey(Store, on_delete=models.CASCADE, related_name='reviews')
product = models.ForeignKey(
Product, on_delete=models.SET_NULL, null=True, blank=True, related_name='reviews',
)
rating = models.PositiveSmallIntegerField(validators=[MinValueValidator(1), MaxValueValidator(5)])
comment = models.TextField(blank=True)
seller_reply = models.TextField(blank=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'نظر'
verbose_name_plural = 'نظرات'
def __str__(self):
return f'{self.store.name} - {self.rating}/5'

View file

@ -0,0 +1,55 @@
from rest_framework import serializers
from apps.orders.models import Order
from .models import Review
class ReviewSerializer(serializers.ModelSerializer):
order_uuid = serializers.PrimaryKeyRelatedField(source='order', queryset=Order.objects.all(), write_only=True)
customer_name = serializers.CharField(source='customer.username', read_only=True)
class Meta:
model = Review
fields = (
'uuid', 'order_uuid', 'store', 'product', 'rating', 'comment',
'seller_reply', 'customer_name', 'created_at',
)
read_only_fields = ('store', 'product', 'seller_reply')
def validate(self, attrs):
order = attrs['order']
request = self.context['request']
if order.customer_id != request.user.pk:
raise serializers.ValidationError({'order_uuid': 'این سفارش متعلق به شما نیست.'})
if order.status != Order.Status.DELIVERED:
raise serializers.ValidationError({'order_uuid': 'فقط سفارش‌های تحویل داده شده را می‌توان نظر داد.'})
if hasattr(order, 'review'):
raise serializers.ValidationError({'order_uuid': 'برای این سفارش قبلاً نظر ثبت شده است.'})
return attrs
def create(self, validated_data):
order = validated_data['order']
validated_data['customer'] = self.context['request'].user
validated_data['store'] = order.store
validated_data['product'] = order.items.first().product if order.items.exists() else None
review = super().create(validated_data)
from .services import refresh_store_rating
refresh_store_rating(review.store)
from apps.orders.services import notify
from apps.orders.models import Notification
notify(
recipient=review.store.owner,
type=Notification.Type.NEW_REVIEW,
title='ثبت نظر جدید',
body=f'مشتری برای سفارش {order.uuid} یک نظر {review.rating} ستاره ثبت کرد.',
related_order=order,
)
return review
class SellerReplySerializer(serializers.Serializer):
seller_reply = serializers.CharField()

8
apps/reviews/services.py Normal file
View file

@ -0,0 +1,8 @@
from django.db.models import Avg, Count
def refresh_store_rating(store):
aggregate = store.reviews.aggregate(avg=Avg('rating'), count=Count('uuid'))
store.rating_avg = aggregate['avg'] or 0
store.rating_count = aggregate['count'] or 0
store.save(update_fields=['rating_avg', 'rating_count', 'updated_at'])

View file

View file

@ -0,0 +1,56 @@
from apps.orders.models import Order, OrderGroup
from apps.reviews.models import Review
from apps.orders.tests.base import OrdersTestCase
class ReviewTests(OrdersTestCase):
def setUp(self):
self.mock_external_clients()
self.client.force_authenticate(user=self.customer)
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,
})
self.order = Order.objects.get(uuid=response.data['orders'][0]['uuid'])
def _deliver_order(self):
self.client.force_authenticate(user=self.seller1)
for action in ('confirm', 'mark-ready', 'mark-shipped', 'mark-delivered'):
self.client.post(f'/api/v1/seller/orders/{self.order.uuid}/{action}/')
self.client.force_authenticate(user=self.customer)
def test_cannot_review_before_delivery(self):
response = self.client.post('/api/v1/reviews/', {
'order_uuid': str(self.order.uuid), 'rating': 5, 'comment': 'خوب بود',
})
self.assertEqual(response.status_code, 400)
def test_can_review_after_delivery_and_store_rating_updates(self):
self._deliver_order()
response = self.client.post('/api/v1/reviews/', {
'order_uuid': str(self.order.uuid), 'rating': 4, 'comment': 'راضی بودم',
})
self.assertEqual(response.status_code, 201, response.data)
self.store1.refresh_from_db()
self.assertEqual(self.store1.rating_avg, 4)
self.assertEqual(self.store1.rating_count, 1)
def test_cannot_review_the_same_order_twice(self):
self._deliver_order()
self.client.post('/api/v1/reviews/', {'order_uuid': str(self.order.uuid), 'rating': 4})
response = self.client.post('/api/v1/reviews/', {'order_uuid': str(self.order.uuid), 'rating': 5})
self.assertEqual(response.status_code, 400)
def test_seller_can_reply_to_a_review(self):
self._deliver_order()
self.client.post('/api/v1/reviews/', {'order_uuid': str(self.order.uuid), 'rating': 4})
review = Review.objects.get(order=self.order)
self.client.force_authenticate(user=self.seller1)
response = self.client.post(f'/api/v1/seller/reviews/{review.uuid}/reply/', {'seller_reply': 'ممنون از شما'})
self.assertEqual(response.status_code, 200, response.data)
review.refresh_from_db()
self.assertEqual(review.seller_reply, 'ممنون از شما')

11
apps/reviews/urls.py Normal file
View file

@ -0,0 +1,11 @@
from rest_framework.routers import DefaultRouter
from .views import ReviewViewSet, SellerReviewViewSet
app_name = "reviews"
router = DefaultRouter()
router.register('reviews', ReviewViewSet, basename='review')
router.register('seller/reviews', SellerReviewViewSet, basename='seller-review')
urlpatterns = router.urls

50
apps/reviews/views.py Normal file
View file

@ -0,0 +1,50 @@
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from apps.core.permissions import IsStoreOwner
from .models import Review
from .serializers import ReviewSerializer, SellerReplySerializer
class ReviewViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet):
"""Customer reviews — create after a delivered order, list is public per store."""
schema_tags = ['Reviews']
serializer_class = ReviewSerializer
def get_permissions(self):
if self.action == 'create':
return [IsAuthenticated()]
return [AllowAny()]
def get_queryset(self):
queryset = Review.objects.select_related('customer', 'store')
store_uuid = self.request.query_params.get('store')
if store_uuid:
queryset = queryset.filter(store__uuid=store_uuid)
return queryset
class SellerReviewViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
"""Reviews left for the authenticated seller's store, with reply support."""
schema_tags = ['Seller · Reviews']
permission_classes = [IsAuthenticated, IsStoreOwner]
serializer_class = ReviewSerializer
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False):
return Review.objects.none()
return Review.objects.filter(store=self.request.user.store).select_related('customer')
@action(detail=True, methods=['post'])
def reply(self, request, pk=None):
review = self.get_object()
serializer = SellerReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
review.seller_reply = serializer.validated_data['seller_reply']
review.save(update_fields=['seller_reply', 'updated_at'])
return Response(ReviewSerializer(review).data)

0
apps/stores/__init__.py Normal file
View file

26
apps/stores/admin.py Normal file
View file

@ -0,0 +1,26 @@
from django.contrib import admin
from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
@admin.register(StoreCategory)
class StoreCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'order')
class StoreWorkingHoursInline(admin.TabularInline):
model = StoreWorkingHours
extra = 0
class StoreBankAccountInline(admin.StackedInline):
model = StoreBankAccount
extra = 0
@admin.register(Store)
class StoreAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'category', 'city', 'status', 'is_open', 'rating_avg')
list_filter = ('status', 'is_open', 'category', 'city')
search_fields = ('name', 'owner__username', 'phone_number')
inlines = [StoreWorkingHoursInline, StoreBankAccountInline]

77
apps/stores/analytics.py Normal file
View file

@ -0,0 +1,77 @@
from django.db.models import Count, F, Sum
from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.permissions import IsStoreOwner
from apps.orders.models import Order, OrderItem
from .serializers import SellerAnalyticsSerializer
PERIOD_START = {
'today': lambda now: now.replace(hour=0, minute=0, second=0, microsecond=0),
'week': lambda now: now - timezone.timedelta(days=7),
'month': lambda now: now - timezone.timedelta(days=30),
}
class SellerAnalyticsView(APIView):
"""Seller sales dashboard (S17): daily chart, stat cards, top-selling products."""
schema_tags = ['Seller · Analytics']
permission_classes = [IsAuthenticated, IsStoreOwner]
serializer_class = SellerAnalyticsSerializer
def get(self, request):
period = request.query_params.get('period', 'today')
start_fn = PERIOD_START.get(period, PERIOD_START['today'])
start = start_fn(timezone.now())
store = request.user.store
period_orders = Order.objects.filter(store=store, created_at__gte=start)
order_count = period_orders.count()
cancelled_count = period_orders.filter(status=Order.Status.CANCELLED).count()
cancellation_rate = round(cancelled_count / order_count * 100, 1) if order_count else 0
delivered_orders = period_orders.filter(status=Order.Status.DELIVERED)
total_sales = sum(o.total_amount for o in delivered_orders)
delivered_count = delivered_orders.count()
average_order_value = round(total_sales / delivered_count) if delivered_count else 0
trunc = TruncHour if period == 'today' else TruncDate
chart = list(
delivered_orders
.annotate(bucket=trunc('created_at'))
.values('bucket')
.annotate(sales=Sum(F('items_subtotal') + F('delivery_fee')))
.order_by('bucket')
)
top_products = list(
OrderItem.objects.filter(order__in=delivered_orders)
.values('product_name_snapshot')
.annotate(
units_sold=Sum('quantity'),
revenue=Sum(F('unit_price_snapshot') * F('quantity')),
)
.order_by('-revenue')[:5]
)
return Response({
'period': period,
'order_count': order_count,
'total_sales': total_sales,
'cancellation_rate': cancellation_rate,
'average_order_value': average_order_value,
'chart': [{'bucket': point['bucket'], 'sales': point['sales']} for point in chart],
'top_products': [
{
'name': p['product_name_snapshot'],
'units_sold': p['units_sold'],
'revenue': p['revenue'],
}
for p in top_products
],
})

6
apps/stores/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class StoresConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.stores'

View file

@ -0,0 +1,107 @@
# Generated by Django 6.0.2 on 2026-08-09 10:29
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('locations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StoreCategory',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=100, unique=True)),
('icon', models.ImageField(blank=True, null=True, upload_to='store_categories/')),
('order', models.PositiveSmallIntegerField(default=0)),
],
options={
'verbose_name': 'دسته\u200cبندی فروشگاه',
'verbose_name_plural': 'دسته\u200cبندی\u200cهای فروشگاه',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='Store',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('name', models.CharField(max_length=150)),
('description', models.TextField(blank=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='store_logos/')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='store_covers/')),
('phone_number', models.CharField(blank=True, max_length=20)),
('address', models.TextField()),
('location', django.contrib.gis.db.models.fields.PointField(blank=True, geography=True, null=True, srid=4326)),
('delivery_radius_km', models.DecimalField(decimal_places=2, default=5, max_digits=5)),
('min_order_amount', models.PositiveBigIntegerField(default=0)),
('delivery_fee', models.PositiveBigIntegerField(default=0)),
('free_delivery_threshold', models.PositiveBigIntegerField(blank=True, null=True)),
('commission_percent', models.DecimalField(blank=True, decimal_places=2, help_text='در صورت خالی بودن، از DEFAULT_COMMISSION_PERCENT استفاده می\u200cشود.', max_digits=5, null=True)),
('accepts_wallet', models.BooleanField(default=True)),
('accepts_online', models.BooleanField(default=True)),
('accepts_cash_on_delivery', models.BooleanField(default=True)),
('rating_avg', models.DecimalField(decimal_places=2, default=0, max_digits=3)),
('rating_count', models.PositiveIntegerField(default=0)),
('status', models.CharField(choices=[('pending', 'در انتظار تایید'), ('approved', 'تایید شده'), ('suspended', 'معلق شده')], default='pending', max_length=10)),
('is_open', models.BooleanField(default=True)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stores', to='locations.city')),
('owner', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='store', to=settings.AUTH_USER_MODEL)),
('service_neighborhoods', models.ManyToManyField(blank=True, help_text='محله\u200cهایی که این فروشگاه به آن\u200cها ارسال دارد.', related_name='stores', to='locations.neighborhood')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stores', to='stores.storecategory')),
],
options={
'verbose_name': 'فروشگاه',
'verbose_name_plural': 'فروشگاه\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='StoreBankAccount',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('iban', models.CharField(max_length=26, validators=[django.core.validators.RegexValidator('^IR\\d{24}$', 'شماره شبا معتبر نیست.')])),
('account_holder_name', models.CharField(max_length=150)),
('store', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bank_account', to='stores.store')),
],
options={
'verbose_name': 'حساب بانکی فروشگاه',
'verbose_name_plural': 'حساب\u200cهای بانکی فروشگاه',
},
),
migrations.CreateModel(
name='StoreWorkingHours',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('weekday', models.PositiveSmallIntegerField(choices=[(0, 'شنبه'), (1, 'یکشنبه'), (2, 'دوشنبه'), (3, 'سه\u200cشنبه'), (4, 'چهارشنبه'), (5, 'پنجشنبه'), (6, 'جمعه')])),
('opens_at', models.TimeField(blank=True, null=True)),
('closes_at', models.TimeField(blank=True, null=True)),
('is_closed', models.BooleanField(default=False)),
('store', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='working_hours', to='stores.store')),
],
options={
'verbose_name': 'ساعت کاری فروشگاه',
'verbose_name_plural': 'ساعات کاری فروشگاه',
'ordering': ['weekday'],
'unique_together': {('store', 'weekday')},
},
),
]

View file

120
apps/stores/models.py Normal file
View file

@ -0,0 +1,120 @@
from django.conf import settings
from django.contrib.gis.db import models as gis_models
from django.core.validators import RegexValidator
from django.db import models
from apps.locations.models import City, Neighborhood
from utils.models import BaseModel
class StoreCategory(BaseModel):
name = models.CharField(max_length=100, unique=True)
icon = models.ImageField(upload_to='store_categories/', null=True, blank=True)
order = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['order', 'name']
verbose_name = 'دسته‌بندی فروشگاه'
verbose_name_plural = 'دسته‌بندی‌های فروشگاه'
def __str__(self):
return self.name
class Store(BaseModel):
class Status(models.TextChoices):
PENDING = 'pending', 'در انتظار تایید'
APPROVED = 'approved', 'تایید شده'
SUSPENDED = 'suspended', 'معلق شده'
owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='store')
category = models.ForeignKey(StoreCategory, on_delete=models.PROTECT, related_name='stores')
name = models.CharField(max_length=150)
description = models.TextField(blank=True)
logo = models.ImageField(upload_to='store_logos/', null=True, blank=True)
cover_image = models.ImageField(upload_to='store_covers/', null=True, blank=True)
phone_number = models.CharField(max_length=20, blank=True)
city = models.ForeignKey(City, on_delete=models.PROTECT, related_name='stores')
address = models.TextField()
location = gis_models.PointField(geography=True, null=True, blank=True)
service_neighborhoods = models.ManyToManyField(
Neighborhood, related_name='stores', blank=True,
help_text='محله‌هایی که این فروشگاه به آن‌ها ارسال دارد.',
)
delivery_radius_km = models.DecimalField(max_digits=5, decimal_places=2, default=5)
min_order_amount = models.PositiveBigIntegerField(default=0)
delivery_fee = models.PositiveBigIntegerField(default=0)
free_delivery_threshold = models.PositiveBigIntegerField(null=True, blank=True)
commission_percent = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text='در صورت خالی بودن، از DEFAULT_COMMISSION_PERCENT استفاده می‌شود.',
)
accepts_wallet = models.BooleanField(default=True)
accepts_online = models.BooleanField(default=True)
accepts_cash_on_delivery = models.BooleanField(default=True)
rating_avg = models.DecimalField(max_digits=3, decimal_places=2, default=0)
rating_count = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
is_open = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'فروشگاه'
verbose_name_plural = 'فروشگاه‌ها'
def __str__(self):
return self.name
@property
def commission_percent_effective(self):
if self.commission_percent is not None:
return self.commission_percent
from django.conf import settings as dj_settings
return dj_settings.DEFAULT_COMMISSION_PERCENT
class StoreWorkingHours(BaseModel):
class Weekday(models.IntegerChoices):
SATURDAY = 0, 'شنبه'
SUNDAY = 1, 'یکشنبه'
MONDAY = 2, 'دوشنبه'
TUESDAY = 3, 'سه‌شنبه'
WEDNESDAY = 4, 'چهارشنبه'
THURSDAY = 5, 'پنجشنبه'
FRIDAY = 6, 'جمعه'
store = models.ForeignKey(Store, on_delete=models.CASCADE, related_name='working_hours')
weekday = models.PositiveSmallIntegerField(choices=Weekday.choices)
opens_at = models.TimeField(null=True, blank=True)
closes_at = models.TimeField(null=True, blank=True)
is_closed = models.BooleanField(default=False)
class Meta:
ordering = ['weekday']
unique_together = ('store', 'weekday')
verbose_name = 'ساعت کاری فروشگاه'
verbose_name_plural = 'ساعات کاری فروشگاه'
def __str__(self):
return f'{self.store.name} - {self.get_weekday_display()}'
class StoreBankAccount(BaseModel):
store = models.OneToOneField(Store, on_delete=models.CASCADE, related_name='bank_account')
iban = models.CharField(
max_length=26,
validators=[RegexValidator(r'^IR\d{24}$', 'شماره شبا معتبر نیست.')],
)
account_holder_name = models.CharField(max_length=150)
class Meta:
verbose_name = 'حساب بانکی فروشگاه'
verbose_name_plural = 'حساب‌های بانکی فروشگاه'
def __str__(self):
return f'{self.store.name} - {self.iban}'

116
apps/stores/serializers.py Normal file
View file

@ -0,0 +1,116 @@
from django.contrib.gis.geos import Point
from rest_framework import serializers
from apps.locations.models import City, Neighborhood
from apps.locations.serializers import CitySerializer, NeighborhoodSerializer
from .models import Store, StoreBankAccount, StoreCategory, StoreWorkingHours
class StoreCategorySerializer(serializers.ModelSerializer):
class Meta:
model = StoreCategory
fields = ('uuid', 'name', 'icon', 'order')
class StoreWorkingHoursSerializer(serializers.ModelSerializer):
class Meta:
model = StoreWorkingHours
fields = ('weekday', 'opens_at', 'closes_at', 'is_closed')
class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True)
class Meta:
model = Store
fields = (
'uuid', 'name', 'category', 'logo', 'cover_image',
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open',
)
class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'phone_number',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
)
class TopProductSerializer(serializers.Serializer):
name = serializers.CharField()
units_sold = serializers.IntegerField()
revenue = serializers.IntegerField()
class SalesChartPointSerializer(serializers.Serializer):
bucket = serializers.DateTimeField()
sales = serializers.IntegerField()
class SellerAnalyticsSerializer(serializers.Serializer):
period = serializers.CharField()
order_count = serializers.IntegerField()
total_sales = serializers.IntegerField()
cancellation_rate = serializers.FloatField()
average_order_value = serializers.IntegerField()
chart = SalesChartPointSerializer(many=True)
top_products = TopProductSerializer(many=True)
class StoreBankAccountSerializer(serializers.ModelSerializer):
class Meta:
model = StoreBankAccount
fields = ('iban', 'account_holder_name')
class SellerStoreSerializer(serializers.ModelSerializer):
"""Read/write serializer for the authenticated seller's own store."""
category = StoreCategorySerializer(read_only=True)
category_uuid = serializers.PrimaryKeyRelatedField(
source='category', queryset=StoreCategory.objects.all(), write_only=True,
)
city = CitySerializer(read_only=True)
city_uuid = serializers.PrimaryKeyRelatedField(
source='city', queryset=City.objects.filter(is_active=True), write_only=True,
)
service_neighborhoods = NeighborhoodSerializer(many=True, read_only=True)
service_neighborhood_uuids = serializers.PrimaryKeyRelatedField(
source='service_neighborhoods', queryset=Neighborhood.objects.filter(is_active=True),
write_only=True, many=True, required=False,
)
latitude = serializers.FloatField(write_only=True, required=False)
longitude = serializers.FloatField(write_only=True, required=False)
bank_account = StoreBankAccountSerializer(read_only=True)
class Meta:
model = Store
fields = (
'uuid', 'name', 'category', 'category_uuid', 'description', 'logo', 'cover_image',
'phone_number', 'city', 'city_uuid', 'address', 'latitude', 'longitude',
'service_neighborhoods', 'service_neighborhood_uuids', 'delivery_radius_km',
'min_order_amount', 'delivery_fee', 'free_delivery_threshold',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery',
'rating_avg', 'rating_count', 'status', 'is_open', 'bank_account', 'created_at',
)
read_only_fields = ('rating_avg', 'rating_count', 'status')
def _pop_location(self, validated_data):
lat = validated_data.pop('latitude', None)
lng = validated_data.pop('longitude', None)
if lat is not None and lng is not None:
validated_data['location'] = Point(lng, lat, srid=4326)
def create(self, validated_data):
self._pop_location(validated_data)
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
def update(self, instance, validated_data):
self._pop_location(validated_data)
return super().update(instance, validated_data)

View file

View file

@ -0,0 +1,39 @@
import uuid
from apps.locations.models import City
from apps.stores.models import Store, StoreCategory
from apps.users.models import User
from apps.orders.tests.base import OrdersTestCase
class SellerStoreTests(OrdersTestCase):
def setUp(self):
self.new_seller = User.objects.create_user(pk=uuid.uuid4(), username='new_seller')
self.category = StoreCategory.objects.create(name='کافه')
def test_seller_can_create_their_store(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.assertTrue(Store.objects.filter(owner=self.new_seller).exists())
def test_seller_cannot_create_a_second_store(self):
self.client.force_authenticate(user=self.seller1)
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, 409)
def test_non_seller_cannot_access_seller_endpoints(self):
self.client.force_authenticate(user=self.customer)
response = self.client.get('/api/v1/seller/store/')
self.assertEqual(response.status_code, 404) # no store yet -> get_object_or_404

17
apps/stores/urls.py Normal file
View file

@ -0,0 +1,17 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .analytics import SellerAnalyticsView
from .views import SellerStoreView, SellerStoreWorkingHoursView, StoreCategoryViewSet, StoreViewSet
app_name = "stores"
router = DefaultRouter()
router.register('stores', StoreViewSet, basename='store')
router.register('store-categories', StoreCategoryViewSet, basename='store-category')
urlpatterns = router.urls + [
path('seller/store/', SellerStoreView.as_view(), name='seller-store'),
path('seller/store/working-hours/', SellerStoreWorkingHoursView.as_view(), name='seller-store-working-hours'),
path('seller/analytics/', SellerAnalyticsView.as_view(), name='seller-analytics'),
]

123
apps/stores/views.py Normal file
View file

@ -0,0 +1,123 @@
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.permissions import IsStoreOwner
from .models import Store, StoreCategory, StoreWorkingHours
from .serializers import (
SellerStoreSerializer,
StoreCategorySerializer,
StoreDetailSerializer,
StoreListSerializer,
StoreWorkingHoursSerializer,
)
class StoreCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
schema_tags = ['Stores']
permission_classes = [AllowAny]
serializer_class = StoreCategorySerializer
queryset = StoreCategory.objects.all()
class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing store browsing (home feed, store page)."""
schema_tags = ['Stores']
permission_classes = [AllowAny]
queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city')
def get_serializer_class(self):
if self.action == 'retrieve':
return StoreDetailSerializer
return StoreListSerializer
def get_queryset(self):
queryset = super().get_queryset()
neighborhood_uuid = self.request.query_params.get('neighborhood')
if neighborhood_uuid:
queryset = queryset.filter(service_neighborhoods__uuid=neighborhood_uuid)
category_uuid = self.request.query_params.get('category')
if category_uuid:
queryset = queryset.filter(category__uuid=category_uuid)
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search))
lat = self.request.query_params.get('lat')
lng = self.request.query_params.get('lng')
if lat and lng:
point = Point(float(lng), float(lat), srid=4326)
queryset = queryset.filter(location__distance_lte=(point, D(km=15))).annotate(
distance=Distance('location', point)
).order_by('distance')
return queryset.distinct()
class SellerStoreView(APIView):
"""The authenticated seller's own store — GET/PATCH to manage it, POST to create it."""
schema_tags = ['Seller · Store']
permission_classes = [IsAuthenticated]
serializer_class = SellerStoreSerializer
def get(self, request):
store = get_object_or_404(Store, owner=request.user)
return Response(SellerStoreSerializer(store, context={'request': request}).data)
def post(self, request):
if hasattr(request.user, 'store'):
return Response(
{'detail': 'شما قبلاً یک فروشگاه ثبت کرده‌اید.'}, status=status.HTTP_409_CONFLICT,
)
serializer = SellerStoreSerializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@extend_schema(request=SellerStoreSerializer)
def patch(self, request):
store = get_object_or_404(Store, owner=request.user)
serializer = SellerStoreSerializer(
store, data=request.data, partial=True, context={'request': request},
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class SellerStoreWorkingHoursView(APIView):
"""Bulk get/set the authenticated seller's weekly working hours (S14)."""
schema_tags = ['Seller · Store']
permission_classes = [IsAuthenticated, IsStoreOwner]
serializer_class = StoreWorkingHoursSerializer
def get(self, request):
hours = StoreWorkingHours.objects.filter(store=request.user.store)
return Response(StoreWorkingHoursSerializer(hours, many=True).data)
@extend_schema(request=StoreWorkingHoursSerializer(many=True))
def put(self, request):
store = request.user.store
entries = request.data if isinstance(request.data, list) else request.data.get('working_hours', [])
serializer = StoreWorkingHoursSerializer(data=entries, many=True)
serializer.is_valid(raise_exception=True)
StoreWorkingHours.objects.filter(store=store).delete()
StoreWorkingHours.objects.bulk_create(
[StoreWorkingHours(store=store, **entry) for entry in serializer.validated_data]
)
return Response(StoreWorkingHoursSerializer(store.working_hours.all(), many=True).data)

0
apps/users/__init__.py Normal file
View file

5
apps/users/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from .models import User
admin.site.register(User)

6
apps/users/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users'

View file

@ -0,0 +1,46 @@
# Generated by Django 6.0.2 on 2026-08-09 10:12
import apps.users.models
import django.contrib.auth.validators
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('password', models.CharField(max_length=128, verbose_name='password')),
('username', models.CharField(blank=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, null=True, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('last_update', models.DateTimeField(auto_now=True, max_length=30, null=True, verbose_name='last update')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', apps.users.models.UserManager()),
],
),
]

View file

64
apps/users/models.py Normal file
View file

@ -0,0 +1,64 @@
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import uuid
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, pk=None, **extra_fields):
user = self.model(pk=pk, **extra_fields)
user.date_joined = timezone.now()
user.save(using=self._db)
return user
def create_user(self, pk, **extra_fields):
extra_fields.setdefault('is_staff', False)
extra_fields.setdefault('is_superuser', False)
return self._create_user(pk=pk, **extra_fields)
def create_superuser(self, username, email, password, **extra_fields):
if not username:
raise ValueError('The given username must be set')
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')
return self._create_user(None, username=username, email=email, password=password, **extra_fields)
class User(AbstractUser):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
password = models.CharField(_('password'), max_length=128)
username_validator = UnicodeUsernameValidator()
username = models.CharField(
_('username'),
max_length=150,
unique=True,
help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
validators=[username_validator],
error_messages={
'unique': _("A user with that username already exists."),
},
blank=True,
null=True
)
last_update = models.DateTimeField(_('last update'), max_length=30, blank=True, null=True, auto_now=True)
last_login = models.DateTimeField(_('last login'), blank=True, null=True)
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
objects = UserManager()
def __str__(self):
return str(self.username or self.pk)

View file

9
apps/users/urls.py Normal file
View file

@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
app_name = "users"
router = DefaultRouter()
urlpatterns = router.urls
urlpatterns += []

0
apps/users/views.py Normal file
View file

0
config/__init__.py Normal file
View file

16
config/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

View file

Some files were not shown because too many files have changed in this diff Show more