commit 52adc732fa3d3f904f6f7805f107fa0b48f8015f Author: Ali Asadi Date: Mon Aug 10 14:03:15 2026 +0330 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dafb604 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f39e753 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +.env +db.sqlite3 +/static/ +/media/ +/log/ +/logs/ +.DS_Store +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..364ce4a --- /dev/null +++ b/CLAUDE.md @@ -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 # 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}`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7def56 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md new file mode 100644 index 0000000..d10cb30 --- /dev/null +++ b/FRONTEND_GUIDE.md @@ -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 +``` + +- 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": "", + "neighborhood_uuid": "", + "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": "", "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": "
", + "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": "", + "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_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": "", + "description": "...", + "phone_number": "09166352131", + "city_uuid": "", + "address": "خیابان آزادی، نبش کوچه مریم", + "latitude": 35.71, "longitude": 51.35, + "service_neighborhood_uuids": ["", "..."], + "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. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/__init__.py b/apps/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/admin.py b/apps/catalog/admin.py new file mode 100644 index 0000000..26345f4 --- /dev/null +++ b/apps/catalog/admin.py @@ -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') diff --git a/apps/catalog/apps.py b/apps/catalog/apps.py new file mode 100644 index 0000000..77fda12 --- /dev/null +++ b/apps/catalog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CatalogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.catalog' diff --git a/apps/catalog/migrations/0001_initial.py b/apps/catalog/migrations/0001_initial.py new file mode 100644 index 0000000..cd6e61e --- /dev/null +++ b/apps/catalog/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/catalog/migrations/__init__.py b/apps/catalog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/models.py b/apps/catalog/models.py new file mode 100644 index 0000000..4d3f896 --- /dev/null +++ b/apps/catalog/models.py @@ -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 diff --git a/apps/catalog/serializers.py b/apps/catalog/serializers.py new file mode 100644 index 0000000..f0160ac --- /dev/null +++ b/apps/catalog/serializers.py @@ -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) diff --git a/apps/catalog/tests/__init__.py b/apps/catalog/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/catalog/tests/test_seller_products.py b/apps/catalog/tests/test_seller_products.py new file mode 100644 index 0000000..63c229f --- /dev/null +++ b/apps/catalog/tests/test_seller_products.py @@ -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) diff --git a/apps/catalog/urls.py b/apps/catalog/urls.py new file mode 100644 index 0000000..e27a2af --- /dev/null +++ b/apps/catalog/urls.py @@ -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 diff --git a/apps/catalog/views.py b/apps/catalog/views.py new file mode 100644 index 0000000..4073713 --- /dev/null +++ b/apps/catalog/views.py @@ -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) diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..0fc76c7 --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'apps.core' diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/management/commands/seed_demo_data.py b/apps/core/management/commands/seed_demo_data.py new file mode 100644 index 0000000..66ad8fb --- /dev/null +++ b/apps/core/management/commands/seed_demo_data.py @@ -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) diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/permissions.py b/apps/core/permissions.py new file mode 100644 index 0000000..0e5d6b8 --- /dev/null +++ b/apps/core/permissions.py @@ -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')) diff --git a/apps/core/schema.py b/apps/core/schema.py new file mode 100644 index 0000000..eee4777 --- /dev/null +++ b/apps/core/schema.py @@ -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() diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..6452106 --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import HealthCheckView + +app_name = "core" + +urlpatterns = [ + path("health/", HealthCheckView.as_view(), name="health"), +] diff --git a/apps/core/views/__init__.py b/apps/core/views/__init__.py new file mode 100644 index 0000000..6155f2b --- /dev/null +++ b/apps/core/views/__init__.py @@ -0,0 +1,3 @@ +from .health import HealthCheckView + +__all__ = ["HealthCheckView"] diff --git a/apps/core/views/health.py b/apps/core/views/health.py new file mode 100644 index 0000000..6c0771c --- /dev/null +++ b/apps/core/views/health.py @@ -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"}) diff --git a/apps/gooyal_oauth2/__init__.py b/apps/gooyal_oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth2/admin.py b/apps/gooyal_oauth2/admin.py new file mode 100644 index 0000000..30f17d6 --- /dev/null +++ b/apps/gooyal_oauth2/admin.py @@ -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 diff --git a/apps/gooyal_oauth2/apps.py b/apps/gooyal_oauth2/apps.py new file mode 100644 index 0000000..e0ce9c1 --- /dev/null +++ b/apps/gooyal_oauth2/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GooyalOauthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.gooyal_oauth2' diff --git a/apps/gooyal_oauth2/forms.py b/apps/gooyal_oauth2/forms.py new file mode 100644 index 0000000..a5e80e6 --- /dev/null +++ b/apps/gooyal_oauth2/forms.py @@ -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 diff --git a/apps/gooyal_oauth2/migrations/0001_initial.py b/apps/gooyal_oauth2/migrations/0001_initial.py new file mode 100644 index 0000000..39868de --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/apps/gooyal_oauth2/migrations/0002_initial.py b/apps/gooyal_oauth2/migrations/0002_initial.py new file mode 100644 index 0000000..e599f02 --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0002_initial.py @@ -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), + ), + ] diff --git a/apps/gooyal_oauth2/migrations/__init__.py b/apps/gooyal_oauth2/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py new file mode 100644 index 0000000..983859b --- /dev/null +++ b/apps/gooyal_oauth2/models.py @@ -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 diff --git a/apps/gooyal_oauth2/rest_framework.py b/apps/gooyal_oauth2/rest_framework.py new file mode 100644 index 0000000..d316e9c --- /dev/null +++ b/apps/gooyal_oauth2/rest_framework.py @@ -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) diff --git a/apps/gooyal_oauth2/utils.py b/apps/gooyal_oauth2/utils.py new file mode 100644 index 0000000..269496f --- /dev/null +++ b/apps/gooyal_oauth2/utils.py @@ -0,0 +1,6 @@ +def get_application(request): + try: + application = request.auth.application + except: + application = None + return application diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py new file mode 100644 index 0000000..b336cf5 --- /dev/null +++ b/apps/gooyal_oauth2/validators.py @@ -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 diff --git a/apps/locations/__init__.py b/apps/locations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/locations/admin.py b/apps/locations/admin.py new file mode 100644 index 0000000..e50f28c --- /dev/null +++ b/apps/locations/admin.py @@ -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') diff --git a/apps/locations/apps.py b/apps/locations/apps.py new file mode 100644 index 0000000..5bda953 --- /dev/null +++ b/apps/locations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LocationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.locations' diff --git a/apps/locations/migrations/0001_initial.py b/apps/locations/migrations/0001_initial.py new file mode 100644 index 0000000..3b92874 --- /dev/null +++ b/apps/locations/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/locations/migrations/__init__.py b/apps/locations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/locations/models.py b/apps/locations/models.py new file mode 100644 index 0000000..5b577f1 --- /dev/null +++ b/apps/locations/models.py @@ -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) diff --git a/apps/locations/serializers.py b/apps/locations/serializers.py new file mode 100644 index 0000000..438ec71 --- /dev/null +++ b/apps/locations/serializers.py @@ -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) diff --git a/apps/locations/tests/__init__.py b/apps/locations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/locations/urls.py b/apps/locations/urls.py new file mode 100644 index 0000000..428625c --- /dev/null +++ b/apps/locations/urls.py @@ -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 diff --git a/apps/locations/views.py b/apps/locations/views.py new file mode 100644 index 0000000..6e9ab39 --- /dev/null +++ b/apps/locations/views.py @@ -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') diff --git a/apps/orders/__init__.py b/apps/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/admin.py b/apps/orders/admin.py new file mode 100644 index 0000000..dcad460 --- /dev/null +++ b/apps/orders/admin.py @@ -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') diff --git a/apps/orders/apps.py b/apps/orders/apps.py new file mode 100644 index 0000000..4f436a5 --- /dev/null +++ b/apps/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.orders' diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000..7ba0a32 --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/apps/orders/migrations/__init__.py b/apps/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/models.py b/apps/orders/models.py new file mode 100644 index 0000000..953a5ff --- /dev/null +++ b/apps/orders/models.py @@ -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}' diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py new file mode 100644 index 0000000..e06ebd3 --- /dev/null +++ b/apps/orders/serializers.py @@ -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 diff --git a/apps/orders/services.py b/apps/orders/services.py new file mode 100644 index 0000000..ad2db7d --- /dev/null +++ b/apps/orders/services.py @@ -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) diff --git a/apps/orders/tests/__init__.py b/apps/orders/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/tests/base.py b/apps/orders/tests/base.py new file mode 100644 index 0000000..2daa20c --- /dev/null +++ b/apps/orders/tests/base.py @@ -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 diff --git a/apps/orders/tests/test_cart.py b/apps/orders/tests/test_cart.py new file mode 100644 index 0000000..64167bb --- /dev/null +++ b/apps/orders/tests/test_cart.py @@ -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'], []) diff --git a/apps/orders/tests/test_checkout.py b/apps/orders/tests/test_checkout.py new file mode 100644 index 0000000..7374275 --- /dev/null +++ b/apps/orders/tests/test_checkout.py @@ -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) diff --git a/apps/orders/tests/test_status_transitions.py b/apps/orders/tests/test_status_transitions.py new file mode 100644 index 0000000..5d2de47 --- /dev/null +++ b/apps/orders/tests/test_status_transitions.py @@ -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) diff --git a/apps/orders/urls.py b/apps/orders/urls.py new file mode 100644 index 0000000..365d66d --- /dev/null +++ b/apps/orders/urls.py @@ -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//', CartItemView.as_view(), name='cart-item-detail'), +] diff --git a/apps/orders/views.py b/apps/orders/views.py new file mode 100644 index 0000000..8a2aaa6 --- /dev/null +++ b/apps/orders/views.py @@ -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) diff --git a/apps/reviews/__init__.py b/apps/reviews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/admin.py b/apps/reviews/admin.py new file mode 100644 index 0000000..8d29d9c --- /dev/null +++ b/apps/reviews/admin.py @@ -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') diff --git a/apps/reviews/apps.py b/apps/reviews/apps.py new file mode 100644 index 0000000..c977ae3 --- /dev/null +++ b/apps/reviews/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReviewsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.reviews' diff --git a/apps/reviews/migrations/0001_initial.py b/apps/reviews/migrations/0001_initial.py new file mode 100644 index 0000000..938cf55 --- /dev/null +++ b/apps/reviews/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/reviews/migrations/__init__.py b/apps/reviews/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/models.py b/apps/reviews/models.py new file mode 100644 index 0000000..706d551 --- /dev/null +++ b/apps/reviews/models.py @@ -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' diff --git a/apps/reviews/serializers.py b/apps/reviews/serializers.py new file mode 100644 index 0000000..656f532 --- /dev/null +++ b/apps/reviews/serializers.py @@ -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() diff --git a/apps/reviews/services.py b/apps/reviews/services.py new file mode 100644 index 0000000..b1a405f --- /dev/null +++ b/apps/reviews/services.py @@ -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']) diff --git a/apps/reviews/tests/__init__.py b/apps/reviews/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/tests/test_reviews.py b/apps/reviews/tests/test_reviews.py new file mode 100644 index 0000000..bf7baab --- /dev/null +++ b/apps/reviews/tests/test_reviews.py @@ -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, 'ممنون از شما') diff --git a/apps/reviews/urls.py b/apps/reviews/urls.py new file mode 100644 index 0000000..5747e5a --- /dev/null +++ b/apps/reviews/urls.py @@ -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 diff --git a/apps/reviews/views.py b/apps/reviews/views.py new file mode 100644 index 0000000..f3c9ca0 --- /dev/null +++ b/apps/reviews/views.py @@ -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) diff --git a/apps/stores/__init__.py b/apps/stores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stores/admin.py b/apps/stores/admin.py new file mode 100644 index 0000000..a7f733c --- /dev/null +++ b/apps/stores/admin.py @@ -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] diff --git a/apps/stores/analytics.py b/apps/stores/analytics.py new file mode 100644 index 0000000..2f21e59 --- /dev/null +++ b/apps/stores/analytics.py @@ -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 + ], + }) diff --git a/apps/stores/apps.py b/apps/stores/apps.py new file mode 100644 index 0000000..4982ac3 --- /dev/null +++ b/apps/stores/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StoresConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.stores' diff --git a/apps/stores/migrations/0001_initial.py b/apps/stores/migrations/0001_initial.py new file mode 100644 index 0000000..ec9f02f --- /dev/null +++ b/apps/stores/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/apps/stores/migrations/__init__.py b/apps/stores/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stores/models.py b/apps/stores/models.py new file mode 100644 index 0000000..94c91eb --- /dev/null +++ b/apps/stores/models.py @@ -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}' diff --git a/apps/stores/serializers.py b/apps/stores/serializers.py new file mode 100644 index 0000000..ce40149 --- /dev/null +++ b/apps/stores/serializers.py @@ -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) diff --git a/apps/stores/tests/__init__.py b/apps/stores/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stores/tests/test_seller_store.py b/apps/stores/tests/test_seller_store.py new file mode 100644 index 0000000..c877dc6 --- /dev/null +++ b/apps/stores/tests/test_seller_store.py @@ -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 diff --git a/apps/stores/urls.py b/apps/stores/urls.py new file mode 100644 index 0000000..a3b8077 --- /dev/null +++ b/apps/stores/urls.py @@ -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'), +] diff --git a/apps/stores/views.py b/apps/stores/views.py new file mode 100644 index 0000000..8c7d130 --- /dev/null +++ b/apps/stores/views.py @@ -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) diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..41a4e09 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import User + + +admin.site.register(User) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..2bb189c --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..5c81790 --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..0d77745 --- /dev/null +++ b/apps/users/models.py @@ -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) diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/urls.py b/apps/users/urls.py new file mode 100644 index 0000000..3732585 --- /dev/null +++ b/apps/users/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +app_name = "users" + +router = DefaultRouter() + +urlpatterns = router.urls + +urlpatterns += [] diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..e69de29 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/other_settings/__init__.py b/config/other_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/other_settings/logging.py b/config/other_settings/logging.py new file mode 100644 index 0000000..ceea0dd --- /dev/null +++ b/config/other_settings/logging.py @@ -0,0 +1,90 @@ +import os + + +def get_settings(key): + from django.conf import settings + return getattr(settings, key) + + +BASE_DIR = get_settings('BASE_DIR') + +LOG_DIR = BASE_DIR / 'log' +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{asctime} [{levelname}] {name} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'standard': { + 'format': '{asctime} [{levelname}] {name}: {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'winofy.log'), + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + 'error_file': { + 'level': 'ERROR', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'errors.log'), + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + 'console_always': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'loggers': { + '': { + 'handlers': ['console', 'file', 'error_file'], + 'level': 'INFO', + 'propagate': True, + }, + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + 'django.request': { + 'handlers': ['file', 'error_file', 'console'], + 'level': 'ERROR', + 'propagate': False, + }, + }, +} diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..1e93470 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,205 @@ +from pathlib import Path + +import environ +from decouple import config + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env() +environ.Env.read_env(BASE_DIR / ".env") + +SECRET_KEY = env("SECRET_KEY") +DEBUG = env.bool("DEBUG", default=False) +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.gis", + + # pip installed apps + 'drf_spectacular', + 'oauth2_provider', + 'rest_framework', + 'rest_framework_gis', + 'django_filters', + 'corsheaders', + + # local apps + 'apps.core', + 'apps.users', + 'apps.gooyal_oauth2', + 'apps.locations', + 'apps.stores', + 'apps.catalog', + 'apps.orders', + 'apps.reviews', +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + 'corsheaders.middleware.CorsMiddleware', + "django.middleware.common.CommonMiddleware", + 'django.middleware.locale.LocaleMiddleware', + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + 'oauth2_provider.middleware.OAuth2TokenMiddleware', + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + 'utils.exceptions.ErrorMiddleware', +] + +OAUTH2_PROVIDER_APPLICATION_MODEL = "gooyal_oauth2.Application" +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "gooyal_oauth2.AccessToken" +OAUTH2_PROVIDER_ID_TOKEN_MODEL = "gooyal_oauth2.IDToken" +OAUTH2_PROVIDER_GRANT_MODEL = "gooyal_oauth2.Grant" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "gooyal_oauth2.RefreshToken" + +OAUTH2_PROVIDER_BASE_PUBLIC_URL = config("OAUTH2_PROVIDER_BASE_PUBLIC_URL", default="") +OAUTH2_PROVIDER_BASE_PRIVATE_URL = config("OAUTH2_PROVIDER_BASE_PRIVATE_URL", default="") + +OAUTH2_PROVIDER_CLIENT_ID = config('OAUTH2_PROVIDER_CLIENT_ID', default="") +OAUTH2_PROVIDER_CLIENT_SECRET = config('OAUTH2_PROVIDER_CLIENT_SECRET', default="") +OAUTH2_PROVIDER_SCOPES = config('OAUTH2_PROVIDER_SCOPES', default='') +OAUTH2_PROVIDER = { + 'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator', + 'RESOURCE_SERVER_INTROSPECTION_URL': (OAUTH2_PROVIDER_BASE_PRIVATE_URL or OAUTH2_PROVIDER_BASE_PUBLIC_URL) + '/introspect/', + 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': (OAUTH2_PROVIDER_CLIENT_ID, OAUTH2_PROVIDER_CLIENT_SECRET), + 'RESOURCE_SERVER_TOKEN_CACHING_SECONDS': 600, +} + +AUTHENTICATION_BACKENDS = ( + 'oauth2_provider.backends.OAuth2Backend', + 'django.contrib.auth.backends.ModelBackend', +) + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": env("DB_NAME", default="winofy_dev"), + "USER": env("DB_USER", default=""), + "PASSWORD": env("DB_PASSWORD", default=""), + "HOST": env("DB_HOST", default="localhost"), + "PORT": env("DB_PORT", default="5432"), + } +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL", default="redis://localhost:6379/1"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +AUTH_USER_MODEL = 'users.User' +LANGUAGE_CODE = "fa" +TIME_ZONE = "Asia/Tehran" +USE_I18N = True +USE_TZ = True +LOCALE_PATHS = [BASE_DIR / 'locale'] + +API_BASE_PATH = config('API_BASE_PATH', default='') +STATIC_URL = "{}static/".format(API_BASE_PATH) +STATIC_ROOT = BASE_DIR / "static" +MEDIA_URL = "{}media/".format(API_BASE_PATH) +MEDIA_ROOT = BASE_DIR / "media" +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) + +if _gdal := env("GDAL_LIBRARY_PATH", default=""): + GDAL_LIBRARY_PATH = _gdal +if _geos := env("GEOS_LIBRARY_PATH", default=""): + GEOS_LIBRARY_PATH = _geos + +# Platform commission taken from each delivered order's items subtotal (S10 mock: 4%). +# Overridable per-store via Store.commission_percent. +DEFAULT_COMMISSION_PERCENT = env.float("DEFAULT_COMMISSION_PERCENT", default=4.0) + +# Fallback delivery-coverage radius (meters) used when a store has no explicit +# service_neighborhoods assigned yet. +NEARBY_RADIUS_METERS = env.int("NEARBY_RADIUS_METERS", default=5000) + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 50, + 'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',), + "DEFAULT_SCHEMA_CLASS": "apps.core.schema.TaggedAutoSchema", + 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Winofy API", + "DESCRIPTION": "Winofy — neighborhood multi-vendor marketplace (customer app + seller panel).", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "TAGS": [ + {"name": "Locations", "description": "Cities and neighborhoods (public reference data)."}, + {"name": "Addresses", "description": "The authenticated customer's saved addresses."}, + {"name": "Stores", "description": "Customer-facing store browsing (home feed, store page)."}, + {"name": "Catalog", "description": "Customer-facing product browsing and search."}, + {"name": "Cart", "description": "The authenticated customer's multi-store cart."}, + {"name": "Checkout", "description": "Splits the cart into per-store orders and starts payment."}, + {"name": "Orders", "description": "Customer order history and tracking."}, + {"name": "Reviews", "description": "Customer reviews of stores/products."}, + {"name": "Notifications", "description": "Shared notification feed (customer app + seller panel)."}, + {"name": "Seller · Store", "description": "The seller's own store profile, settings, working hours."}, + {"name": "Seller · Products", "description": "The seller's product catalog and inventory."}, + {"name": "Seller · Orders", "description": "Seller order management and status stepper."}, + {"name": "Seller · Analytics", "description": "Seller sales dashboard."}, + {"name": "Seller · Reviews", "description": "Reviews left for the seller's store, with reply support."}, + ], +} + +from config.other_settings.logging import LOGGING # noqa: E402 diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..487535c --- /dev/null +++ b/config/urls.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +urlpatterns = [ + path("api/admin/", admin.site.urls), + path("api/", include("apps.core.urls")), + path('api/oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path("api/v1/", include("apps.locations.urls")), + path("api/v1/", include("apps.stores.urls")), + path("api/v1/", include("apps.catalog.urls")), + path("api/v1/", include("apps.orders.urls")), + path("api/v1/", include("apps.reviews.urls")), + path("api/swagger/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/swagger/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/swagger/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e966b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + winofy: + build: . + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./logs:/app/log + depends_on: + - db + - redis + networks: + - app + - monitoring + + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - app + + redis: + image: redis:7-alpine + networks: + - app + + promtail: + image: grafana/promtail:2.8.0 + volumes: + - ./promtail-config.yaml:/etc/promtail/config.yaml + - ./logs:/var/log/django + command: -config.file=/etc/promtail/config.yaml + networks: + - monitoring + +volumes: + pgdata: + +networks: + app: + driver: bridge + monitoring: + driver: bridge diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/promtail-config.yaml b/promtail-config.yaml new file mode 100644 index 0000000..22f6acf --- /dev/null +++ b/promtail-config.yaml @@ -0,0 +1,23 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: https://loki.winsoo.ir/loki/api/v1/push + +scrape_configs: + - job_name: winofy-backend + static_configs: + - targets: + - localhost + labels: + job: winofy-backend + __path__: /var/log/django/*.log + pipeline_stages: + - regex: + expression: '^(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?P\w+) (?P.*)' + - labels: + level: diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..e188981 --- /dev/null +++ b/requirements.in @@ -0,0 +1,46 @@ +# Web framework +Django==6.0.2 +asgiref==3.11.1 +sqlparse==0.5.3 + +# REST framework +djangorestframework==3.16.1 +djangorestframework-gis==1.2.1 # GeoJSON field support (PointField) for locations/stores +django-filter==25.2 + +# Environment & configuration +django-environ==0.13.0 +python-decouple==3.8 + +# Database +psycopg[binary,pool]==3.3.3 # PostgreSQL/PostGIS driver + +# Cache (OAuth2 client-credentials token caching) +redis==6.4.0 +django-redis==6.0.0 + +# API schema & docs +drf-spectacular==0.29.0 +PyYAML==6.0.3 +uritemplate==4.2.0 +inflection==0.5.1 + +# OAuth2 — resource server against the central Gooyal accounts service +django-oauth-toolkit==3.2.0 + +# CORS +django-cors-headers==4.9.0 + +# HTTP clients — vendored Gooyal service SDKs (accounts, wallet) + hand-written ipg client +httpx==0.28.1 +requests==2.32.5 +attrs==25.4.0 +python-dateutil==2.9.0.post0 + +# Static files +whitenoise==6.9.0 +Pillow==12.3.0 # ImageField support (product/store images) + +# App server +gunicorn==26.0.0 +gevent==26.7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d814586 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,42 @@ +anyio==4.14.2 +asgiref==3.11.1 +attrs==25.4.0 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.4.9 +cryptography==50.0.0 +Django==6.0.2 +django-cors-headers==4.9.0 +django-environ==0.13.0 +django-filter==25.2 +django-oauth-toolkit==3.2.0 +django-redis==6.0.0 +djangorestframework==3.16.1 +djangorestframework-gis==1.2.1 +drf-spectacular==0.29.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.18 +inflection==0.5.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +jwcrypto==1.5.8 +oauthlib==3.3.1 +psycopg==3.3.3 +psycopg-binary==3.3.3 +psycopg-pool==3.3.1 +pycparser==3.0 +python-dateutil==2.9.0.post0 +python-decouple==3.8 +PyYAML==6.0.3 +redis==6.4.0 +referencing==0.37.0 +requests==2.32.5 +rpds-py==2026.6.3 +six==1.17.0 +sqlparse==0.5.3 +typing_extensions==4.16.0 +uritemplate==4.2.0 +urllib3==2.7.0 +whitenoise==6.9.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..f69ab51 --- /dev/null +++ b/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +while ! nc -z $DB_HOST 5432 ; do + echo "APP Waiting for the DB Server" + sleep 3 +done +python3 manage.py collectstatic --noinput +python3 manage.py migrate +gunicorn config.wsgi:application --bind 0.0.0.0:8000 -w 4 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/accounts_client.py b/utils/accounts_client.py new file mode 100644 index 0000000..051996a --- /dev/null +++ b/utils/accounts_client.py @@ -0,0 +1,51 @@ +import logging +import time + +import requests +from django.conf import settings +from django.core.cache import cache + +from utils.clients.gooyal_accounts_client import AuthenticatedClient +from utils.clients.gooyal_accounts_client.models import Account +from utils.clients.gooyal_accounts_client.api.users import users_api_users_details_retrieve + +logger = logging.getLogger('winofy.utils.accounts_client') + + +def login_as_client_credentials(): + access_token = cache.get('winofy_accounts_access_token') + if access_token: + return access_token + + data = { + "grant_type": 'client_credentials', + "scope": settings.OAUTH2_PROVIDER_SCOPES, + } + auth = (settings.OAUTH2_PROVIDER_CLIENT_ID, settings.OAUTH2_PROVIDER_CLIENT_SECRET) + + response = requests.post( + f'{settings.OAUTH2_PROVIDER_BASE_PUBLIC_URL}/token/', + data=data, + auth=auth, + timeout=10, + ) + auth_data = response.json() + + if 'access_token' in auth_data: + auth_data['created_at'] = time.time() + cache.set('winofy_accounts_access_token', auth_data, timeout=auth_data.get('expires_in') - 5) + + return auth_data + + +def get_client(): + auth_data = login_as_client_credentials() + if 'access_token' not in auth_data: + raise Exception(f"OAuth2 token error: {auth_data.get('error', 'unknown')}") + return AuthenticatedClient(base_url=settings.OAUTH2_PROVIDER_BASE_PUBLIC_URL, token=auth_data['access_token']) + + +def get_user_detailed_info(uuid): + client = get_client() + response: Account = users_api_users_details_retrieve.sync(uuid=uuid, client=client) + return response diff --git a/utils/clients/__init__.py b/utils/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/clients/gooyal_accounts_client/__init__.py b/utils/clients/gooyal_accounts_client/__init__.py new file mode 100644 index 0000000..94931df --- /dev/null +++ b/utils/clients/gooyal_accounts_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing Gooyal Accounts""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/utils/clients/gooyal_accounts_client/api/__init__.py b/utils/clients/gooyal_accounts_client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/utils/clients/gooyal_accounts_client/api/oauth2/__init__.py b/utils/clients/gooyal_accounts_client/api/oauth2/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/oauth2/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_avatar_retrieve.py b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_avatar_retrieve.py new file mode 100644 index 0000000..8a48f72 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_avatar_retrieve.py @@ -0,0 +1,142 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.application import Application +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/oauth2/apis/applications/{id}/avatar/", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Application]: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Application]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient, +) -> Response[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + id: str, + *, + client: AuthenticatedClient, +) -> Optional[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application + """ + + return sync_detailed( + id=id, + client=client, + ).parsed + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient, +) -> Response[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + id: str, + *, + client: AuthenticatedClient, +) -> Optional[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application + """ + + return ( + await asyncio_detailed( + id=id, + client=client, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_list.py b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_list.py new file mode 100644 index 0000000..0683709 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_list.py @@ -0,0 +1,154 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.paginated_application_list import PaginatedApplicationList +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: Union[Unset, int] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["page"] = page + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/oauth2/apis/applications/", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[PaginatedApplicationList]: + if response.status_code == 200: + response_200 = PaginatedApplicationList.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[PaginatedApplicationList]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Response[PaginatedApplicationList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PaginatedApplicationList] + """ + + kwargs = _get_kwargs( + page=page, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Optional[PaginatedApplicationList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PaginatedApplicationList + """ + + return sync_detailed( + client=client, + page=page, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Response[PaginatedApplicationList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PaginatedApplicationList] + """ + + kwargs = _get_kwargs( + page=page, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Optional[PaginatedApplicationList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PaginatedApplicationList + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_retrieve.py b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_retrieve.py new file mode 100644 index 0000000..464b71b --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/oauth2/oauth2_apis_applications_retrieve.py @@ -0,0 +1,142 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.application import Application +from ...types import Response + + +def _get_kwargs( + id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/oauth2/apis/applications/{id}/", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Application]: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Application]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: str, + *, + client: AuthenticatedClient, +) -> Response[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + id: str, + *, + client: AuthenticatedClient, +) -> Optional[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application + """ + + return sync_detailed( + id=id, + client=client, + ).parsed + + +async def asyncio_detailed( + id: str, + *, + client: AuthenticatedClient, +) -> Response[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application] + """ + + kwargs = _get_kwargs( + id=id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + id: str, + *, + client: AuthenticatedClient, +) -> Optional[Application]: + """ + Args: + id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application + """ + + return ( + await asyncio_detailed( + id=id, + client=client, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/__init__.py b/utils/clients/gooyal_accounts_client/api/users/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_account_partial_update.py b/utils/clients/gooyal_accounts_client/api/users/users_api_account_partial_update.py new file mode 100644 index 0000000..8bbbab3 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_account_partial_update.py @@ -0,0 +1,152 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.account import Account +from ...models.patched_account import PatchedAccount +from ...types import Response + + +def _get_kwargs( + *, + body: PatchedAccount, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/users/api/account/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Account]: + if response.status_code == 200: + response_200 = Account.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Account]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: PatchedAccount, +) -> Response[Account]: + """ + Args: + body (PatchedAccount): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: PatchedAccount, +) -> Optional[Account]: + """ + Args: + body (PatchedAccount): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: PatchedAccount, +) -> Response[Account]: + """ + Args: + body (PatchedAccount): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: PatchedAccount, +) -> Optional[Account]: + """ + Args: + body (PatchedAccount): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_account_retrieve.py b/utils/clients/gooyal_accounts_client/api/users/users_api_account_retrieve.py new file mode 100644 index 0000000..e15e774 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_account_retrieve.py @@ -0,0 +1,118 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.account import Account +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/users/api/account/", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Account]: + if response.status_code == 200: + response_200 = Account.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Account]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[Account]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> Optional[Account]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[Account]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> Optional[Account]: + """ + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_account_update.py b/utils/clients/gooyal_accounts_client/api/users/users_api_account_update.py new file mode 100644 index 0000000..1a621e6 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_account_update.py @@ -0,0 +1,151 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.account import Account +from ...types import Response + + +def _get_kwargs( + *, + body: Account, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": "/users/api/account/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Account]: + if response.status_code == 200: + response_200 = Account.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Account]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: Account, +) -> Response[Account]: + """ + Args: + body (Account): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: Account, +) -> Optional[Account]: + """ + Args: + body (Account): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: Account, +) -> Response[Account]: + """ + Args: + body (Account): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: Account, +) -> Optional[Account]: + """ + Args: + body (Account): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_partial_update.py b/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_partial_update.py new file mode 100644 index 0000000..87f5f90 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_partial_update.py @@ -0,0 +1,156 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.change_password import ChangePassword +from ...models.patched_change_password import PatchedChangePassword +from ...types import Response + + +def _get_kwargs( + *, + body: PatchedChangePassword, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/users/api/change_password/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[ChangePassword]: + if response.status_code == 200: + response_200 = ChangePassword.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[ChangePassword]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: PatchedChangePassword, +) -> Response[ChangePassword]: + """ + Args: + body (PatchedChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ChangePassword] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: PatchedChangePassword, +) -> Optional[ChangePassword]: + """ + Args: + body (PatchedChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ChangePassword + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: PatchedChangePassword, +) -> Response[ChangePassword]: + """ + Args: + body (PatchedChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ChangePassword] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: PatchedChangePassword, +) -> Optional[ChangePassword]: + """ + Args: + body (PatchedChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ChangePassword + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_update.py b/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_update.py new file mode 100644 index 0000000..0200030 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_change_password_update.py @@ -0,0 +1,155 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.change_password import ChangePassword +from ...types import Response + + +def _get_kwargs( + *, + body: ChangePassword, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": "/users/api/change_password/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[ChangePassword]: + if response.status_code == 200: + response_200 = ChangePassword.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[ChangePassword]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: ChangePassword, +) -> Response[ChangePassword]: + """ + Args: + body (ChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ChangePassword] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: ChangePassword, +) -> Optional[ChangePassword]: + """ + Args: + body (ChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ChangePassword + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: ChangePassword, +) -> Response[ChangePassword]: + """ + Args: + body (ChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ChangePassword] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: ChangePassword, +) -> Optional[ChangePassword]: + """ + Args: + body (ChangePassword): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ChangePassword + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_inquiry_create.py b/utils/clients/gooyal_accounts_client/api/users/users_api_inquiry_create.py new file mode 100644 index 0000000..971684f --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_inquiry_create.py @@ -0,0 +1,151 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.user_inquiry import UserInquiry +from ...types import Response + + +def _get_kwargs( + *, + body: UserInquiry, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/users/api/inquiry/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[UserInquiry]: + if response.status_code == 201: + response_201 = UserInquiry.from_dict(response.json()) + + return response_201 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[UserInquiry]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: UserInquiry, +) -> Response[UserInquiry]: + """ + Args: + body (UserInquiry): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[UserInquiry] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: UserInquiry, +) -> Optional[UserInquiry]: + """ + Args: + body (UserInquiry): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + UserInquiry + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: UserInquiry, +) -> Response[UserInquiry]: + """ + Args: + body (UserInquiry): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[UserInquiry] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: UserInquiry, +) -> Optional[UserInquiry]: + """ + Args: + body (UserInquiry): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + UserInquiry + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_request_otp_create.py b/utils/clients/gooyal_accounts_client/api/users/users_api_request_otp_create.py new file mode 100644 index 0000000..c987308 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_request_otp_create.py @@ -0,0 +1,151 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.request_otp import RequestOTP +from ...types import Response + + +def _get_kwargs( + *, + body: RequestOTP, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/users/api/request_otp/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[RequestOTP]: + if response.status_code == 201: + response_201 = RequestOTP.from_dict(response.json()) + + return response_201 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[RequestOTP]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: RequestOTP, +) -> Response[RequestOTP]: + """ + Args: + body (RequestOTP): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[RequestOTP] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: RequestOTP, +) -> Optional[RequestOTP]: + """ + Args: + body (RequestOTP): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + RequestOTP + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: RequestOTP, +) -> Response[RequestOTP]: + """ + Args: + body (RequestOTP): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[RequestOTP] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: RequestOTP, +) -> Optional[RequestOTP]: + """ + Args: + body (RequestOTP): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + RequestOTP + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_request_ott_create.py b/utils/clients/gooyal_accounts_client/api/users/users_api_request_ott_create.py new file mode 100644 index 0000000..13142f9 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_request_ott_create.py @@ -0,0 +1,151 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.request_ott import RequestOTT +from ...types import Response + + +def _get_kwargs( + *, + body: RequestOTT, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/users/api/request_ott/", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[RequestOTT]: + if response.status_code == 201: + response_201 = RequestOTT.from_dict(response.json()) + + return response_201 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[RequestOTT]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: RequestOTT, +) -> Response[RequestOTT]: + """ + Args: + body (RequestOTT): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[RequestOTT] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: RequestOTT, +) -> Optional[RequestOTT]: + """ + Args: + body (RequestOTT): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + RequestOTT + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: RequestOTT, +) -> Response[RequestOTT]: + """ + Args: + body (RequestOTT): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[RequestOTT] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: RequestOTT, +) -> Optional[RequestOTT]: + """ + Args: + body (RequestOTT): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + RequestOTT + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_users_avatar_retrieve.py b/utils/clients/gooyal_accounts_client/api/users/users_api_users_avatar_retrieve.py new file mode 100644 index 0000000..6c317d9 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_users_avatar_retrieve.py @@ -0,0 +1,92 @@ +from http import HTTPStatus +from typing import Any, Optional, Union +from uuid import UUID + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + uuid: UUID, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/users/api/users/{uuid}/avatar", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]: + if response.status_code == 200: + return None + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[Any]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[Any]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_users_details_retrieve.py b/utils/clients/gooyal_accounts_client/api/users/users_api_users_details_retrieve.py new file mode 100644 index 0000000..1da2a43 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_users_details_retrieve.py @@ -0,0 +1,143 @@ +from http import HTTPStatus +from typing import Any, Optional, Union +from uuid import UUID + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.account import Account +from ...types import Response + + +def _get_kwargs( + uuid: UUID, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/users/api/users/{uuid}/details", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Account]: + if response.status_code == 200: + response_200 = Account.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Account]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[Account]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Optional[Account]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return sync_detailed( + uuid=uuid, + client=client, + ).parsed + + +async def asyncio_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[Account]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Account] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Optional[Account]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Account + """ + + return ( + await asyncio_detailed( + uuid=uuid, + client=client, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_users_list.py b/utils/clients/gooyal_accounts_client/api/users/users_api_users_list.py new file mode 100644 index 0000000..f43b9ea --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_users_list.py @@ -0,0 +1,154 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.paginated_public_user_list import PaginatedPublicUserList +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: Union[Unset, int] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["page"] = page + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/users/api/users/", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[PaginatedPublicUserList]: + if response.status_code == 200: + response_200 = PaginatedPublicUserList.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[PaginatedPublicUserList]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Response[PaginatedPublicUserList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PaginatedPublicUserList] + """ + + kwargs = _get_kwargs( + page=page, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Optional[PaginatedPublicUserList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PaginatedPublicUserList + """ + + return sync_detailed( + client=client, + page=page, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Response[PaginatedPublicUserList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PaginatedPublicUserList] + """ + + kwargs = _get_kwargs( + page=page, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: Union[Unset, int] = UNSET, +) -> Optional[PaginatedPublicUserList]: + """ + Args: + page (Union[Unset, int]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PaginatedPublicUserList + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/api/users/users_api_users_retrieve.py b/utils/clients/gooyal_accounts_client/api/users/users_api_users_retrieve.py new file mode 100644 index 0000000..17488a5 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/api/users/users_api_users_retrieve.py @@ -0,0 +1,143 @@ +from http import HTTPStatus +from typing import Any, Optional, Union +from uuid import UUID + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.public_user import PublicUser +from ...types import Response + + +def _get_kwargs( + uuid: UUID, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/users/api/users/{uuid}/", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[PublicUser]: + if response.status_code == 200: + response_200 = PublicUser.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[PublicUser]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[PublicUser]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PublicUser] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Optional[PublicUser]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PublicUser + """ + + return sync_detailed( + uuid=uuid, + client=client, + ).parsed + + +async def asyncio_detailed( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Response[PublicUser]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[PublicUser] + """ + + kwargs = _get_kwargs( + uuid=uuid, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + uuid: UUID, + *, + client: AuthenticatedClient, +) -> Optional[PublicUser]: + """ + Args: + uuid (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + PublicUser + """ + + return ( + await asyncio_detailed( + uuid=uuid, + client=client, + ) + ).parsed diff --git a/utils/clients/gooyal_accounts_client/client.py b/utils/clients/gooyal_accounts_client/client.py new file mode 100644 index 0000000..e80446f --- /dev/null +++ b/utils/clients/gooyal_accounts_client/client.py @@ -0,0 +1,268 @@ +import ssl +from typing import Any, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/utils/clients/gooyal_accounts_client/errors.py b/utils/clients/gooyal_accounts_client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/utils/clients/gooyal_accounts_client/models/__init__.py b/utils/clients/gooyal_accounts_client/models/__init__.py new file mode 100644 index 0000000..91f431d --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/__init__.py @@ -0,0 +1,33 @@ +"""Contains all the data models used in inputs/outputs""" + +from .account import Account +from .application import Application +from .blank_enum import BlankEnum +from .change_password import ChangePassword +from .education_degree_enum import EducationDegreeEnum +from .gender_enum import GenderEnum +from .paginated_application_list import PaginatedApplicationList +from .paginated_public_user_list import PaginatedPublicUserList +from .patched_account import PatchedAccount +from .patched_change_password import PatchedChangePassword +from .public_user import PublicUser +from .request_otp import RequestOTP +from .request_ott import RequestOTT +from .user_inquiry import UserInquiry + +__all__ = ( + "Account", + "Application", + "BlankEnum", + "ChangePassword", + "EducationDegreeEnum", + "GenderEnum", + "PaginatedApplicationList", + "PaginatedPublicUserList", + "PatchedAccount", + "PatchedChangePassword", + "PublicUser", + "RequestOTP", + "RequestOTT", + "UserInquiry", +) diff --git a/utils/clients/gooyal_accounts_client/models/account.py b/utils/clients/gooyal_accounts_client/models/account.py new file mode 100644 index 0000000..b53855c --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/account.py @@ -0,0 +1,410 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.blank_enum import BlankEnum +from ..models.education_degree_enum import EducationDegreeEnum +from ..models.gender_enum import GenderEnum +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Account") + + +@_attrs_define +class Account: + """ + Attributes: + uuid (UUID): + email (str): + phone_number (str): + iban_verified (Union[None, bool]): + province_name (str): + city_name (str): + username (Union[None, Unset, str]): Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. + first_name (Union[Unset, str]): + last_name (Union[Unset, str]): + name (Union[Unset, str]): + avatar (Union[None, Unset, str]): + gender (Union[BlankEnum, GenderEnum, None, Unset]): + education_degree (Union[BlankEnum, EducationDegreeEnum, None, Unset]): + education_field (Union[None, Unset, str]): + birthdate (Union[None, Unset, datetime.date]): + iban (Union[None, Unset, str]): + province (Union[None, Unset, int]): + city (Union[None, Unset, int]): + postal_code (Union[None, Unset, str]): + address (Union[None, Unset, str]): + """ + + uuid: UUID + email: str + phone_number: str + iban_verified: Union[None, bool] + province_name: str + city_name: str + username: Union[None, Unset, str] = UNSET + first_name: Union[Unset, str] = UNSET + last_name: Union[Unset, str] = UNSET + name: Union[Unset, str] = UNSET + avatar: Union[None, Unset, str] = UNSET + gender: Union[BlankEnum, GenderEnum, None, Unset] = UNSET + education_degree: Union[BlankEnum, EducationDegreeEnum, None, Unset] = UNSET + education_field: Union[None, Unset, str] = UNSET + birthdate: Union[None, Unset, datetime.date] = UNSET + iban: Union[None, Unset, str] = UNSET + province: Union[None, Unset, int] = UNSET + city: Union[None, Unset, int] = UNSET + postal_code: Union[None, Unset, str] = UNSET + address: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + uuid = str(self.uuid) + + email = self.email + + phone_number = self.phone_number + + iban_verified: Union[None, bool] + iban_verified = self.iban_verified + + province_name = self.province_name + + city_name = self.city_name + + username: Union[None, Unset, str] + if isinstance(self.username, Unset): + username = UNSET + else: + username = self.username + + first_name = self.first_name + + last_name = self.last_name + + name = self.name + + avatar: Union[None, Unset, str] + if isinstance(self.avatar, Unset): + avatar = UNSET + else: + avatar = self.avatar + + gender: Union[None, Unset, str] + if isinstance(self.gender, Unset): + gender = UNSET + elif isinstance(self.gender, GenderEnum): + gender = self.gender.value + elif isinstance(self.gender, BlankEnum): + gender = self.gender.value + else: + gender = self.gender + + education_degree: Union[None, Unset, str] + if isinstance(self.education_degree, Unset): + education_degree = UNSET + elif isinstance(self.education_degree, EducationDegreeEnum): + education_degree = self.education_degree.value + elif isinstance(self.education_degree, BlankEnum): + education_degree = self.education_degree.value + else: + education_degree = self.education_degree + + education_field: Union[None, Unset, str] + if isinstance(self.education_field, Unset): + education_field = UNSET + else: + education_field = self.education_field + + birthdate: Union[None, Unset, str] + if isinstance(self.birthdate, Unset): + birthdate = UNSET + elif isinstance(self.birthdate, datetime.date): + birthdate = self.birthdate.isoformat() + else: + birthdate = self.birthdate + + iban: Union[None, Unset, str] + if isinstance(self.iban, Unset): + iban = UNSET + else: + iban = self.iban + + province: Union[None, Unset, int] + if isinstance(self.province, Unset): + province = UNSET + else: + province = self.province + + city: Union[None, Unset, int] + if isinstance(self.city, Unset): + city = UNSET + else: + city = self.city + + postal_code: Union[None, Unset, str] + if isinstance(self.postal_code, Unset): + postal_code = UNSET + else: + postal_code = self.postal_code + + address: Union[None, Unset, str] + if isinstance(self.address, Unset): + address = UNSET + else: + address = self.address + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "uuid": uuid, + "email": email, + "phone_number": phone_number, + "iban_verified": iban_verified, + "province_name": province_name, + "city_name": city_name, + } + ) + if username is not UNSET: + field_dict["username"] = username + if first_name is not UNSET: + field_dict["first_name"] = first_name + if last_name is not UNSET: + field_dict["last_name"] = last_name + if name is not UNSET: + field_dict["name"] = name + if avatar is not UNSET: + field_dict["avatar"] = avatar + if gender is not UNSET: + field_dict["gender"] = gender + if education_degree is not UNSET: + field_dict["education_degree"] = education_degree + if education_field is not UNSET: + field_dict["education_field"] = education_field + if birthdate is not UNSET: + field_dict["birthdate"] = birthdate + if iban is not UNSET: + field_dict["iban"] = iban + if province is not UNSET: + field_dict["province"] = province + if city is not UNSET: + field_dict["city"] = city + if postal_code is not UNSET: + field_dict["postal_code"] = postal_code + if address is not UNSET: + field_dict["address"] = address + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + uuid = UUID(d.pop("uuid")) + + email = d.pop("email") + + phone_number = d.pop("phone_number") + + def _parse_iban_verified(data: object) -> Union[None, bool]: + if data is None: + return data + return cast(Union[None, bool], data) + + iban_verified = _parse_iban_verified(d.pop("iban_verified")) + + province_name = d.pop("province_name") + + city_name = d.pop("city_name") + + def _parse_username(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + username = _parse_username(d.pop("username", UNSET)) + + first_name = d.pop("first_name", UNSET) + + last_name = d.pop("last_name", UNSET) + + name = d.pop("name", UNSET) + + def _parse_avatar(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + avatar = _parse_avatar(d.pop("avatar", UNSET)) + + def _parse_gender(data: object) -> Union[BlankEnum, GenderEnum, None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + gender_type_0 = GenderEnum(data) + + return gender_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, str): + raise TypeError() + gender_type_1 = BlankEnum(data) + + return gender_type_1 + except: # noqa: E722 + pass + return cast(Union[BlankEnum, GenderEnum, None, Unset], data) + + gender = _parse_gender(d.pop("gender", UNSET)) + + def _parse_education_degree(data: object) -> Union[BlankEnum, EducationDegreeEnum, None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + education_degree_type_0 = EducationDegreeEnum(data) + + return education_degree_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, str): + raise TypeError() + education_degree_type_1 = BlankEnum(data) + + return education_degree_type_1 + except: # noqa: E722 + pass + return cast(Union[BlankEnum, EducationDegreeEnum, None, Unset], data) + + education_degree = _parse_education_degree(d.pop("education_degree", UNSET)) + + def _parse_education_field(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + education_field = _parse_education_field(d.pop("education_field", UNSET)) + + def _parse_birthdate(data: object) -> Union[None, Unset, datetime.date]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + birthdate_type_0 = isoparse(data).date() + + return birthdate_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.date], data) + + birthdate = _parse_birthdate(d.pop("birthdate", UNSET)) + + def _parse_iban(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + iban = _parse_iban(d.pop("iban", UNSET)) + + def _parse_province(data: object) -> Union[None, Unset, int]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, int], data) + + province = _parse_province(d.pop("province", UNSET)) + + def _parse_city(data: object) -> Union[None, Unset, int]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, int], data) + + city = _parse_city(d.pop("city", UNSET)) + + def _parse_postal_code(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + postal_code = _parse_postal_code(d.pop("postal_code", UNSET)) + + def _parse_address(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + address = _parse_address(d.pop("address", UNSET)) + + account = cls( + uuid=uuid, + email=email, + phone_number=phone_number, + iban_verified=iban_verified, + province_name=province_name, + city_name=city_name, + username=username, + first_name=first_name, + last_name=last_name, + name=name, + avatar=avatar, + gender=gender, + education_degree=education_degree, + education_field=education_field, + birthdate=birthdate, + iban=iban, + province=province, + city=city, + postal_code=postal_code, + address=address, + ) + + account.additional_properties = d + return account + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/application.py b/utils/clients/gooyal_accounts_client/models/application.py new file mode 100644 index 0000000..c3538c5 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/application.py @@ -0,0 +1,106 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Application") + + +@_attrs_define +class Application: + """ + Attributes: + uuid (UUID): + name (str): + avatar (Union[None, str]): + client_id (str): + user (UUID): + allowed_scope (str): + """ + + uuid: UUID + name: str + avatar: Union[None, str] + client_id: str + user: UUID + allowed_scope: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + uuid = str(self.uuid) + + name = self.name + + avatar: Union[None, str] + avatar = self.avatar + + client_id = self.client_id + + user = str(self.user) + + allowed_scope = self.allowed_scope + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "uuid": uuid, + "name": name, + "avatar": avatar, + "client_id": client_id, + "user": user, + "allowed_scope": allowed_scope, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + uuid = UUID(d.pop("uuid")) + + name = d.pop("name") + + def _parse_avatar(data: object) -> Union[None, str]: + if data is None: + return data + return cast(Union[None, str], data) + + avatar = _parse_avatar(d.pop("avatar")) + + client_id = d.pop("client_id") + + user = UUID(d.pop("user")) + + allowed_scope = d.pop("allowed_scope") + + application = cls( + uuid=uuid, + name=name, + avatar=avatar, + client_id=client_id, + user=user, + allowed_scope=allowed_scope, + ) + + application.additional_properties = d + return application + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/blank_enum.py b/utils/clients/gooyal_accounts_client/models/blank_enum.py new file mode 100644 index 0000000..8a87569 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/blank_enum.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class BlankEnum(str, Enum): + VALUE_0 = "" + + def __str__(self) -> str: + return str(self.value) diff --git a/utils/clients/gooyal_accounts_client/models/change_password.py b/utils/clients/gooyal_accounts_client/models/change_password.py new file mode 100644 index 0000000..a49b227 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/change_password.py @@ -0,0 +1,78 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ChangePassword") + + +@_attrs_define +class ChangePassword: + """ + Attributes: + old_password (str): + new_password (str): + old_password_field (Union[Unset, str]): Default: 'password'. + """ + + old_password: str + new_password: str + old_password_field: Union[Unset, str] = "password" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + old_password = self.old_password + + new_password = self.new_password + + old_password_field = self.old_password_field + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "old_password": old_password, + "new_password": new_password, + } + ) + if old_password_field is not UNSET: + field_dict["old_password_field"] = old_password_field + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + old_password = d.pop("old_password") + + new_password = d.pop("new_password") + + old_password_field = d.pop("old_password_field", UNSET) + + change_password = cls( + old_password=old_password, + new_password=new_password, + old_password_field=old_password_field, + ) + + change_password.additional_properties = d + return change_password + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/education_degree_enum.py b/utils/clients/gooyal_accounts_client/models/education_degree_enum.py new file mode 100644 index 0000000..d8d5573 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/education_degree_enum.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class EducationDegreeEnum(str, Enum): + ASSOCIATE = "associate" + BACHELORS = "bachelors" + DOCTORATE = "doctorate" + HIGH = "high" + MASTERS = "masters" + MIDDLE = "middle" + PRIMARY = "primary" + + def __str__(self) -> str: + return str(self.value) diff --git a/utils/clients/gooyal_accounts_client/models/gender_enum.py b/utils/clients/gooyal_accounts_client/models/gender_enum.py new file mode 100644 index 0000000..2da7760 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/gender_enum.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class GenderEnum(str, Enum): + F = "f" + M = "m" + + def __str__(self) -> str: + return str(self.value) diff --git a/utils/clients/gooyal_accounts_client/models/paginated_application_list.py b/utils/clients/gooyal_accounts_client/models/paginated_application_list.py new file mode 100644 index 0000000..3d76808 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/paginated_application_list.py @@ -0,0 +1,123 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.application import Application + + +T = TypeVar("T", bound="PaginatedApplicationList") + + +@_attrs_define +class PaginatedApplicationList: + """ + Attributes: + count (int): Example: 123. + results (list['Application']): + next_ (Union[None, Unset, str]): Example: http://api.example.org/accounts/?page=4. + previous (Union[None, Unset, str]): Example: http://api.example.org/accounts/?page=2. + """ + + count: int + results: list["Application"] + next_: Union[None, Unset, str] = UNSET + previous: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + count = self.count + + results = [] + for results_item_data in self.results: + results_item = results_item_data.to_dict() + results.append(results_item) + + next_: Union[None, Unset, str] + if isinstance(self.next_, Unset): + next_ = UNSET + else: + next_ = self.next_ + + previous: Union[None, Unset, str] + if isinstance(self.previous, Unset): + previous = UNSET + else: + previous = self.previous + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "count": count, + "results": results, + } + ) + if next_ is not UNSET: + field_dict["next"] = next_ + if previous is not UNSET: + field_dict["previous"] = previous + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.application import Application + + d = dict(src_dict) + count = d.pop("count") + + results = [] + _results = d.pop("results") + for results_item_data in _results: + results_item = Application.from_dict(results_item_data) + + results.append(results_item) + + def _parse_next_(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + next_ = _parse_next_(d.pop("next", UNSET)) + + def _parse_previous(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + previous = _parse_previous(d.pop("previous", UNSET)) + + paginated_application_list = cls( + count=count, + results=results, + next_=next_, + previous=previous, + ) + + paginated_application_list.additional_properties = d + return paginated_application_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/paginated_public_user_list.py b/utils/clients/gooyal_accounts_client/models/paginated_public_user_list.py new file mode 100644 index 0000000..dbea027 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/paginated_public_user_list.py @@ -0,0 +1,123 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.public_user import PublicUser + + +T = TypeVar("T", bound="PaginatedPublicUserList") + + +@_attrs_define +class PaginatedPublicUserList: + """ + Attributes: + count (int): Example: 123. + results (list['PublicUser']): + next_ (Union[None, Unset, str]): Example: http://api.example.org/accounts/?page=4. + previous (Union[None, Unset, str]): Example: http://api.example.org/accounts/?page=2. + """ + + count: int + results: list["PublicUser"] + next_: Union[None, Unset, str] = UNSET + previous: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + count = self.count + + results = [] + for results_item_data in self.results: + results_item = results_item_data.to_dict() + results.append(results_item) + + next_: Union[None, Unset, str] + if isinstance(self.next_, Unset): + next_ = UNSET + else: + next_ = self.next_ + + previous: Union[None, Unset, str] + if isinstance(self.previous, Unset): + previous = UNSET + else: + previous = self.previous + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "count": count, + "results": results, + } + ) + if next_ is not UNSET: + field_dict["next"] = next_ + if previous is not UNSET: + field_dict["previous"] = previous + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.public_user import PublicUser + + d = dict(src_dict) + count = d.pop("count") + + results = [] + _results = d.pop("results") + for results_item_data in _results: + results_item = PublicUser.from_dict(results_item_data) + + results.append(results_item) + + def _parse_next_(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + next_ = _parse_next_(d.pop("next", UNSET)) + + def _parse_previous(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + previous = _parse_previous(d.pop("previous", UNSET)) + + paginated_public_user_list = cls( + count=count, + results=results, + next_=next_, + previous=previous, + ) + + paginated_public_user_list.additional_properties = d + return paginated_public_user_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/patched_account.py b/utils/clients/gooyal_accounts_client/models/patched_account.py new file mode 100644 index 0000000..837b3be --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/patched_account.py @@ -0,0 +1,425 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.blank_enum import BlankEnum +from ..models.education_degree_enum import EducationDegreeEnum +from ..models.gender_enum import GenderEnum +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PatchedAccount") + + +@_attrs_define +class PatchedAccount: + """ + Attributes: + uuid (Union[Unset, UUID]): + username (Union[None, Unset, str]): Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. + email (Union[Unset, str]): + phone_number (Union[Unset, str]): + first_name (Union[Unset, str]): + last_name (Union[Unset, str]): + name (Union[Unset, str]): + avatar (Union[None, Unset, str]): + gender (Union[BlankEnum, GenderEnum, None, Unset]): + education_degree (Union[BlankEnum, EducationDegreeEnum, None, Unset]): + education_field (Union[None, Unset, str]): + birthdate (Union[None, Unset, datetime.date]): + iban (Union[None, Unset, str]): + iban_verified (Union[None, Unset, bool]): + province (Union[None, Unset, int]): + province_name (Union[Unset, str]): + city (Union[None, Unset, int]): + city_name (Union[Unset, str]): + postal_code (Union[None, Unset, str]): + address (Union[None, Unset, str]): + """ + + uuid: Union[Unset, UUID] = UNSET + username: Union[None, Unset, str] = UNSET + email: Union[Unset, str] = UNSET + phone_number: Union[Unset, str] = UNSET + first_name: Union[Unset, str] = UNSET + last_name: Union[Unset, str] = UNSET + name: Union[Unset, str] = UNSET + avatar: Union[None, Unset, str] = UNSET + gender: Union[BlankEnum, GenderEnum, None, Unset] = UNSET + education_degree: Union[BlankEnum, EducationDegreeEnum, None, Unset] = UNSET + education_field: Union[None, Unset, str] = UNSET + birthdate: Union[None, Unset, datetime.date] = UNSET + iban: Union[None, Unset, str] = UNSET + iban_verified: Union[None, Unset, bool] = UNSET + province: Union[None, Unset, int] = UNSET + province_name: Union[Unset, str] = UNSET + city: Union[None, Unset, int] = UNSET + city_name: Union[Unset, str] = UNSET + postal_code: Union[None, Unset, str] = UNSET + address: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + uuid: Union[Unset, str] = UNSET + if not isinstance(self.uuid, Unset): + uuid = str(self.uuid) + + username: Union[None, Unset, str] + if isinstance(self.username, Unset): + username = UNSET + else: + username = self.username + + email = self.email + + phone_number = self.phone_number + + first_name = self.first_name + + last_name = self.last_name + + name = self.name + + avatar: Union[None, Unset, str] + if isinstance(self.avatar, Unset): + avatar = UNSET + else: + avatar = self.avatar + + gender: Union[None, Unset, str] + if isinstance(self.gender, Unset): + gender = UNSET + elif isinstance(self.gender, GenderEnum): + gender = self.gender.value + elif isinstance(self.gender, BlankEnum): + gender = self.gender.value + else: + gender = self.gender + + education_degree: Union[None, Unset, str] + if isinstance(self.education_degree, Unset): + education_degree = UNSET + elif isinstance(self.education_degree, EducationDegreeEnum): + education_degree = self.education_degree.value + elif isinstance(self.education_degree, BlankEnum): + education_degree = self.education_degree.value + else: + education_degree = self.education_degree + + education_field: Union[None, Unset, str] + if isinstance(self.education_field, Unset): + education_field = UNSET + else: + education_field = self.education_field + + birthdate: Union[None, Unset, str] + if isinstance(self.birthdate, Unset): + birthdate = UNSET + elif isinstance(self.birthdate, datetime.date): + birthdate = self.birthdate.isoformat() + else: + birthdate = self.birthdate + + iban: Union[None, Unset, str] + if isinstance(self.iban, Unset): + iban = UNSET + else: + iban = self.iban + + iban_verified: Union[None, Unset, bool] + if isinstance(self.iban_verified, Unset): + iban_verified = UNSET + else: + iban_verified = self.iban_verified + + province: Union[None, Unset, int] + if isinstance(self.province, Unset): + province = UNSET + else: + province = self.province + + province_name = self.province_name + + city: Union[None, Unset, int] + if isinstance(self.city, Unset): + city = UNSET + else: + city = self.city + + city_name = self.city_name + + postal_code: Union[None, Unset, str] + if isinstance(self.postal_code, Unset): + postal_code = UNSET + else: + postal_code = self.postal_code + + address: Union[None, Unset, str] + if isinstance(self.address, Unset): + address = UNSET + else: + address = self.address + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if uuid is not UNSET: + field_dict["uuid"] = uuid + if username is not UNSET: + field_dict["username"] = username + if email is not UNSET: + field_dict["email"] = email + if phone_number is not UNSET: + field_dict["phone_number"] = phone_number + if first_name is not UNSET: + field_dict["first_name"] = first_name + if last_name is not UNSET: + field_dict["last_name"] = last_name + if name is not UNSET: + field_dict["name"] = name + if avatar is not UNSET: + field_dict["avatar"] = avatar + if gender is not UNSET: + field_dict["gender"] = gender + if education_degree is not UNSET: + field_dict["education_degree"] = education_degree + if education_field is not UNSET: + field_dict["education_field"] = education_field + if birthdate is not UNSET: + field_dict["birthdate"] = birthdate + if iban is not UNSET: + field_dict["iban"] = iban + if iban_verified is not UNSET: + field_dict["iban_verified"] = iban_verified + if province is not UNSET: + field_dict["province"] = province + if province_name is not UNSET: + field_dict["province_name"] = province_name + if city is not UNSET: + field_dict["city"] = city + if city_name is not UNSET: + field_dict["city_name"] = city_name + if postal_code is not UNSET: + field_dict["postal_code"] = postal_code + if address is not UNSET: + field_dict["address"] = address + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + _uuid = d.pop("uuid", UNSET) + uuid: Union[Unset, UUID] + if isinstance(_uuid, Unset): + uuid = UNSET + else: + uuid = UUID(_uuid) + + def _parse_username(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + username = _parse_username(d.pop("username", UNSET)) + + email = d.pop("email", UNSET) + + phone_number = d.pop("phone_number", UNSET) + + first_name = d.pop("first_name", UNSET) + + last_name = d.pop("last_name", UNSET) + + name = d.pop("name", UNSET) + + def _parse_avatar(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + avatar = _parse_avatar(d.pop("avatar", UNSET)) + + def _parse_gender(data: object) -> Union[BlankEnum, GenderEnum, None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + gender_type_0 = GenderEnum(data) + + return gender_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, str): + raise TypeError() + gender_type_1 = BlankEnum(data) + + return gender_type_1 + except: # noqa: E722 + pass + return cast(Union[BlankEnum, GenderEnum, None, Unset], data) + + gender = _parse_gender(d.pop("gender", UNSET)) + + def _parse_education_degree(data: object) -> Union[BlankEnum, EducationDegreeEnum, None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + education_degree_type_0 = EducationDegreeEnum(data) + + return education_degree_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, str): + raise TypeError() + education_degree_type_1 = BlankEnum(data) + + return education_degree_type_1 + except: # noqa: E722 + pass + return cast(Union[BlankEnum, EducationDegreeEnum, None, Unset], data) + + education_degree = _parse_education_degree(d.pop("education_degree", UNSET)) + + def _parse_education_field(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + education_field = _parse_education_field(d.pop("education_field", UNSET)) + + def _parse_birthdate(data: object) -> Union[None, Unset, datetime.date]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + birthdate_type_0 = isoparse(data).date() + + return birthdate_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.date], data) + + birthdate = _parse_birthdate(d.pop("birthdate", UNSET)) + + def _parse_iban(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + iban = _parse_iban(d.pop("iban", UNSET)) + + def _parse_iban_verified(data: object) -> Union[None, Unset, bool]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, bool], data) + + iban_verified = _parse_iban_verified(d.pop("iban_verified", UNSET)) + + def _parse_province(data: object) -> Union[None, Unset, int]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, int], data) + + province = _parse_province(d.pop("province", UNSET)) + + province_name = d.pop("province_name", UNSET) + + def _parse_city(data: object) -> Union[None, Unset, int]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, int], data) + + city = _parse_city(d.pop("city", UNSET)) + + city_name = d.pop("city_name", UNSET) + + def _parse_postal_code(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + postal_code = _parse_postal_code(d.pop("postal_code", UNSET)) + + def _parse_address(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + address = _parse_address(d.pop("address", UNSET)) + + patched_account = cls( + uuid=uuid, + username=username, + email=email, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + name=name, + avatar=avatar, + gender=gender, + education_degree=education_degree, + education_field=education_field, + birthdate=birthdate, + iban=iban, + iban_verified=iban_verified, + province=province, + province_name=province_name, + city=city, + city_name=city_name, + postal_code=postal_code, + address=address, + ) + + patched_account.additional_properties = d + return patched_account + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/patched_change_password.py b/utils/clients/gooyal_accounts_client/models/patched_change_password.py new file mode 100644 index 0000000..60bb80a --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/patched_change_password.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PatchedChangePassword") + + +@_attrs_define +class PatchedChangePassword: + """ + Attributes: + old_password (Union[Unset, str]): + old_password_field (Union[Unset, str]): Default: 'password'. + new_password (Union[Unset, str]): + """ + + old_password: Union[Unset, str] = UNSET + old_password_field: Union[Unset, str] = "password" + new_password: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + old_password = self.old_password + + old_password_field = self.old_password_field + + new_password = self.new_password + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if old_password is not UNSET: + field_dict["old_password"] = old_password + if old_password_field is not UNSET: + field_dict["old_password_field"] = old_password_field + if new_password is not UNSET: + field_dict["new_password"] = new_password + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + old_password = d.pop("old_password", UNSET) + + old_password_field = d.pop("old_password_field", UNSET) + + new_password = d.pop("new_password", UNSET) + + patched_change_password = cls( + old_password=old_password, + old_password_field=old_password_field, + new_password=new_password, + ) + + patched_change_password.additional_properties = d + return patched_change_password + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/public_user.py b/utils/clients/gooyal_accounts_client/models/public_user.py new file mode 100644 index 0000000..7d834e8 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/public_user.py @@ -0,0 +1,113 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PublicUser") + + +@_attrs_define +class PublicUser: + """ + Attributes: + avatar (Union[None, str]): + name (str): + username (Union[None, str]): Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. + first_name (str): + last_name (str): + uuid (UUID): + """ + + avatar: Union[None, str] + name: str + username: Union[None, str] + first_name: str + last_name: str + uuid: UUID + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + avatar: Union[None, str] + avatar = self.avatar + + name = self.name + + username: Union[None, str] + username = self.username + + first_name = self.first_name + + last_name = self.last_name + + uuid = str(self.uuid) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "avatar": avatar, + "name": name, + "username": username, + "first_name": first_name, + "last_name": last_name, + "uuid": uuid, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_avatar(data: object) -> Union[None, str]: + if data is None: + return data + return cast(Union[None, str], data) + + avatar = _parse_avatar(d.pop("avatar")) + + name = d.pop("name") + + def _parse_username(data: object) -> Union[None, str]: + if data is None: + return data + return cast(Union[None, str], data) + + username = _parse_username(d.pop("username")) + + first_name = d.pop("first_name") + + last_name = d.pop("last_name") + + uuid = UUID(d.pop("uuid")) + + public_user = cls( + avatar=avatar, + name=name, + username=username, + first_name=first_name, + last_name=last_name, + uuid=uuid, + ) + + public_user.additional_properties = d + return public_user + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/request_otp.py b/utils/clients/gooyal_accounts_client/models/request_otp.py new file mode 100644 index 0000000..a72c886 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/request_otp.py @@ -0,0 +1,94 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="RequestOTP") + + +@_attrs_define +class RequestOTP: + """ + Attributes: + phone_number (str): + otp_expire (Union[None, datetime.datetime]): + ttl (str): + """ + + phone_number: str + otp_expire: Union[None, datetime.datetime] + ttl: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + phone_number = self.phone_number + + otp_expire: Union[None, str] + if isinstance(self.otp_expire, datetime.datetime): + otp_expire = self.otp_expire.isoformat() + else: + otp_expire = self.otp_expire + + ttl = self.ttl + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "phone_number": phone_number, + "otp_expire": otp_expire, + "ttl": ttl, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + phone_number = d.pop("phone_number") + + def _parse_otp_expire(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + otp_expire_type_0 = isoparse(data) + + return otp_expire_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + otp_expire = _parse_otp_expire(d.pop("otp_expire")) + + ttl = d.pop("ttl") + + request_otp = cls( + phone_number=phone_number, + otp_expire=otp_expire, + ttl=ttl, + ) + + request_otp.additional_properties = d + return request_otp + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/request_ott.py b/utils/clients/gooyal_accounts_client/models/request_ott.py new file mode 100644 index 0000000..a48091b --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/request_ott.py @@ -0,0 +1,100 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="RequestOTT") + + +@_attrs_define +class RequestOTT: + """ + Attributes: + application (str): + ott (Union[None, str]): + ott_expire (Union[None, datetime.datetime]): + """ + + application: str + ott: Union[None, str] + ott_expire: Union[None, datetime.datetime] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + application = self.application + + ott: Union[None, str] + ott = self.ott + + ott_expire: Union[None, str] + if isinstance(self.ott_expire, datetime.datetime): + ott_expire = self.ott_expire.isoformat() + else: + ott_expire = self.ott_expire + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "application": application, + "ott": ott, + "ott_expire": ott_expire, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + application = d.pop("application") + + def _parse_ott(data: object) -> Union[None, str]: + if data is None: + return data + return cast(Union[None, str], data) + + ott = _parse_ott(d.pop("ott")) + + def _parse_ott_expire(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + ott_expire_type_0 = isoparse(data) + + return ott_expire_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + ott_expire = _parse_ott_expire(d.pop("ott_expire")) + + request_ott = cls( + application=application, + ott=ott, + ott_expire=ott_expire, + ) + + request_ott.additional_properties = d + return request_ott + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/models/user_inquiry.py b/utils/clients/gooyal_accounts_client/models/user_inquiry.py new file mode 100644 index 0000000..d507185 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/models/user_inquiry.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UserInquiry") + + +@_attrs_define +class UserInquiry: + """ + Attributes: + uuid (UUID): + phone_number (str): + """ + + uuid: UUID + phone_number: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + uuid = str(self.uuid) + + phone_number = self.phone_number + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "uuid": uuid, + "phone_number": phone_number, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + uuid = UUID(d.pop("uuid")) + + phone_number = d.pop("phone_number") + + user_inquiry = cls( + uuid=uuid, + phone_number=phone_number, + ) + + user_inquiry.additional_properties = d + return user_inquiry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/utils/clients/gooyal_accounts_client/py.typed b/utils/clients/gooyal_accounts_client/py.typed new file mode 100644 index 0000000..1aad327 --- /dev/null +++ b/utils/clients/gooyal_accounts_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/utils/clients/gooyal_accounts_client/types.py b/utils/clients/gooyal_accounts_client/types.py new file mode 100644 index 0000000..b9ed58b --- /dev/null +++ b/utils/clients/gooyal_accounts_client/types.py @@ -0,0 +1,46 @@ +"""Contains some shared types for properties""" + +from collections.abc import MutableMapping +from http import HTTPStatus +from typing import BinaryIO, Generic, Literal, Optional, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +FileJsonType = tuple[Optional[str], BinaryIO, Optional[str]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: Optional[str] = None + mime_type: Optional[str] = None + + def to_tuple(self) -> FileJsonType: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: Optional[T] + + +__all__ = ["UNSET", "File", "FileJsonType", "Response", "Unset"] diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..55bf5a3 --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,139 @@ +import logging + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError +from django.http import JsonResponse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_exception_handler + +logger = logging.getLogger(__name__) + + +def _make_error_response(status_code: int, message, error_code: str) -> Response: + return Response( + { + "success": False, + "status_code": status_code, + "status_message": message, + "details": { + "message": message, + "error": error_code, + "timestamp": timezone.now().isoformat(), + }, + }, + status=status_code, + ) + + +def exception_handler(exc, context): + logger.exception(exc) + + response = drf_exception_handler(exc, context) + + if response is not None: + response_data = { + "success": False, + "status_code": response.status_code, + "status_message": str(getattr(exc, "default_detail", exc)), + "details": getattr(exc, "detail", None), + } + + if isinstance(exc, APIException): + try: + error = ( + getattr(getattr(exc, "detail", None), "code", None) + or getattr(exc, "code", None) + or getattr(exc, "default_code", "") + ) + except Exception: + error = "" + + response_data["details"] = { + "message": exc.detail, + "error": error, + "timestamp": timezone.now().isoformat(), + } + + response.data = response_data + return response + + # ── Django / DB exceptions not caught by DRF ──────────────────────────── + + if isinstance(exc, ObjectDoesNotExist): + return _make_error_response( + status.HTTP_404_NOT_FOUND, + "مورد درخواستی یافت نشد.", + "not_found", + ) + + if isinstance(exc, IntegrityError): + return _make_error_response( + status.HTTP_409_CONFLICT, + "این اطلاعات قبلاً در سیستم ثبت شده است.", + "conflict", + ) + + if isinstance(exc, DjangoValidationError): + messages = list(exc.messages) if hasattr(exc, "messages") else [str(exc)] + detail = messages[0] if len(messages) == 1 else messages + return _make_error_response( + status.HTTP_400_BAD_REQUEST, + detail, + "validation_error", + ) + + if isinstance(exc, ValueError): + return _make_error_response( + status.HTTP_400_BAD_REQUEST, + "مقدار ورودی نامعتبر است.", + "invalid_value", + ) + + return None + + +class ErrorMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + def process_exception(self, request, exception): + logger.exception(exception) + return JsonResponse( + { + "success": False, + "status_code": 500, + "status_message": "خطای داخلی سرور. لطفاً با پشتیبانی تماس بگیرید.", + "details": { + "message": "خطای داخلی سرور.", + "error": "internal_server_error", + }, + }, + status=500, + ) + + +class UnprocessableEntity(APIException): + status_code = 422 + default_detail = "درخواست معتبر است اما به دلیل خطای منطقی قابل پردازش نیست." + default_code = "unprocessable_entity" + + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = "سرویس در حال حاضر در دسترس نیست. لطفاً دوباره تلاش کنید." + default_code = "service_unavailable" + + +class Conflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = "تعارض در داده‌ها." + default_code = "conflict" diff --git a/utils/models.py b/utils/models.py new file mode 100644 index 0000000..9593375 --- /dev/null +++ b/utils/models.py @@ -0,0 +1,13 @@ +import uuid + +from django.db import models + + +class BaseModel(models.Model): + id = None + uuid = models.UUIDField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + class Meta: + abstract = True