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:
commit
52adc732fa
153 changed files with 9333 additions and 0 deletions
28
.env.example
Normal file
28
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
66
CLAUDE.md
Normal 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
14
Dockerfile
Normal 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
443
FRONTEND_GUIDE.md
Normal 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
0
apps/__init__.py
Normal file
0
apps/catalog/__init__.py
Normal file
0
apps/catalog/__init__.py
Normal file
17
apps/catalog/admin.py
Normal file
17
apps/catalog/admin.py
Normal 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
6
apps/catalog/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CatalogConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.catalog'
|
||||
59
apps/catalog/migrations/0001_initial.py
Normal file
59
apps/catalog/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/catalog/migrations/__init__.py
Normal file
0
apps/catalog/migrations/__init__.py
Normal file
60
apps/catalog/models.py
Normal file
60
apps/catalog/models.py
Normal 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
|
||||
58
apps/catalog/serializers.py
Normal file
58
apps/catalog/serializers.py
Normal 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)
|
||||
0
apps/catalog/tests/__init__.py
Normal file
0
apps/catalog/tests/__init__.py
Normal file
33
apps/catalog/tests/test_seller_products.py
Normal file
33
apps/catalog/tests/test_seller_products.py
Normal 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
12
apps/catalog/urls.py
Normal 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
78
apps/catalog/views.py
Normal 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
0
apps/core/__init__.py
Normal file
5
apps/core/apps.py
Normal file
5
apps/core/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = 'apps.core'
|
||||
0
apps/core/management/__init__.py
Normal file
0
apps/core/management/__init__.py
Normal file
0
apps/core/management/commands/__init__.py
Normal file
0
apps/core/management/commands/__init__.py
Normal file
430
apps/core/management/commands/seed_demo_data.py
Normal file
430
apps/core/management/commands/seed_demo_data.py
Normal 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)
|
||||
0
apps/core/migrations/__init__.py
Normal file
0
apps/core/migrations/__init__.py
Normal file
10
apps/core/permissions.py
Normal file
10
apps/core/permissions.py
Normal 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
15
apps/core/schema.py
Normal 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
9
apps/core/urls.py
Normal 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"),
|
||||
]
|
||||
3
apps/core/views/__init__.py
Normal file
3
apps/core/views/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .health import HealthCheckView
|
||||
|
||||
__all__ = ["HealthCheckView"]
|
||||
12
apps/core/views/health.py
Normal file
12
apps/core/views/health.py
Normal 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"})
|
||||
0
apps/gooyal_oauth2/__init__.py
Normal file
0
apps/gooyal_oauth2/__init__.py
Normal file
12
apps/gooyal_oauth2/admin.py
Normal file
12
apps/gooyal_oauth2/admin.py
Normal 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
|
||||
6
apps/gooyal_oauth2/apps.py
Normal file
6
apps/gooyal_oauth2/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GooyalOauthConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.gooyal_oauth2'
|
||||
7
apps/gooyal_oauth2/forms.py
Normal file
7
apps/gooyal_oauth2/forms.py
Normal 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
|
||||
103
apps/gooyal_oauth2/migrations/0001_initial.py
Normal file
103
apps/gooyal_oauth2/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
83
apps/gooyal_oauth2/migrations/0002_initial.py
Normal file
83
apps/gooyal_oauth2/migrations/0002_initial.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
apps/gooyal_oauth2/migrations/__init__.py
Normal file
0
apps/gooyal_oauth2/migrations/__init__.py
Normal file
48
apps/gooyal_oauth2/models.py
Normal file
48
apps/gooyal_oauth2/models.py
Normal 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
|
||||
19
apps/gooyal_oauth2/rest_framework.py
Normal file
19
apps/gooyal_oauth2/rest_framework.py
Normal 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)
|
||||
6
apps/gooyal_oauth2/utils.py
Normal file
6
apps/gooyal_oauth2/utils.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def get_application(request):
|
||||
try:
|
||||
application = request.auth.application
|
||||
except:
|
||||
application = None
|
||||
return application
|
||||
169
apps/gooyal_oauth2/validators.py
Normal file
169
apps/gooyal_oauth2/validators.py
Normal 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
|
||||
0
apps/locations/__init__.py
Normal file
0
apps/locations/__init__.py
Normal file
23
apps/locations/admin.py
Normal file
23
apps/locations/admin.py
Normal 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
6
apps/locations/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LocationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.locations'
|
||||
80
apps/locations/migrations/0001_initial.py
Normal file
80
apps/locations/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/locations/migrations/__init__.py
Normal file
0
apps/locations/migrations/__init__.py
Normal file
72
apps/locations/models.py
Normal file
72
apps/locations/models.py
Normal 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)
|
||||
60
apps/locations/serializers.py
Normal file
60
apps/locations/serializers.py
Normal 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)
|
||||
0
apps/locations/tests/__init__.py
Normal file
0
apps/locations/tests/__init__.py
Normal file
12
apps/locations/urls.py
Normal file
12
apps/locations/urls.py
Normal 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
37
apps/locations/views.py
Normal 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
0
apps/orders/__init__.py
Normal file
47
apps/orders/admin.py
Normal file
47
apps/orders/admin.py
Normal 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
6
apps/orders/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.orders'
|
||||
156
apps/orders/migrations/0001_initial.py
Normal file
156
apps/orders/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/orders/migrations/__init__.py
Normal file
0
apps/orders/migrations/__init__.py
Normal file
182
apps/orders/models.py
Normal file
182
apps/orders/models.py
Normal 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
134
apps/orders/serializers.py
Normal 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
179
apps/orders/services.py
Normal 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)
|
||||
0
apps/orders/tests/__init__.py
Normal file
0
apps/orders/tests/__init__.py
Normal file
63
apps/orders/tests/base.py
Normal file
63
apps/orders/tests/base.py
Normal 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
|
||||
43
apps/orders/tests/test_cart.py
Normal file
43
apps/orders/tests/test_cart.py
Normal 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'], [])
|
||||
69
apps/orders/tests/test_checkout.py
Normal file
69
apps/orders/tests/test_checkout.py
Normal 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)
|
||||
60
apps/orders/tests/test_status_transitions.py
Normal file
60
apps/orders/tests/test_status_transitions.py
Normal 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
27
apps/orders/urls.py
Normal 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
205
apps/orders/views.py
Normal 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
0
apps/reviews/__init__.py
Normal file
10
apps/reviews/admin.py
Normal file
10
apps/reviews/admin.py
Normal 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
6
apps/reviews/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReviewsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.reviews'
|
||||
42
apps/reviews/migrations/0001_initial.py
Normal file
42
apps/reviews/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/reviews/migrations/__init__.py
Normal file
0
apps/reviews/migrations/__init__.py
Normal file
28
apps/reviews/models.py
Normal file
28
apps/reviews/models.py
Normal 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'
|
||||
55
apps/reviews/serializers.py
Normal file
55
apps/reviews/serializers.py
Normal 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
8
apps/reviews/services.py
Normal 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'])
|
||||
0
apps/reviews/tests/__init__.py
Normal file
0
apps/reviews/tests/__init__.py
Normal file
56
apps/reviews/tests/test_reviews.py
Normal file
56
apps/reviews/tests/test_reviews.py
Normal 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
11
apps/reviews/urls.py
Normal 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
50
apps/reviews/views.py
Normal 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
0
apps/stores/__init__.py
Normal file
26
apps/stores/admin.py
Normal file
26
apps/stores/admin.py
Normal 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
77
apps/stores/analytics.py
Normal 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
6
apps/stores/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class StoresConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.stores'
|
||||
107
apps/stores/migrations/0001_initial.py
Normal file
107
apps/stores/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/stores/migrations/__init__.py
Normal file
0
apps/stores/migrations/__init__.py
Normal file
120
apps/stores/models.py
Normal file
120
apps/stores/models.py
Normal 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
116
apps/stores/serializers.py
Normal 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)
|
||||
0
apps/stores/tests/__init__.py
Normal file
0
apps/stores/tests/__init__.py
Normal file
39
apps/stores/tests/test_seller_store.py
Normal file
39
apps/stores/tests/test_seller_store.py
Normal 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
17
apps/stores/urls.py
Normal 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
123
apps/stores/views.py
Normal 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
0
apps/users/__init__.py
Normal file
5
apps/users/admin.py
Normal file
5
apps/users/admin.py
Normal 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
6
apps/users/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
46
apps/users/migrations/0001_initial.py
Normal file
46
apps/users/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/users/migrations/__init__.py
Normal file
0
apps/users/migrations/__init__.py
Normal file
64
apps/users/models.py
Normal file
64
apps/users/models.py
Normal 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)
|
||||
0
apps/users/serializers.py
Normal file
0
apps/users/serializers.py
Normal file
9
apps/users/urls.py
Normal file
9
apps/users/urls.py
Normal 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
0
apps/users/views.py
Normal file
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
16
config/asgi.py
Normal file
16
config/asgi.py
Normal 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()
|
||||
0
config/other_settings/__init__.py
Normal file
0
config/other_settings/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue