vibe coding

This commit is contained in:
Hossein 2026-05-02 20:01:30 +03:30
parent 06c42312e3
commit f746a9ff89
131 changed files with 14831 additions and 0 deletions

View File

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09121234567\"}')",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09121234567\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09131111111\"}')",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/verify/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09131111111\",\"code\":\"0000\",\"full_name\":\"X\",\"role\":\"student\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"+989141111111\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"12345\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09223740993\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
"Bash(curl -sSv --max-time 15 https://api.sms.ir)",
"Bash(curl -sS -X POST http://localhost:8000/api/auth/otp/send/ -H 'Content-Type: application/json' -d '{\"phone_number\":\"09223740993\"}' -w '\\\\nHTTP %{http_code}\\\\n')"
]
}
}

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
.git
.gitignore
.gitattributes
.env
.env.local
__pycache__
*.pyc
*.pyo
.venv
venv
.pytest_cache
.mypy_cache
node_modules
.vscode
.idea
*.sqlite3
.DS_Store
README.md
locale/*/LC_MESSAGES/*.mo
staticfiles
media

53
.env.example Normal file
View File

@ -0,0 +1,53 @@
DJANGO_SECRET_KEY=dev-only-change-me-in-prod
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
DJANGO_SETTINGS_MODULE=ilo.settings.dev
POSTGRES_DB=ilo
POSTGRES_USER=ilo
POSTGRES_PASSWORD=ilo
POSTGRES_HOST=db
POSTGRES_PORT=5432
# SMS backend: dotted path. ConsoleSMSBackend prints OTPs to logs (dev only).
# For production with sms.ir, switch to SMSIRBackend and fill the SMSIR_* vars.
SMS_BACKEND=apps.accounts.services.sms.ConsoleSMSBackend
SMSIR_API_KEY=
SMSIR_LINE_NUMBER=
SMSIR_TEMPLATE_ID=
SMSIR_TEMPLATE_PARAM_NAME=Code
SMSIR_BASE_URL=https://api.sms.ir
# Media storage. Default = local filesystem (writes to MEDIA_ROOT).
# To use S3-compatible (MinIO / ArvanCloud Object Storage), set:
# DEFAULT_FILE_STORAGE=storages.backends.s3.S3Storage
# and add `django-storages[s3]` to requirements.txt, then configure:
# AWS_S3_ENDPOINT_URL=https://s3.your-host
# AWS_ACCESS_KEY_ID=...
# AWS_SECRET_ACCESS_KEY=...
# AWS_STORAGE_BUCKET_NAME=ilo-media
# AWS_S3_REGION_NAME=...
# AWS_S3_ADDRESSING_STYLE=path # required for MinIO/ArvanCloud
DEFAULT_FILE_STORAGE=django.core.files.storage.FileSystemStorage
# Payments. Console backend = auto-completes orders without hitting a real gateway (dev only).
# In production: PAYMENT_BACKEND=apps.payments.services.gateway.ZarinPalBackend
PAYMENT_BACKEND=apps.payments.services.gateway.ConsolePaymentBackend
PAYMENT_CALLBACK_BASE_URL=http://localhost:8000
PAYMENT_SUCCESS_REDIRECT_URL=/payment/success
PAYMENT_FAILURE_REDIRECT_URL=/payment/failed
# Platform commission % of every paid order (instructor gets the remainder).
ILO_COMMISSION_PERCENT=15
# ZarinPal — fill these and set ZARINPAL_SANDBOX=false for production.
ZARINPAL_MERCHANT_ID=
ZARINPAL_SANDBOX=true
# IDPay — fill these to enable PAYMENT_BACKEND=apps.payments.services.gateway.IDPayBackend
IDPAY_API_KEY=
IDPAY_SANDBOX=true
# Referral split (applied at checkout when a referral code is supplied)
REFERRAL_BUYER_DISCOUNT_PERCENT=10
REFERRAL_REFERRER_CREDIT_PERCENT=10

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.env
.env.local
*.sqlite3
db.sqlite3
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
staticfiles/
media/
.DS_Store
.idea/
.vscode/
*.log
locale/*/LC_MESSAGES/*.mo

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gettext \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

323
README.md Normal file
View File

@ -0,0 +1,323 @@
# ilo
SaaS platform for instructors. **Step 6C done — backend only; frontend will be built separately from the design files in `design/`.**
See [the MVP plan](../../.claude/plans/eager-snuggling-petal.md) for scope and phasing.
## Stack
- Django 5.1 + Postgres 16
- DRF + `djangorestframework-simplejwt` for the API
- `django-modeltranslation` for bilingual model fields (en + fa)
- `django-filter` for catalog filtering, `drf-spectacular` for auto-generated API docs
- Pluggable file storage via Django 5's `STORAGES` (FileSystemStorage in dev → S3-compatible in prod)
- django-environ for config
- Bilingual (en + fa) UI via `LocaleMiddleware` + `i18n_patterns` URL prefixes
- Fully containerized — no host Python needed; only Docker
## Run locally
You need: Docker Desktop (or Docker Engine + compose v2).
```bash
cp .env.example .env # already exists locally; do this on a fresh clone
docker compose up --build
```
Open:
- http://localhost:8000/ → redirects to default language
- http://localhost:8000/en/ → English placeholder home
- http://localhost:8000/fa/ → Persian placeholder home
- http://localhost:8000/admin/ → Django admin (after creating a superuser)
- http://localhost:8000/api/auth/... → Auth API (see below)
- http://localhost:8000/api/docs/ → **Swagger UI** (interactive API docs — try requests in the browser)
- http://localhost:8000/api/redoc/ → Redoc (clean reference page)
- http://localhost:8000/api/schema/ → raw OpenAPI 3 schema (YAML)
Migrations run automatically on container start.
### Common commands
```bash
docker compose logs -f web # stream logs
docker compose exec web python manage.py <command> # any manage command
docker compose exec web python manage.py createsuperuser # admin user (password)
docker compose exec web python manage.py test apps.accounts # accounts tests
docker compose down # stop, keep data
docker compose down -v # stop, wipe DB volume
```
When you change `.env`, plain `docker compose restart` won't reload env vars. Use:
```bash
docker compose up -d --force-recreate web
```
## Auth (step 2 — done, awaiting review)
**Roles:** `student`, `teacher`, `admin` (mutually exclusive). Students/teachers sign up & log in via SMS OTP. Admins log in to `/admin/` with phone + password (no OTP).
**Flow:**
```
POST /api/auth/otp/send/ body: {phone_number}
POST /api/auth/otp/verify/ body: {phone_number, code, [full_name, role]}
full_name + role are required only for new (signup) accounts
GET /api/auth/me/ requires Bearer access token
POST /api/auth/token/refresh/ body: {refresh}
POST /api/auth/logout/ body: {refresh} blacklists the refresh token
```
**OTP policy:**
- 4 digits, 2-min expiry, max 5 wrong attempts before invalidation
- 60s cooldown between sends per phone (returns `429` with `retry_after`)
- 5 sends/phone/hour and 30/min/IP throttle (DRF, cache-backed)
- Phone numbers normalized to canonical `09xxxxxxxxx`; inputs `+98...`, `0098...`, `98...` accepted
**SMS backends** (`SMS_BACKEND` env var, dotted path):
- `apps.accounts.services.sms.ConsoleSMSBackend`**dev default**, logs OTP to container output
- `apps.accounts.services.sms.SMSIRBackend` — sms.ir template send via `/v1/send/verify`. Requires `SMSIR_API_KEY`, `SMSIR_LINE_NUMBER`, `SMSIR_TEMPLATE_ID`. Param name is `Code` by default; override with `SMSIR_TEMPLATE_PARAM_NAME` if your template uses a different placeholder.
**Note on real SMS in dev:** sms.ir's API geo-restricts to Iranian IPs, so calling it from a foreign / VPN-routed dev machine will time out. Keep `ConsoleSMSBackend` locally; real SMS only needs to work once deployed to an Iran-hosted server.
**Smoke-test the flow with curl** (Console backend):
```bash
# 1. Send
curl -X POST http://localhost:8000/api/auth/otp/send/ \
-H 'Content-Type: application/json' \
-d '{"phone_number":"09121234567"}'
# 2. Read the OTP from logs
docker compose logs web | grep '\[SMS-CONSOLE\]' | tail -1
# 3. Verify (signup — first time for this phone)
curl -X POST http://localhost:8000/api/auth/otp/verify/ \
-H 'Content-Type: application/json' \
-d '{"phone_number":"09121234567","code":"<code>","full_name":"Ali","role":"teacher"}'
# 4. Use the access token
curl http://localhost:8000/api/auth/me/ -H 'Authorization: Bearer <access>'
```
**Tests:** `docker compose exec web python manage.py test apps.accounts` — 25 tests covering phone normalization, OTP service, and the full API.
**API documentation:** auto-generated from the DRF code via [drf-spectacular](https://drf-spectacular.readthedocs.io/). Always in sync with the live endpoints — add `@extend_schema(...)` to a new view to refine its description.
- Interactive: http://localhost:8000/api/docs/ (Swagger UI)
- Reference: http://localhost:8000/api/redoc/
- Raw schema: http://localhost:8000/api/schema/
## Courses (step 3 — done, awaiting review)
**Catalog model** — `Category``Course``Section``Lesson` (+ per-course `CourseHighlight` and `CourseFAQ`).
- All user-visible text fields are **bilingual** via `django-modeltranslation`: `title`, `tagline`, `description`, section/lesson `title`, lesson `body`, highlight `text`, FAQ `question`/`answer`. The DB has `*_fa` and `*_en` columns; the bare attribute returns the active language (with `fa → en` fallback).
- **Lesson kinds**: `video` (requires `video_url`), `text` (requires `body`), `pdf` (requires `pdf_file`), `task` (requires `body`), `quiz` (kind reserved for future quiz model — no quiz logic yet).
- **`is_preview`** flag on lessons → public detail exposes `body`/`video_url`/`pdf_file` only when `is_preview=true`; locked lessons return those as `null`.
**Public endpoints** (no auth):
- `GET /api/categories/`
- `GET /api/courses/` — paginated. Filters: `category`, `level`, `instructor`, `language`. Search: `?search=...` over title/tagline. Ordering: `created_at`, `published_at`. Only `status=published` courses appear.
- `GET /api/courses/<slug>/` — full detail with sections + (published) lessons + highlights + FAQs.
**Instructor endpoints** (Bearer JWT, `role=teacher`, owner-only):
*Courses* — `GET/POST /api/instructor/courses/` · `GET/PATCH/DELETE /api/instructor/courses/<id>/`. Filters: `?status=draft|published`, `?category=<id>`, `?search=...`. Ordering: `created_at`, `updated_at`, `published_at`.
- `POST /api/instructor/courses/<id>/publish/`**requires `TeacherProfile.is_approved=True`** (admins approve via the admin "Approve selected teacher profiles" action)
- `POST /api/instructor/courses/<id>/unpublish/`
- `POST /api/instructor/courses/<id>/sections/reorder/` — body `{"ids":[…]}`
*Sections* — `GET/POST /api/instructor/sections/` · `GET/PATCH/DELETE /api/instructor/sections/<id>/`. Filter: `?course=<id>`.
- `POST /api/instructor/sections/<id>/lessons/reorder/` — body `{"ids":[…]}`
*Lessons* — `GET/POST /api/instructor/lessons/` · `GET/PATCH/DELETE /api/instructor/lessons/<id>/`. Filters: `?section=<id>`, `?section__course=<id>`, `?kind=`, `?status=`, `?is_preview=`.
*Course highlights* (bullets on the public course page) — `GET/POST/PATCH/DELETE /api/instructor/highlights/`. Filter: `?course=<id>`.
*Course FAQs* — `GET/POST/PATCH/DELETE /api/instructor/faqs/`. Filter: `?course=<id>`.
**File uploads** (cover images, PDFs) are written via Django's pluggable `STORAGES`. Default in dev is `FileSystemStorage` writing to `MEDIA_ROOT` (served at `/media/...` only when `DEBUG=True`). To swap to S3-compatible storage in prod, see `.env.example` — set `DEFAULT_FILE_STORAGE=storages.backends.s3.S3Storage`, add `django-storages[s3]` to `requirements.txt`, and configure the `AWS_*` env vars.
**Tests:** `docker compose exec web python manage.py test` — 144 tests total.
## Promotions, referrals, search, IDPay (step 6C — done, awaiting review)
**Pricing & promotions**
- `Course.discount_starts_at` / `discount_ends_at` → catalog responses now include `discount_active` (computed) plus the raw `discount_starts_at` / `discount_ends_at` fields so the frontend can render the countdown timer
- `PromoCode` model: `code`, `discount_percent` *or* `discount_amount_toman`, optional `course` scope, `max_uses` / `used_count`, `valid_from` / `valid_until`, `is_active`. Code matching is case-insensitive (stored uppercase)
- Order now snapshots `base_amount_toman`, `discount_amount_toman`, `amount_toman` (= base discount). Commission still computed off the final charged amount
- **Zero-amount auto-complete**: if a 100% promo or full referral discount drops `amount_toman` to 0, the gateway is skipped — the order is created with `gateway=free`, marked paid, and the enrollment created in one atomic step
**Referrals**
- `User.referral_code` (6-char alphanumeric, auto-generated on save, unique). Existing users were backfilled by migration
- Pass `{"referral_code": "<code>"}` at checkout → buyer gets `REFERRAL_BUYER_DISCOUNT_PERCENT` off (default 10%); on successful payment a `Referral` row is created with `referrer_credit_toman = REFERRAL_REFERRER_CREDIT_PERCENT` of the post-discount amount
- Self-referral and `promo_code + referral_code` together are rejected at validate time
- Referrer credit is **tracking-only** — actual payout is an ops process (or future Wallet step)
**Featured instructors**
- `TeacherProfile.is_featured` flag → `GET /api/instructors/?is_featured=true` returns the curated strip
**Global search**
- `GET /api/search/?q=&types=courses,instructors,lessons&limit=5` — single endpoint for the topbar / ⌘K palette
- Default returns `courses` + `instructors`. Authenticated students additionally get `lessons` matching their enrolled courses
**Checkout endpoints**
- `POST /api/courses/<slug>/checkout/` body now accepts optional `{promo_code, referral_code}` (mutually exclusive)
- New: `POST /api/courses/<slug>/checkout/preview/` body `{promo_code?, referral_code?}` → returns `base_amount_toman`, `discount_amount_toman`, `final_amount_toman`, `promo_code_applied`, `referrer_code_applied` without creating an order. Drives the "apply code" step in the cart UI
**IDPay backend**
- `apps.payments.services.gateway.IDPayBackend` — full lifecycle (POST `/v1.1/payment` → redirect → POST `/v1.1/payment/verify`). Sandbox toggled via `X-SANDBOX: 1` header. Same Toman→Rial × 10 convention. Switch via `PAYMENT_BACKEND` env var; configure `IDPAY_API_KEY` + `IDPAY_SANDBOX`
**Persian + English `.po` translation catalogs**
- Generated via `python manage.py makemessages -l fa -l en``locale/{fa,en}/LC_MESSAGES/django.po`
- Empty translations ready to fill. Compile with `python manage.py compilemessages`
**New env vars** (all optional with sensible defaults)
- `REFERRAL_BUYER_DISCOUNT_PERCENT=10`
- `REFERRAL_REFERRER_CREDIT_PERCENT=10`
- `IDPAY_API_KEY=` and `IDPAY_SANDBOX=true`
## KPIs + activity + engagement (step 6B — done, awaiting review)
**New plumbing**
- `User.last_active_at` — bumped on every authenticated API request via a custom `TrackingJWTAuthentication` (debounced to one DB write per user per 5 minutes)
- `ActivityEvent` model (`apps/enrollments/models.py`) + `record_event()` helper (`apps/enrollments/services/events.py`) — events fire automatically from EnrollView, the gateway callback (enrolled + paid), LessonCompleteView (+ course_completed when 100%), and InstructorAnnouncementViewSet
- `Enrollment.engagement_status()` — computed bucket: `active` / `at_risk` / `inactive` / `completed`
**Instructor enrollments list** (`/api/instructor/courses/<id>/enrollments/`) now returns `student_last_active_at` + `engagement_status` per row, and accepts `?engagement_status=active|at_risk|inactive|completed` to filter.
**New endpoints under tag `analytics`:**
- `GET /api/instructor/kpis/` — 4 KPI cards in the design's shape (revenue, active_students, avg_progress, satisfaction). Each card has `value`, `unit`, `delta_percent` vs prior period, `good`, and a 12-point `spark` for the sparkline. Satisfaction returns 0 until Reviews ship.
- `GET /api/instructor/revenue/monthly/` — last 12 calendar months of `revenue_toman` + `orders_count`
- `GET /api/instructor/activity/` — paginated activity feed across the instructor's courses. Filters: `?course=<id>`, `?kind=enrolled|paid|lesson_completed|course_completed|announcement_posted`
## Catalog enrichment (step 6A — done, awaiting review)
Small fields + endpoints to round out the catalog & checkout shapes for the frontend:
**New fields**
- `Lesson.is_downloadable` (default `False`) — opt-in per lesson; videos are non-downloadable unless the instructor flips this on
- `Course.is_coming_soon` (default `False`) — explicit instructor flag for the catalog badge
- `TeacherProfile.headline` / `.bio` / `.avatar` / `.years_experience` / `.is_verified` — fills the "About instructor" card
**Catalog list/detail now include** (annotated via correlated subqueries — single SQL):
- `lesson_count`, `enrolled_count`, `total_minutes_seconds`
- `is_new` (created within `COURSE_NEW_WINDOW_DAYS`, default 30 days)
- `is_bestseller` (≥ `COURSE_BESTSELLER_THRESHOLD` paid orders in the last `COURSE_BESTSELLER_WINDOW_DAYS`, defaults `10` & `90`)
- `is_coming_soon` (the model flag)
- `instructor` block on detail view (id, full_name, headline, bio, is_verified, years_experience, avatar)
**New ordering on `/api/courses/`:** `?ordering=-popularity` — sorts by paid-order count over the bestseller window. Combine with filters/search.
**New endpoints**
- `GET /api/categories/` now returns `course_count` per category (counted across published courses only)
- `GET /api/instructors/` — featured-instructors strip; sorted by `students_count` then `courses_count`
- `GET /api/instructors/<id>/` — public instructor profile + their published courses (catalog shape)
- `GET /api/me/orders/<id>/` — order receipt for the checkout success page (owner-only)
**New env vars** (all optional with sensible defaults):
- `COURSE_NEW_WINDOW_DAYS=30`
- `COURSE_BESTSELLER_THRESHOLD=10`
- `COURSE_BESTSELLER_WINDOW_DAYS=90`
## Payments + pricing (step 5 — done, awaiting review)
Course pricing is stored in **Toman** on `Course` (`price_toman`, `original_price_toman`, both nullable — null/0 = **free**). ZarinPal expects Rial, so the gateway client multiplies by 10 on every API call.
**Endpoints:**
*Student* (Bearer JWT, `role=student`):
- `POST /api/courses/<slug>/checkout/` — opens a payment intent, creates a pending `Order`, returns `{order_id, redirect_url, amount_toman, gateway}`. Redirect the user's browser to `redirect_url`. Returns `400` if the course is free or the student is already enrolled.
- `GET /api/me/orders/` — order history (any status), most recent first
- `POST /api/courses/<slug>/enroll/` — still works for **free** courses; returns `402` for paid ones with the checkout URL
*Gateway* (server-to-browser-to-server, no auth):
- `GET /api/payments/callback/<gateway>/?order_id=&Authority=&Status=` — the gateway redirects the user's browser here after they pay (or cancel). We `select_for_update` the Order, verify with the gateway, mark `paid` (or `failed`), create the Enrollment on success, and HTTP-redirect to the configured success/failure URL. Idempotent — already-paid orders skip re-verify.
*Instructor*:
- `GET /api/instructor/orders/` — sales across all my courses. Filter `?status=paid&course=<id>`.
**Backends** (mirrors the SMS pattern; switched via `PAYMENT_BACKEND` env):
- `apps.payments.services.gateway.ConsolePaymentBackend`**dev default**. `start()` returns a URL pointing back to our own callback with `Status=OK`, so the dev clicks through and completes the order without a real gateway. `verify()` always succeeds.
- `apps.payments.services.gateway.ZarinPalBackend` — production. Talks to ZarinPal v4 (sandbox or live, toggled by `ZARINPAL_SANDBOX`). Hand-rolled with `requests`, lifted from the working Vitron-back implementation with the cleanups identified during code review (no hardcoded sandbox URLs, explicit timeouts, no logging of secrets, treats verify code 101 = "already verified" as success).
**Commission:** `ILO_COMMISSION_PERCENT` (default `15`). Each `Order` snapshots the commission % at creation time and stores `ilo_fee_toman` + `instructor_share_toman` so changes to the platform rate don't affect already-paid orders. Actual payout to instructors is an ops process (out of scope here).
**Production prerequisites for real payments:**
1. Register a ZarinPal merchant account, get `MerchantID`
2. Set in `.env`: `PAYMENT_BACKEND=apps.payments.services.gateway.ZarinPalBackend`, `ZARINPAL_MERCHANT_ID=...`, `ZARINPAL_SANDBOX=false`, `PAYMENT_CALLBACK_BASE_URL=https://your-public-domain` (must be reachable from ZarinPal — Iran VPS, public HTTPS), `PAYMENT_SUCCESS_REDIRECT_URL=https://...frontend.../payment/success`, `PAYMENT_FAILURE_REDIRECT_URL=https://...frontend.../payment/failed`
3. `docker compose up -d --force-recreate web`
## Enrollments + progress + announcements (step 4 — done, awaiting review)
**Student flow** (Bearer JWT, `role=student`):
- `POST /api/courses/<slug>/enroll/` — enroll in a published course (idempotent)
- `GET /api/me/courses/` — my enrolled courses with `progress_percent`, `completed_lessons`, `total_lessons`, `last_lesson_id`/`title`, `next_lesson_id`/`title` (matches the design's `MY_COURSES` shape)
- `GET /api/me/courses/<slug>/` — full course tree with **all published lesson content unlocked** (no preview gating once enrolled). Includes per-lesson completion state.
- `POST /api/me/lessons/<id>/complete/` — mark a lesson complete (auto-marks the enrollment as completed when all published lessons are done)
- `POST /api/me/lessons/<id>/uncomplete/` — reverse it
- `GET /api/me/courses/<slug>/announcements/` — instructor's posts on this course
**Instructor flow** (Bearer JWT, `role=teacher`, owner-only):
- `GET /api/instructor/courses/<id>/enrollments/` — students enrolled in my course (CRM-style list with progress)
- `GET/POST/PATCH/DELETE /api/instructor/announcements/` — bilingual `body` field via modeltranslation; filter `?course=<id>`
## Project layout
```
ilo/
├── manage.py
├── Dockerfile / docker-compose.yml
├── requirements.txt
├── .env / .env.example
├── ilo/ # Django project package
│ ├── settings/{base,dev,prod}.py
│ ├── urls.py # i18n_patterns + /api/auth
│ ├── wsgi.py / asgi.py
├── apps/
│ ├── accounts/ # custom User, OTPCode, TeacherProfile ✅ step 2
│ │ ├── models.py
│ │ ├── validators.py # Iran phone normalization + validation
│ │ ├── admin.py
│ │ ├── api/ # DRF serializers, views, urls, throttling
│ │ ├── services/ # sms.py (Console + sms.ir backends), otp.py
│ │ ├── migrations/0001_initial.py
│ │ └── tests.py
│ ├── core/ # placeholder home view (no UI yet)
│ ├── courses/ enrollments/ announcements/ # stubs
└── locale/ # gettext .po files (empty until UI strings exist)
```
## What's done
- **Step 1 — scaffold** ✅ : containerized dev (Django + Postgres), split settings, i18n config, stub apps, language switcher
- **Step 2 — auth** ✅ : custom User (phone+OTP), 3 roles, TeacherProfile w/ approval, SMS OTP send/verify API, JWT (simplejwt), throttling, Iran phone normalization, 25 tests, sms.ir backend (geo-blocked from dev — works in prod)
- **Step 3 — courses** ✅ : Category / Course / Section / Lesson + Highlight + FAQ, all bilingual via modeltranslation; public catalog with filters/search/pagination; instructor CRUD with publish/approval gate, status / course / section filters on every list; section + lesson reorder; preview-gated lesson content; pluggable storage; 33 tests
- **Step 4 — enrollments + progress + announcements** ✅ : Enrollment, LessonProgress (auto course completion when 100%), Announcement (bilingual body); student endpoints (enroll, my-courses dashboard, full unlocked course detail, complete/uncomplete); instructor enrollment list (CRM); instructor announcement CRUD + student feed; 18 tests
- **Step 5 — payments + pricing** ✅ : `price_toman` / `original_price_toman` on Course; `Order` model with snapshotted commission split; pluggable gateway backend (Console for dev, ZarinPal for prod, lifted-and-cleaned from Vitron); `/checkout`, `/payments/callback/<gateway>`, `/me/orders`, `/instructor/orders`; free vs paid course flows (`/enroll` returns 402 for paid); idempotent callback under `select_for_update`; 15 tests
- **Step 6A — catalog enrichment** ✅ : badges (`is_new` / `is_bestseller` / `is_coming_soon`), category course-counts, course stats (lessons / minutes / enrolled), bestseller ordering, public instructor list + detail endpoints, "About instructor" card embedded in course detail, `Lesson.is_downloadable` opt-in toggle, `MyOrderDetailView` for checkout success page; 10 tests added
- **Step 6B — KPIs + activity + engagement** ✅ : `User.last_active_at` (auto-tracked via custom JWT auth), `ActivityEvent` model + `record_event` helper hooked into all engagement-touching flows, `Enrollment.engagement_status()` bucket, instructor enrollments enriched with `student_last_active_at` + `engagement_status` filter, new `/api/instructor/kpis/`, `/revenue/monthly/`, `/activity/` endpoints (sparkline + 12-month chart + paginated feed); 18 tests added
- **Step 6C — promos + referrals + search + IDPay** ✅ : `PromoCode` + `Referral` models with snapshotted discount on Order (`base_amount` / `discount` / `amount`), `User.referral_code` auto-generated, zero-amount auto-complete bypasses gateway, `discount_starts_at`/`ends_at` for catalog countdown, `TeacherProfile.is_featured` + filter, global `/api/search/`, checkout `/preview/` endpoint, IDPay payment backend, fa+en `.po` files generated; 25 tests added
## What's NOT done yet
- **Frontend** — design files in `design/` are reference only; a separate frontend project will consume the API
- **Reviews** (course rating + reviews from enrolled students)
- **Refunds** (admin manual mark-as-cancelled works; no API yet)
- **Real-SMS + real-ZarinPal smoke test** — both endpoints geo-block from this dev machine; first end-to-end verification happens on the Iran-deployed instance
- **Quizzes / tasks / live sessions / certificates / chat / marketing / white-label site** — phase 2+ per the long-term vision
- **i18n .po files** — generated once we have real UI strings to translate
- **Production secrets:** the dev `DJANGO_SECRET_KEY` is too short for HS256 (PyJWT warns). Generate a 32+ byte secret for prod.
- **Cache for production:** dev uses `LocMemCache` (per-process) — fine for the single dev container, but for prod throttling + JWT-blacklist coordination across workers, swap in Redis.
- **S3 storage activation:** `STORAGES` is wired but `django-storages[s3]` is NOT yet in requirements. Add it (and the `AWS_*` env vars) when we deploy to a host with MinIO/ArvanCloud bucket.
## Notes & decisions
- `requirements.txt` instead of `pyproject.toml` — simpler Docker layer caching.
- `TIME_ZONE = "Asia/Tehran"`.
- Custom User uses `phone_number` as `USERNAME_FIELD`; `full_name` is the only `REQUIRED_FIELD`. Email is optional.
- OTP code stored in a separate `OTPCode` model (not on the User row, unlike Vitron-back) — supports multiple concurrent codes, attempt counters, and audit history.
- Teachers are auto-given a `TeacherProfile` (`is_approved=False`) on signup. They can browse but cannot publish courses until an admin approves them via the admin "Approve selected" action.

0
apps/__init__.py Normal file
View File

View File

69
apps/accounts/admin.py Normal file
View File

@ -0,0 +1,69 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from .models import OTPCode, TeacherProfile, User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
ordering = ("-date_joined",)
list_display = ("phone_number", "full_name", "role", "is_active", "is_staff", "date_joined")
list_filter = ("role", "is_active", "is_staff")
search_fields = ("phone_number", "full_name", "email")
readonly_fields = ("last_login", "date_joined")
fieldsets = (
(None, {"fields": ("phone_number", "password")}),
(_("Personal info"), {"fields": ("full_name", "email", "language")}),
(
_("Role & permissions"),
{"fields": ("role", "is_active", "is_staff", "is_superuser", "groups", "user_permissions")},
),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("phone_number", "full_name", "role", "password1", "password2"),
},
),
)
@admin.register(TeacherProfile)
class TeacherProfileAdmin(admin.ModelAdmin):
list_display = ("user", "headline", "is_verified", "is_approved", "approved_at", "approved_by")
list_filter = ("is_approved", "is_verified")
search_fields = ("user__phone_number", "user__full_name", "headline", "bio")
raw_id_fields = ("user", "approved_by")
actions = ["approve_selected"]
@admin.action(description=_("Approve selected teacher profiles"))
def approve_selected(self, request, queryset):
for tp in queryset.filter(is_approved=False):
tp.approve(by_user=request.user)
@admin.register(OTPCode)
class OTPCodeAdmin(admin.ModelAdmin):
list_display = ("phone_number", "code_masked", "created_at", "expires_at", "is_used", "attempts")
list_filter = ("is_used",)
search_fields = ("phone_number",)
readonly_fields = (
"phone_number",
"code",
"created_at",
"expires_at",
"attempts",
"is_used",
"consumed_at",
)
@admin.display(description=_("code"))
def code_masked(self, obj):
if not obj.code:
return ""
return obj.code[0] + "*" * (len(obj.code) - 1)

View File

144
apps/accounts/api/public.py Normal file
View File

@ -0,0 +1,144 @@
"""Public-facing endpoints for instructor profiles (used by the catalog UI)."""
from datetime import timedelta
from django.conf import settings
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.generics import ListAPIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import Role, User
from apps.courses.api.queries import annotate_course_stats
from apps.courses.api.serializers import CourseListSerializer
from apps.courses.models import Course, CourseStatus
from apps.payments.models import Order, OrderStatus
def _instructor_qs():
"""Teachers (approved or not — all show on the catalog) annotated with stats."""
return User.objects.filter(role=Role.TEACHER).select_related("teacher_profile")
def _profile(obj):
"""Returns the user's TeacherProfile or None (defensive — should always exist for teachers)."""
return getattr(obj, "teacher_profile", None)
class InstructorListItemSerializer(serializers.Serializer):
id = serializers.IntegerField()
full_name = serializers.CharField()
headline = serializers.SerializerMethodField()
is_verified = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
courses_count = serializers.IntegerField(default=0)
students_count = serializers.IntegerField(default=0)
average_rating = serializers.FloatField(allow_null=True, default=None)
def get_headline(self, obj):
tp = _profile(obj)
return tp.headline if tp else ""
def get_is_verified(self, obj):
tp = _profile(obj)
return bool(tp.is_verified) if tp else False
def get_avatar(self, obj):
tp = _profile(obj)
if not tp or not tp.avatar:
return None
request = self.context.get("request")
return request.build_absolute_uri(tp.avatar.url) if request else tp.avatar.url
class InstructorDetailSerializer(InstructorListItemSerializer):
bio = serializers.SerializerMethodField()
years_experience = serializers.SerializerMethodField()
def get_bio(self, obj):
tp = _profile(obj)
return tp.bio if tp else ""
def get_years_experience(self, obj):
tp = _profile(obj)
return tp.years_experience if tp else None
def _annotate_instructor_stats(qs):
"""Adds courses_count and students_count subqueries."""
courses_sq = (
Course.objects.filter(instructor=OuterRef("pk"), status=CourseStatus.PUBLISHED)
.order_by()
.values("instructor")
.annotate(c=Count("id"))
.values("c")[:1]
)
students_sq = (
Order.objects.filter(course__instructor=OuterRef("pk"), status=OrderStatus.PAID)
.order_by()
.values("course__instructor")
.annotate(c=Count("student", distinct=True))
.values("c")[:1]
)
return qs.annotate(
courses_count=Coalesce(Subquery(courses_sq, output_field=IntegerField()), 0),
students_count=Coalesce(Subquery(students_sq, output_field=IntegerField()), 0),
)
@extend_schema(
tags=["instructors-public"],
summary="List public instructors",
description=(
"Used by the catalog's 'featured instructors' strip. Each instructor includes their "
"published-courses count and a unique-paid-students count. Pass `?is_featured=true` "
"to scope to only the strip's curated list."
),
)
class InstructorListView(ListAPIView):
permission_classes = [AllowAny]
pagination_class = None
serializer_class = InstructorListItemSerializer
ordering_fields = ["students_count", "courses_count", "id"]
ordering = ["-students_count", "-courses_count"]
def get_queryset(self):
qs = _annotate_instructor_stats(_instructor_qs())
if self.request.query_params.get("is_featured", "").lower() in {"1", "true", "yes"}:
qs = qs.filter(teacher_profile__is_featured=True)
return qs
@extend_schema(
tags=["instructors-public"],
summary="Public instructor profile",
description=(
"Returns the instructor's full public profile (bio, headline, avatar, verified, "
"years_experience) plus stats (`courses_count`, `students_count`) and the list of their "
"published courses with the standard catalog shape."
),
responses={200: InstructorDetailSerializer, 404: OpenApiResponse(description="Not found.")},
)
class InstructorDetailView(APIView):
permission_classes = [AllowAny]
def get(self, request, instructor_id):
qs = _annotate_instructor_stats(_instructor_qs())
instructor = get_object_or_404(qs, pk=instructor_id)
courses = annotate_course_stats(
Course.objects.filter(instructor=instructor, status=CourseStatus.PUBLISHED)
.select_related("instructor", "category")
).order_by("-published_at")
body = InstructorDetailSerializer(instructor, context={"request": request}).data
body["courses"] = CourseListSerializer(
courses, many=True, context={"request": request}
).data
return Response(body)

View File

@ -0,0 +1,12 @@
from django.urls import path
from .public import InstructorDetailView, InstructorListView
urlpatterns = [
path("instructors/", InstructorListView.as_view(), name="public-instructors"),
path(
"instructors/<int:instructor_id>/",
InstructorDetailView.as_view(),
name="public-instructor-detail",
),
]

View File

@ -0,0 +1,57 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from rest_framework_simplejwt.tokens import RefreshToken
from ..models import Role
from ..validators import normalize_iran_mobile, validate_iran_mobile
User = get_user_model()
class IranPhoneField(serializers.CharField):
def to_internal_value(self, data):
v = super().to_internal_value(data)
normalized = normalize_iran_mobile(v)
try:
validate_iran_mobile(normalized)
except DjangoValidationError:
raise serializers.ValidationError("Enter a valid Iranian mobile number.")
return normalized
class OTPSendSerializer(serializers.Serializer):
phone_number = IranPhoneField()
class OTPVerifySerializer(serializers.Serializer):
phone_number = IranPhoneField()
code = serializers.CharField(min_length=4, max_length=6)
full_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
role = serializers.ChoiceField(
required=False,
choices=[(Role.STUDENT.value, "student"), (Role.TEACHER.value, "teacher")],
)
def validate(self, attrs):
if not User.objects.filter(phone_number=attrs["phone_number"]).exists():
missing = {}
if not attrs.get("full_name"):
missing["full_name"] = "Required for new account"
if not attrs.get("role"):
missing["role"] = "Required for new account"
if missing:
raise serializers.ValidationError(missing)
return attrs
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "phone_number", "full_name", "role", "email", "language"]
read_only_fields = fields
def issue_tokens_for(user) -> dict:
refresh = RefreshToken.for_user(user)
return {"access": str(refresh.access_token), "refresh": str(refresh)}

View File

@ -0,0 +1,23 @@
from rest_framework.throttling import SimpleRateThrottle
class OTPSendByPhoneThrottle(SimpleRateThrottle):
"""Per-phone hourly limit on OTP sends (configured via DEFAULT_THROTTLE_RATES)."""
scope = "otp_send_phone"
def get_cache_key(self, request, view):
phone = (request.data or {}).get("phone_number") if hasattr(request, "data") else None
if not phone:
return None
return self.cache_format % {"scope": self.scope, "ident": phone}
class OTPSendByIPThrottle(SimpleRateThrottle):
"""Per-IP throttle on OTP sends to absorb scripted abuse from a single client."""
scope = "otp_send_ip"
def get_cache_key(self, request, view):
ident = self.get_ident(request)
return self.cache_format % {"scope": self.scope, "ident": ident}

14
apps/accounts/api/urls.py Normal file
View File

@ -0,0 +1,14 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .views import LogoutView, MeView, OTPSendView, OTPVerifyView
app_name = "accounts_api"
urlpatterns = [
path("otp/send/", OTPSendView.as_view(), name="otp-send"),
path("otp/verify/", OTPVerifyView.as_view(), name="otp-verify"),
path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
]

234
apps/accounts/api/views.py Normal file
View File

@ -0,0 +1,234 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema, inline_serializer
from rest_framework import serializers as drf_serializers
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.tokens import RefreshToken
from ..models import Role, TeacherProfile
from ..services.otp import (
OTPCooldownError,
OTPVerificationError,
send_otp,
verify_otp,
)
from ..services.sms import SMSError
from .serializers import (
OTPSendSerializer,
OTPVerifySerializer,
UserSerializer,
issue_tokens_for,
)
from .throttling import OTPSendByIPThrottle, OTPSendByPhoneThrottle
User = get_user_model()
_TOKEN_PAIR = inline_serializer(
name="TokenPair",
fields={
"access": drf_serializers.CharField(),
"refresh": drf_serializers.CharField(),
},
)
class OTPSendView(APIView):
permission_classes = [AllowAny]
throttle_classes = [OTPSendByPhoneThrottle, OTPSendByIPThrottle]
@extend_schema(
tags=["auth"],
summary="Send OTP",
description=(
"Generate a 4-digit OTP and dispatch it to the given Iranian phone number "
"via the configured SMS backend. Phone is normalized to `09xxxxxxxxx`. "
"Per-phone cooldown of 60 seconds; per-phone hourly cap of 5; per-IP cap of 30/min."
),
request=OTPSendSerializer,
responses={
200: OpenApiResponse(
response=inline_serializer(
name="OTPSendResponse",
fields={
"detail": drf_serializers.CharField(),
"phone_number": drf_serializers.CharField(),
},
),
description="OTP issued and SMS dispatched.",
),
400: OpenApiResponse(description="Phone number is missing or invalid."),
429: OpenApiResponse(
response=inline_serializer(
name="OTPCooldownResponse",
fields={
"detail": drf_serializers.CharField(),
"retry_after": drf_serializers.IntegerField(),
},
),
description="Cooldown active or hourly cap reached.",
),
502: OpenApiResponse(description="SMS provider is unreachable or returned an error."),
},
examples=[
OpenApiExample(
"Iran phone, canonical",
value={"phone_number": "09121234567"},
request_only=True,
),
OpenApiExample(
"Iran phone, E.164",
value={"phone_number": "+989121234567"},
request_only=True,
),
],
)
def post(self, request):
ser = OTPSendSerializer(data=request.data)
ser.is_valid(raise_exception=True)
phone = ser.validated_data["phone_number"]
try:
send_otp(phone)
except OTPCooldownError as e:
return Response(
{"detail": "Too soon", "retry_after": e.retry_after},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except SMSError as e:
return Response(
{"detail": "Could not send SMS", "error": str(e)},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response({"detail": "Code sent", "phone_number": phone})
class OTPVerifyView(APIView):
permission_classes = [AllowAny]
@extend_schema(
tags=["auth"],
summary="Verify OTP and login or signup",
description=(
"Consumes the latest active OTP for the phone.\n\n"
"- **Existing user** → login. Body needs only `phone_number` and `code`.\n"
"- **New user** → signup. Body must also include `full_name` and `role` (`student` or `teacher`). "
"If `role=teacher`, a `TeacherProfile` is auto-created with `is_approved=False` "
"(an admin must approve before the teacher can publish courses).\n\n"
"Returns access + refresh JWTs on success."
),
request=OTPVerifySerializer,
responses={
200: OpenApiResponse(
response=inline_serializer(
name="OTPVerifyResponse",
fields={
"created": drf_serializers.BooleanField(),
"user": UserSerializer(),
"tokens": _TOKEN_PAIR,
},
),
description="Login or signup succeeded; tokens issued.",
),
400: OpenApiResponse(
description=(
"Possible reasons: invalid code, code expired, no active code for phone, "
"too many wrong attempts, or signup fields missing for new account."
)
),
},
examples=[
OpenApiExample(
"Existing user — login",
value={"phone_number": "09121234567", "code": "1234"},
request_only=True,
),
OpenApiExample(
"New user — signup as teacher",
value={
"phone_number": "09121234567",
"code": "1234",
"full_name": "Ali Reza",
"role": "teacher",
},
request_only=True,
),
],
)
def post(self, request):
ser = OTPVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
phone = ser.validated_data["phone_number"]
code = ser.validated_data["code"]
try:
verify_otp(phone, code)
except OTPVerificationError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone_number=phone).first()
created = False
if user is None:
with transaction.atomic():
user = User.objects.create_user(
phone_number=phone,
full_name=ser.validated_data["full_name"],
role=ser.validated_data["role"],
)
if user.role == Role.TEACHER:
TeacherProfile.objects.create(user=user)
created = True
tokens = issue_tokens_for(user)
return Response(
{
"created": created,
"user": UserSerializer(user).data,
"tokens": tokens,
}
)
class MeView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["auth"],
summary="Current user",
description="Returns the authenticated user's profile (id, phone, full name, role, email, language).",
responses={200: UserSerializer, 401: OpenApiResponse(description="Missing or invalid token.")},
)
def get(self, request):
return Response(UserSerializer(request.user).data)
class LogoutView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["auth"],
summary="Logout (blacklist refresh token)",
description="Blacklists the supplied refresh token so it cannot be rotated again. The access token remains valid until natural expiry.",
request=inline_serializer(
name="LogoutRequest",
fields={"refresh": drf_serializers.CharField()},
),
responses={
204: OpenApiResponse(description="Refresh token blacklisted."),
400: OpenApiResponse(description="Missing or invalid refresh token."),
},
)
def post(self, request):
refresh = request.data.get("refresh")
if not refresh:
return Response({"detail": "refresh required"}, status=status.HTTP_400_BAD_REQUEST)
try:
RefreshToken(refresh).blacklist()
except TokenError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)

7
apps/accounts/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"

26
apps/accounts/auth.py Normal file
View File

@ -0,0 +1,26 @@
"""Custom JWT authentication that touches `User.last_active_at` on each request."""
from datetime import timedelta
from django.utils import timezone
from rest_framework_simplejwt.authentication import JWTAuthentication
class TrackingJWTAuthentication(JWTAuthentication):
"""Wraps simplejwt's auth and bumps `last_active_at` whenever an authenticated
request comes in. Debounced at most one DB write per user per `DEBOUNCE_SECONDS`.
"""
DEBOUNCE_SECONDS = 5 * 60 # 5 minutes
def authenticate(self, request):
result = super().authenticate(request)
if result is None:
return None
user, validated_token = result
now = timezone.now()
last = user.last_active_at
if last is None or (now - last).total_seconds() > self.DEBOUNCE_SECONDS:
type(user).objects.filter(pk=user.pk).update(last_active_at=now)
user.last_active_at = now
return user, validated_token

View File

@ -0,0 +1,80 @@
# Generated by Django 5.1.15 on 2026-05-02 06:48
import apps.accounts.models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
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=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('phone_number', models.CharField(max_length=11, unique=True, verbose_name='phone number')),
('full_name', models.CharField(max_length=150, verbose_name='full name')),
('role', models.CharField(choices=[('student', 'Student'), ('teacher', 'Teacher'), ('admin', 'Admin')], default='student', max_length=16, verbose_name='role')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email')),
('language', models.CharField(choices=[('en', 'English'), ('fa', 'Persian')], default='fa', max_length=5, verbose_name='language')),
('is_active', models.BooleanField(default=True, verbose_name='active')),
('is_staff', models.BooleanField(default=False, verbose_name='staff status')),
('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',
},
managers=[
('objects', apps.accounts.models.UserManager()),
],
),
migrations.CreateModel(
name='OTPCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone_number', models.CharField(db_index=True, max_length=11)),
('code', models.CharField(max_length=6)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField()),
('attempts', models.PositiveIntegerField(default=0)),
('is_used', models.BooleanField(default=False)),
('consumed_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['phone_number', 'is_used'], name='accounts_ot_phone_n_aabe5e_idx'), models.Index(fields=['expires_at'], name='accounts_ot_expires_2f08f4_idx')],
},
),
migrations.CreateModel(
name='TeacherProfile',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='teacher_profile', serialize=False, to=settings.AUTH_USER_MODEL)),
('bio', models.TextField(blank=True, verbose_name='bio')),
('is_approved', models.BooleanField(default=False, verbose_name='approved')),
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='approved at')),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_teacher_profiles', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'teacher profile',
'verbose_name_plural': 'teacher profiles',
},
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['role'], name='accounts_us_role_1fa9a5_idx'),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.1.15 on 2026-05-02 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='teacherprofile',
name='avatar',
field=models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='avatar'),
),
migrations.AddField(
model_name='teacherprofile',
name='headline',
field=models.CharField(blank=True, help_text="Short professional headline shown on instructor cards (e.g. 'Senior Product Designer · 10y experience').", max_length=200, verbose_name='headline'),
),
migrations.AddField(
model_name='teacherprofile',
name='is_verified',
field=models.BooleanField(default=False, verbose_name='verified'),
),
migrations.AddField(
model_name='teacherprofile',
name='years_experience',
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='years of experience'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-05-02 16:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_teacherprofile_avatar_teacherprofile_headline_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_active_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='Updated on each authenticated API request (debounced ~5 minutes).', null=True, verbose_name='last active at'),
),
]

View File

@ -0,0 +1,51 @@
# Generated by Django 5.1.15 on 2026-05-02 16:12 — hand-edited to backfill referral_code
import secrets
import string
from django.db import migrations, models
def _backfill_referral_codes(apps, schema_editor):
User = apps.get_model("accounts", "User")
alphabet = string.ascii_uppercase + string.digits
existing = set(User.objects.exclude(referral_code="").values_list("referral_code", flat=True))
for user in User.objects.filter(referral_code=""):
for _ in range(20):
cand = "".join(secrets.choice(alphabet) for _ in range(6))
if cand not in existing:
existing.add(cand)
user.referral_code = cand
user.save(update_fields=["referral_code"])
break
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_user_last_active_at'),
]
operations = [
migrations.AddField(
model_name='teacherprofile',
name='is_featured',
field=models.BooleanField(db_index=True, default=False, help_text="If true, the instructor appears in the public 'Featured instructors' strip.", verbose_name='featured'),
),
# Step 1: add the column without unique constraint OR db_index so the default
# empty string doesn't collide and so we don't pre-create the `_like` index
# that AlterField would try to add a second time below.
migrations.AddField(
model_name='user',
name='referral_code',
field=models.CharField(blank=True, default='', help_text='Auto-generated 6-char code that other users can apply at checkout.', max_length=12, verbose_name='referral code'),
),
# Step 2: backfill unique codes for any existing rows.
migrations.RunPython(_backfill_referral_codes, migrations.RunPython.noop),
# Step 3: now safe to add the unique constraint.
migrations.AlterField(
model_name='user',
name='referral_code',
field=models.CharField(blank=True, db_index=True, help_text='Auto-generated 6-char code that other users can apply at checkout.', max_length=12, unique=True, verbose_name='referral code'),
),
]

View File

208
apps/accounts/models.py Normal file
View File

@ -0,0 +1,208 @@
import secrets
import string
from datetime import timedelta
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .validators import normalize_iran_mobile, validate_iran_mobile
_REFERRAL_ALPHABET = string.ascii_uppercase + string.digits
def generate_referral_code(length: int = 6) -> str:
return "".join(secrets.choice(_REFERRAL_ALPHABET) for _ in range(length))
class Role(models.TextChoices):
STUDENT = "student", _("Student")
TEACHER = "teacher", _("Teacher")
ADMIN = "admin", _("Admin")
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, phone_number, password=None, **extra_fields):
if not phone_number:
raise ValueError("Phone number is required")
phone_number = normalize_iran_mobile(phone_number)
validate_iran_mobile(phone_number)
user = self.model(phone_number=phone_number, **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_user(self, phone_number, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(phone_number, password, **extra_fields)
def create_superuser(self, phone_number, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("role", Role.ADMIN)
extra_fields.setdefault("full_name", "Admin")
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(phone_number, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
phone_number = models.CharField(_("phone number"), max_length=11, unique=True)
full_name = models.CharField(_("full name"), max_length=150)
role = models.CharField(_("role"), max_length=16, choices=Role.choices, default=Role.STUDENT)
email = models.EmailField(_("email"), blank=True)
language = models.CharField(
_("language"),
max_length=5,
choices=[("en", "English"), ("fa", "Persian")],
default="fa",
)
is_active = models.BooleanField(_("active"), default=True)
is_staff = models.BooleanField(_("staff status"), default=False)
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
last_active_at = models.DateTimeField(
_("last active at"), null=True, blank=True, db_index=True,
help_text=_("Updated on each authenticated API request (debounced ~5 minutes)."),
)
referral_code = models.CharField(
_("referral code"), max_length=12, unique=True, blank=True, db_index=True,
help_text=_("Auto-generated 6-char code that other users can apply at checkout."),
)
objects = UserManager()
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = ["full_name"]
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
indexes = [models.Index(fields=["role"])]
def __str__(self):
return f"{self.full_name} ({self.phone_number})"
def clean(self):
super().clean()
self.phone_number = normalize_iran_mobile(self.phone_number)
validate_iran_mobile(self.phone_number)
def save(self, *args, **kwargs):
if not self.referral_code:
for _ in range(10):
candidate = generate_referral_code()
if not type(self).objects.filter(referral_code=candidate).exists():
self.referral_code = candidate
break
else:
# extremely unlikely; expand the alphabet/length if it ever happens
raise RuntimeError("Could not allocate a unique referral_code after 10 attempts")
super().save(*args, **kwargs)
def get_short_name(self):
return (self.full_name or "").split(" ", 1)[0] or self.phone_number
class TeacherProfile(models.Model):
user = models.OneToOneField(
"accounts.User",
on_delete=models.CASCADE,
related_name="teacher_profile",
primary_key=True,
)
bio = models.TextField(_("bio"), blank=True)
headline = models.CharField(
_("headline"), max_length=200, blank=True,
help_text=_("Short professional headline shown on instructor cards (e.g. 'Senior Product Designer · 10y experience')."),
)
avatar = models.ImageField(_("avatar"), upload_to="instructors/avatars/", blank=True, null=True)
years_experience = models.PositiveSmallIntegerField(_("years of experience"), null=True, blank=True)
is_verified = models.BooleanField(_("verified"), default=False)
is_featured = models.BooleanField(
_("featured"), default=False, db_index=True,
help_text=_("If true, the instructor appears in the public 'Featured instructors' strip."),
)
is_approved = models.BooleanField(_("approved"), default=False)
approved_at = models.DateTimeField(_("approved at"), null=True, blank=True)
approved_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
related_name="approved_teacher_profiles",
null=True,
blank=True,
)
class Meta:
verbose_name = _("teacher profile")
verbose_name_plural = _("teacher profiles")
def __str__(self):
return f"TeacherProfile<{self.user_id}>"
def approve(self, by_user):
self.is_approved = True
self.approved_at = timezone.now()
self.approved_by = by_user
self.save(update_fields=["is_approved", "approved_at", "approved_by"])
def _generate_otp_code(length: int = 4) -> str:
upper = 10 ** length
return f"{secrets.randbelow(upper):0{length}d}"
class OTPCode(models.Model):
MAX_ATTEMPTS = 5
phone_number = models.CharField(max_length=11, db_index=True)
code = models.CharField(max_length=6)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
attempts = models.PositiveIntegerField(default=0)
is_used = models.BooleanField(default=False)
consumed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["phone_number", "is_used"]),
models.Index(fields=["expires_at"]),
]
ordering = ["-created_at"]
def __str__(self):
return f"OTP<{self.phone_number} @ {self.created_at:%Y-%m-%d %H:%M:%S}>"
@classmethod
def issue(cls, phone_number: str, ttl_seconds: int = 120, length: int = 4) -> "OTPCode":
cls.objects.filter(phone_number=phone_number, is_used=False).update(
is_used=True, consumed_at=timezone.now()
)
return cls.objects.create(
phone_number=phone_number,
code=_generate_otp_code(length=length),
expires_at=timezone.now() + timedelta(seconds=ttl_seconds),
)
@property
def is_expired(self) -> bool:
return timezone.now() >= self.expires_at
@property
def is_valid(self) -> bool:
return not self.is_used and not self.is_expired and self.attempts < self.MAX_ATTEMPTS
def mark_consumed(self):
self.is_used = True
self.consumed_at = timezone.now()
self.save(update_fields=["is_used", "consumed_at"])

View File

View File

@ -0,0 +1,61 @@
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from ..models import OTPCode
from .sms import SMSError, get_sms_backend
class OTPCooldownError(Exception):
def __init__(self, retry_after: int):
self.retry_after = retry_after
super().__init__(f"Wait {retry_after} seconds before requesting another code")
class OTPVerificationError(Exception):
pass
def _cooldown_key(phone: str) -> str:
return f"otp:cooldown:{phone}"
def send_otp(phone_number: str) -> OTPCode:
cooldown = getattr(settings, "OTP_SEND_COOLDOWN_SECONDS", 60)
if cache.get(_cooldown_key(phone_number)):
raise OTPCooldownError(retry_after=cooldown)
ttl_seconds = getattr(settings, "OTP_TTL_SECONDS", 120)
length = getattr(settings, "OTP_CODE_LENGTH", 4)
otp = OTPCode.issue(phone_number=phone_number, ttl_seconds=ttl_seconds, length=length)
try:
get_sms_backend().send_otp(phone_number, otp.code)
except SMSError:
otp.delete()
raise
cache.set(_cooldown_key(phone_number), "1", timeout=cooldown)
return otp
def verify_otp(phone_number: str, code: str) -> OTPCode:
otp = (
OTPCode.objects.filter(phone_number=phone_number, is_used=False)
.order_by("-created_at")
.first()
)
if otp is None:
raise OTPVerificationError(_("No active code for this phone number"))
if otp.is_expired:
raise OTPVerificationError(_("Code has expired"))
if otp.attempts >= OTPCode.MAX_ATTEMPTS:
raise OTPVerificationError(_("Too many attempts; request a new code"))
if otp.code != str(code):
OTPCode.objects.filter(pk=otp.pk).update(attempts=otp.attempts + 1)
raise OTPVerificationError(_("Invalid code"))
otp.mark_consumed()
return otp

View File

@ -0,0 +1,70 @@
import logging
from typing import Protocol
import requests
from django.conf import settings
from django.utils.module_loading import import_string
logger = logging.getLogger(__name__)
class SMSError(Exception):
pass
class SMSBackend(Protocol):
def send_otp(self, phone_number: str, code: str) -> None: ...
class ConsoleSMSBackend:
"""Dev backend — logs the OTP instead of sending. Never use in production."""
def send_otp(self, phone_number: str, code: str) -> None:
logger.warning("[SMS-CONSOLE] OTP for %s: %s", phone_number, code)
class SMSIRBackend:
"""sms.ir template-based OTP via /v1/send/verify."""
def __init__(self):
self.api_key = settings.SMSIR_API_KEY
self.template_id = settings.SMSIR_TEMPLATE_ID
self.param_name = settings.SMSIR_TEMPLATE_PARAM_NAME
self.base_url = settings.SMSIR_BASE_URL.rstrip("/")
self.timeout = 15
if not self.api_key:
raise SMSError("SMSIR_API_KEY is not configured")
if not self.template_id:
raise SMSError("SMSIR_TEMPLATE_ID is not configured")
def send_otp(self, phone_number: str, code: str) -> None:
url = f"{self.base_url}/v1/send/verify"
headers = {"x-api-key": self.api_key, "Content-Type": "application/json"}
payload = {
"mobile": phone_number,
"templateId": int(self.template_id),
"parameters": [{"name": self.param_name, "value": str(code)}],
}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=self.timeout)
except requests.RequestException as e:
raise SMSError(f"SMS network error: {e}") from e
try:
data = resp.json()
except ValueError as e:
raise SMSError(f"SMS non-JSON response (HTTP {resp.status_code})") from e
if not (200 <= resp.status_code < 300) or data.get("status") != 1:
msg = data.get("message") or f"HTTP {resp.status_code}"
raise SMSError(f"sms.ir error: {msg}")
logger.info("OTP delivered to %s via sms.ir template %s", phone_number, self.template_id)
_DEFAULT_BACKEND = "apps.accounts.services.sms.ConsoleSMSBackend"
def get_sms_backend() -> SMSBackend:
backend_path = getattr(settings, "SMS_BACKEND", _DEFAULT_BACKEND)
return import_string(backend_path)()

237
apps/accounts/tests.py Normal file
View File

@ -0,0 +1,237 @@
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import OTPCode, Role, TeacherProfile
from apps.accounts.services.otp import (
OTPCooldownError,
OTPVerificationError,
send_otp,
verify_otp,
)
from apps.accounts.validators import normalize_iran_mobile, validate_iran_mobile
User = get_user_model()
CONSOLE_SMS = "apps.accounts.services.sms.ConsoleSMSBackend"
class PhoneNormalizationTests(TestCase):
def test_normalizes_e164(self):
self.assertEqual(normalize_iran_mobile("+989121234567"), "09121234567")
def test_normalizes_0098_prefix(self):
self.assertEqual(normalize_iran_mobile("00989121234567"), "09121234567")
def test_normalizes_98_prefix(self):
self.assertEqual(normalize_iran_mobile("989121234567"), "09121234567")
def test_normalizes_no_leading_zero(self):
self.assertEqual(normalize_iran_mobile("9121234567"), "09121234567")
def test_keeps_canonical_form(self):
self.assertEqual(normalize_iran_mobile("09121234567"), "09121234567")
def test_strips_separators(self):
self.assertEqual(normalize_iran_mobile("0912 123 4567"), "09121234567")
def test_validates_canonical(self):
validate_iran_mobile("09121234567")
def test_rejects_too_short(self):
with self.assertRaises(ValidationError):
validate_iran_mobile("0912123456")
def test_rejects_non_iran(self):
with self.assertRaises(ValidationError):
validate_iran_mobile("+12025551234")
@override_settings(SMS_BACKEND=CONSOLE_SMS)
class OTPServiceTests(TestCase):
def setUp(self):
cache.clear()
def test_send_otp_creates_code(self):
otp = send_otp("09121234567")
self.assertEqual(otp.phone_number, "09121234567")
self.assertEqual(len(otp.code), 4)
self.assertFalse(otp.is_used)
def test_send_otp_within_cooldown_raises(self):
send_otp("09121234567")
with self.assertRaises(OTPCooldownError):
send_otp("09121234567")
def test_verify_correct_code_consumes(self):
otp = send_otp("09121234567")
verified = verify_otp("09121234567", otp.code)
self.assertEqual(verified.pk, otp.pk)
otp.refresh_from_db()
self.assertTrue(otp.is_used)
def test_verify_wrong_code_increments_attempts(self):
send_otp("09121234567")
with self.assertRaises(OTPVerificationError):
verify_otp("09121234567", "0000")
latest = OTPCode.objects.filter(phone_number="09121234567").first()
self.assertEqual(latest.attempts, 1)
self.assertFalse(latest.is_used)
def test_verify_with_no_active_code_raises(self):
with self.assertRaises(OTPVerificationError):
verify_otp("09121234567", "1234")
def test_issuing_new_otp_invalidates_previous(self):
first = send_otp("09121234567")
cache.clear()
second = send_otp("09121234567")
first.refresh_from_db()
self.assertTrue(first.is_used)
self.assertFalse(second.is_used)
self.assertNotEqual(first.pk, second.pk)
@override_settings(SMS_BACKEND=CONSOLE_SMS)
class AuthAPITests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
def _send(self, phone):
return self.client.post("/api/auth/otp/send/", {"phone_number": phone}, format="json")
def _latest_otp(self, phone):
return OTPCode.objects.get(phone_number=phone, is_used=False)
def test_signup_flow_creates_teacher_with_profile(self):
r = self._send("09121234567")
self.assertEqual(r.status_code, 200, r.content)
otp = self._latest_otp("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{
"phone_number": "09121234567",
"code": otp.code,
"full_name": "New Teacher",
"role": "teacher",
},
format="json",
)
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertTrue(body["created"])
self.assertEqual(body["user"]["role"], "teacher")
self.assertIn("access", body["tokens"])
self.assertIn("refresh", body["tokens"])
u = User.objects.get(phone_number="09121234567")
self.assertTrue(TeacherProfile.objects.filter(user=u).exists())
self.assertFalse(u.teacher_profile.is_approved)
def test_signup_flow_creates_student_no_profile(self):
self._send("09121234567")
otp = self._latest_otp("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{
"phone_number": "09121234567",
"code": otp.code,
"full_name": "New Student",
"role": "student",
},
format="json",
)
self.assertEqual(r.status_code, 200, r.content)
u = User.objects.get(phone_number="09121234567")
self.assertEqual(u.role, Role.STUDENT)
self.assertFalse(TeacherProfile.objects.filter(user=u).exists())
def test_existing_user_login_does_not_require_signup_fields(self):
u = User.objects.create_user(
phone_number="09121234567", full_name="Existing", role=Role.STUDENT
)
self._send("09121234567")
otp = self._latest_otp("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{"phone_number": "09121234567", "code": otp.code},
format="json",
)
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertFalse(body["created"])
self.assertEqual(body["user"]["id"], u.id)
def test_new_user_without_signup_fields_rejected(self):
self._send("09121234567")
otp = self._latest_otp("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{"phone_number": "09121234567", "code": otp.code},
format="json",
)
self.assertEqual(r.status_code, 400)
body = r.json()
self.assertIn("full_name", body)
self.assertIn("role", body)
def test_verify_wrong_code_400(self):
self._send("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{
"phone_number": "09121234567",
"code": "0000",
"full_name": "X",
"role": "student",
},
format="json",
)
self.assertEqual(r.status_code, 400)
def test_phone_normalization_in_send(self):
r = self.client.post(
"/api/auth/otp/send/",
{"phone_number": "+989121234567"},
format="json",
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["phone_number"], "09121234567")
self.assertTrue(OTPCode.objects.filter(phone_number="09121234567").exists())
def test_invalid_phone_400(self):
r = self.client.post(
"/api/auth/otp/send/", {"phone_number": "12345"}, format="json"
)
self.assertEqual(r.status_code, 400)
def test_cooldown_429(self):
self._send("09121234567")
r = self._send("09121234567")
self.assertEqual(r.status_code, 429)
self.assertIn("retry_after", r.json())
def test_me_requires_auth(self):
r = self.client.get("/api/auth/me/")
self.assertEqual(r.status_code, 401)
def test_me_with_bearer_token(self):
User.objects.create_user(
phone_number="09121234567", full_name="X", role=Role.STUDENT
)
self._send("09121234567")
otp = self._latest_otp("09121234567")
r = self.client.post(
"/api/auth/otp/verify/",
{"phone_number": "09121234567", "code": otp.code},
format="json",
)
access = r.json()["tokens"]["access"]
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
r2 = self.client.get("/api/auth/me/")
self.assertEqual(r2.status_code, 200)
self.assertEqual(r2.json()["phone_number"], "09121234567")

View File

@ -0,0 +1,77 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Role, TeacherProfile
from apps.courses.models import Course, CourseStatus
from apps.payments.models import Gateway, Order, OrderStatus
User = get_user_model()
def _teacher(phone, name="T", approved=True):
u = User.objects.create_user(phone_number=phone, full_name=name, role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved, bio=f"bio of {name}", headline=f"{name} headline")
return u
def _student(phone="09120000099"):
return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT)
def _course(instructor, title="C", published=True, price_toman=None):
c = Course.objects.create(
instructor=instructor, title=title, tagline="T", description="D",
level="beginner", language="fa", price_toman=price_toman,
)
if published:
c.publish()
return c
class InstructorPublicEndpointsTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t1 = _teacher("09120000001", name="Arash")
self.t2 = _teacher("09120000002", name="Neda")
# t1: 2 courses, t2: 0 courses
_course(self.t1, title="C1")
_course(self.t1, title="C2")
def test_list_instructors_returns_all_teachers_with_counts(self):
r = self.client.get("/api/instructors/")
self.assertEqual(r.status_code, 200)
body = r.json()
ids = {row["id"]: row for row in body}
self.assertIn(self.t1.id, ids)
self.assertIn(self.t2.id, ids)
self.assertEqual(ids[self.t1.id]["courses_count"], 2)
self.assertEqual(ids[self.t1.id]["students_count"], 0)
self.assertEqual(ids[self.t2.id]["courses_count"], 0)
def test_instructor_detail_returns_bio_courses_stats(self):
r = self.client.get(f"/api/instructors/{self.t1.id}/")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(body["id"], self.t1.id)
self.assertEqual(body["full_name"], "Arash")
self.assertEqual(body["headline"], "Arash headline")
self.assertEqual(body["bio"], "bio of Arash")
self.assertEqual(body["courses_count"], 2)
self.assertEqual(len(body["courses"]), 2)
def test_instructor_detail_404_for_non_teacher(self):
s = _student()
r = self.client.get(f"/api/instructors/{s.id}/")
self.assertEqual(r.status_code, 404)
def test_students_count_increments_with_paid_orders(self):
course = _course(self.t1, title="Sold", price_toman=1000)
s1 = _student("09120000091")
s2 = _student("09120000092")
Order.objects.create(student=s1, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID)
Order.objects.create(student=s2, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID)
# Same student buys again — should still count as 1 unique student
Order.objects.create(student=s1, course=course, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID)
r = self.client.get(f"/api/instructors/{self.t1.id}/")
self.assertEqual(r.json()["students_count"], 2)

View File

@ -0,0 +1,27 @@
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
_IRAN_MOBILE_RE = re.compile(r"^09\d{9}$")
def normalize_iran_mobile(value: str) -> str:
if value is None:
return ""
digits = re.sub(r"\D", "", str(value))
if not digits:
return ""
if digits.startswith("0098"):
digits = "0" + digits[4:]
elif digits.startswith("98") and len(digits) == 12:
digits = "0" + digits[2:]
elif digits.startswith("9") and len(digits) == 10:
digits = "0" + digits
return digits
def validate_iran_mobile(value: str) -> None:
if not _IRAN_MOBILE_RE.match(value or ""):
raise ValidationError(_("Enter a valid Iranian mobile number (09xxxxxxxxx)."))

View File

View File

@ -0,0 +1,13 @@
from django.contrib import admin
from modeltranslation.admin import TabbedTranslationAdmin
from .models import Announcement
@admin.register(Announcement)
class AnnouncementAdmin(TabbedTranslationAdmin):
list_display = ("course", "posted_by", "created_at")
list_filter = ("course",)
search_fields = ("body", "course__slug")
raw_id_fields = ("course", "posted_by")
readonly_fields = ("created_at", "updated_at")

View File

View File

@ -0,0 +1,22 @@
from rest_framework import serializers
from apps.announcements.models import Announcement
from apps.courses.models import Course
class AnnouncementSerializer(serializers.ModelSerializer):
posted_by_name = serializers.CharField(source="posted_by.full_name", read_only=True, allow_null=True)
class Meta:
model = Announcement
fields = ["id", "course", "body", "posted_by", "posted_by_name", "created_at", "updated_at"]
read_only_fields = ["id", "posted_by", "posted_by_name", "created_at", "updated_at"]
class AnnouncementWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = Announcement
fields = ["id", "course", "body"]
read_only_fields = ["id"]

View File

@ -0,0 +1,14 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import InstructorAnnouncementViewSet, MyCourseAnnouncementsView
instructor_router = DefaultRouter()
instructor_router.register(
r"announcements", InstructorAnnouncementViewSet, basename="instructor-announcement"
)
urlpatterns = [
path("instructor/", include((instructor_router.urls, "instructor-announcements"))),
path("me/courses/<slug:slug>/announcements/", MyCourseAnnouncementsView.as_view(), name="my-course-announcements"),
]

View File

@ -0,0 +1,84 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from apps.announcements.models import Announcement
from apps.courses.api.permissions import IsCourseOwner, IsTeacher
from apps.courses.models import Course, CourseStatus
from apps.enrollments.api.permissions import IsStudent
from apps.enrollments.models import Enrollment
from .serializers import AnnouncementSerializer, AnnouncementWriteSerializer
@extend_schema(tags=["announcements"])
class InstructorAnnouncementViewSet(viewsets.ModelViewSet):
"""Instructor CRUD over announcements on their own courses."""
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
queryset = Announcement.objects.all().select_related("course", "posted_by")
filterset_fields = ["course"]
ordering = ["-created_at"]
def get_queryset(self):
return super().get_queryset().filter(course__instructor=self.request.user)
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return AnnouncementWriteSerializer
return AnnouncementSerializer
def perform_create(self, serializer):
course = serializer.validated_data["course"]
if course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
announcement = serializer.save(posted_by=self.request.user)
# Record activity event for the instructor's home feed
from apps.enrollments.models import ActivityKind
from apps.enrollments.services.events import record_event
record_event(
course=course,
actor=self.request.user,
kind=ActivityKind.ANNOUNCEMENT_POSTED.value,
payload={
"announcement_id": announcement.pk,
"preview": (announcement.body or "")[:120],
},
)
class MyCourseAnnouncementsView(ListAPIView):
"""Student-facing list: announcements for a specific course the student is enrolled in."""
permission_classes = [IsAuthenticated, IsStudent]
serializer_class = AnnouncementSerializer
pagination_class = None
@extend_schema(
tags=["announcements"],
summary="My course announcements",
description="Lists all announcements posted by the instructor on a course the student is enrolled in.",
responses={
200: AnnouncementSerializer(many=True),
403: OpenApiResponse(description="Not enrolled."),
404: OpenApiResponse(description="Course not found or not published."),
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
slug = self.kwargs["slug"]
course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED)
if not Enrollment.objects.filter(student=self.request.user, course=course).exists():
raise PermissionDenied("You are not enrolled in this course.")
return (
Announcement.objects
.filter(course=course)
.select_related("posted_by")
.order_by("-created_at")
)

View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AnnouncementsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.announcements"
label = "announcements"

View File

@ -0,0 +1,37 @@
# Generated by Django 5.1.15 on 2026-05-02 15:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('courses', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(verbose_name='body')),
('body_en', models.TextField(null=True, verbose_name='body')),
('body_fa', models.TextField(null=True, verbose_name='body')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements', to='courses.course')),
('posted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posted_announcements', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'announcement',
'verbose_name_plural': 'announcements',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['course', '-created_at'], name='announcemen_course__b2aa6b_idx')],
},
),
]

View File

@ -0,0 +1,30 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class Announcement(models.Model):
course = models.ForeignKey(
"courses.Course",
on_delete=models.CASCADE,
related_name="announcements",
)
body = models.TextField(_("body"))
posted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="posted_announcements",
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("announcement")
verbose_name_plural = _("announcements")
ordering = ["-created_at"]
indexes = [models.Index(fields=["course", "-created_at"])]
def __str__(self):
return self.body[:60]

View File

@ -0,0 +1,8 @@
from modeltranslation.translator import TranslationOptions, register
from .models import Announcement
@register(Announcement)
class AnnouncementTranslationOptions(TranslationOptions):
fields = ("body",)

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

7
apps/core/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
label = "core"

111
apps/core/search.py Normal file
View File

@ -0,0 +1,111 @@
"""Unified `/api/search/` across courses + instructors (public).
If the requester is an authenticated student, also returns matching lessons in
courses they're enrolled in. Designed for the topbar's command palette / search box.
"""
from django.db.models import Q
from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.api.public import (
InstructorListItemSerializer,
_annotate_instructor_stats,
_instructor_qs,
)
from apps.accounts.models import Role
from apps.courses.api.queries import annotate_course_stats
from apps.courses.api.serializers import CourseListSerializer
from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus
from apps.enrollments.models import Enrollment
class _LessonResultSerializer(serializers.ModelSerializer):
course_id = serializers.IntegerField(source="section.course.id", read_only=True)
course_slug = serializers.CharField(source="section.course.slug", read_only=True)
course_title = serializers.CharField(source="section.course.title", read_only=True)
class Meta:
model = Lesson
fields = ["id", "title", "kind", "course_id", "course_slug", "course_title"]
class GlobalSearchView(APIView):
"""`GET /api/search/?q=...&types=courses,instructors,lessons&limit=5`."""
permission_classes = [AllowAny]
@extend_schema(
tags=["search"],
summary="Global search across courses + instructors (+ lessons for enrolled students)",
description=(
"Returns mixed results grouped by entity. `?types=` is a comma-separated whitelist; "
"default is `courses,instructors`. Lessons are only searched for authenticated students "
"and only within courses they're enrolled in. `?limit=` caps each group (default 5, max 20)."
),
parameters=[
OpenApiParameter(name="q", required=True, type=str, location=OpenApiParameter.QUERY),
OpenApiParameter(name="types", required=False, type=str, location=OpenApiParameter.QUERY),
OpenApiParameter(name="limit", required=False, type=int, location=OpenApiParameter.QUERY),
],
)
def get(self, request):
q = (request.query_params.get("q") or "").strip()
if not q:
return Response({"detail": "missing `q`"}, status=400)
try:
limit = max(1, min(int(request.query_params.get("limit") or 5), 20))
except ValueError:
limit = 5
types_raw = request.query_params.get("types") or "courses,instructors"
wanted = {t.strip() for t in types_raw.split(",") if t.strip()}
out: dict = {"q": q}
if "courses" in wanted:
cqs = (
Course.objects.filter(status=CourseStatus.PUBLISHED)
.filter(
Q(title__icontains=q)
| Q(title_en__icontains=q)
| Q(title_fa__icontains=q)
| Q(tagline__icontains=q)
)
.select_related("instructor", "category")
)
cqs = annotate_course_stats(cqs)[:limit]
out["courses"] = CourseListSerializer(cqs, many=True, context={"request": request}).data
if "instructors" in wanted:
iqs = (
_annotate_instructor_stats(_instructor_qs())
.filter(full_name__icontains=q)
)[:limit]
out["instructors"] = InstructorListItemSerializer(
iqs, many=True, context={"request": request}
).data
if (
"lessons" in wanted
and request.user.is_authenticated
and getattr(request.user, "role", None) == Role.STUDENT
):
enrolled_course_ids = list(
Enrollment.objects.filter(student=request.user).values_list("course_id", flat=True)
)
lqs = (
Lesson.objects.filter(
section__course_id__in=enrolled_course_ids,
status=LessonStatus.PUBLISHED,
)
.filter(
Q(title__icontains=q) | Q(title_en__icontains=q) | Q(title_fa__icontains=q)
)
.select_related("section__course")
)[:limit]
out["lessons"] = _LessonResultSerializer(lqs, many=True).data
return Response(out)

15
apps/core/urls.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import path
from . import views
from .search import GlobalSearchView
app_name = "core"
urlpatterns = [
path("", views.home, name="home"),
]
# Mounted directly under /api/search/ from the project urlconf.
api_urlpatterns = [
path("search/", GlobalSearchView.as_view(), name="global-search"),
]

17
apps/core/views.py Normal file
View File

@ -0,0 +1,17 @@
from django.http import HttpResponse
from django.utils.translation import get_language, gettext as _
def home(request):
lang = get_language()
body = (
f"<!doctype html>\n"
f"<html lang=\"{lang}\">\n"
f"<head><meta charset=\"utf-8\"><title>ilo</title></head>\n"
f"<body>\n"
f" <h1>{_('ilo backend is running')}</h1>\n"
f" <p>{_('Active language')}: <code>{lang}</code></p>\n"
f" <p><a href=\"/en/\">EN</a> | <a href=\"/fa/\">FA</a></p>\n"
f"</body></html>\n"
)
return HttpResponse(body)

0
apps/courses/__init__.py Normal file
View File

62
apps/courses/admin.py Normal file
View File

@ -0,0 +1,62 @@
from django.contrib import admin
from modeltranslation.admin import TabbedTranslationAdmin
from .models import Category, Course, CourseFAQ, CourseHighlight, Lesson, Section
@admin.register(Category)
class CategoryAdmin(TabbedTranslationAdmin):
list_display = ("name", "slug", "order")
search_fields = ("name", "slug")
ordering = ("order", "slug")
class CourseHighlightInline(admin.TabularInline):
model = CourseHighlight
extra = 0
class CourseFAQInline(admin.TabularInline):
model = CourseFAQ
extra = 0
class SectionInline(admin.TabularInline):
model = Section
extra = 0
fields = ("title", "order")
show_change_link = True
@admin.register(Course)
class CourseAdmin(TabbedTranslationAdmin):
list_display = ("title", "instructor", "category", "level", "status", "created_at")
list_filter = ("status", "level", "category")
search_fields = ("title", "slug")
raw_id_fields = ("instructor",)
readonly_fields = ("created_at", "updated_at", "published_at")
prepopulated_fields = {}
inlines = [CourseHighlightInline, CourseFAQInline, SectionInline]
class LessonInline(admin.TabularInline):
model = Lesson
extra = 0
fields = ("title", "kind", "order", "is_preview", "status")
show_change_link = True
@admin.register(Section)
class SectionAdmin(TabbedTranslationAdmin):
list_display = ("title", "course", "order")
list_filter = ("course",)
raw_id_fields = ("course",)
inlines = [LessonInline]
@admin.register(Lesson)
class LessonAdmin(TabbedTranslationAdmin):
list_display = ("title", "section", "kind", "status", "is_preview", "order")
list_filter = ("kind", "status", "is_preview")
search_fields = ("title",)
raw_id_fields = ("section",)

View File

View File

@ -0,0 +1,14 @@
from django_filters import rest_framework as df
from apps.courses.models import Course
class PublicCourseFilter(df.FilterSet):
category = df.CharFilter(field_name="category__slug", lookup_expr="exact")
level = df.CharFilter(field_name="level", lookup_expr="exact")
instructor = df.NumberFilter(field_name="instructor_id", lookup_expr="exact")
language = df.CharFilter(field_name="language", lookup_expr="exact")
class Meta:
model = Course
fields = ["category", "level", "instructor", "language"]

View File

@ -0,0 +1,44 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
from apps.accounts.models import Role
class IsTeacher(BasePermission):
message = "Only teachers can perform this action."
def has_permission(self, request, view):
u = request.user
return bool(u and u.is_authenticated and u.role == Role.TEACHER)
class IsApprovedTeacher(BasePermission):
message = "Your teacher profile must be approved by an admin first."
def has_permission(self, request, view):
u = request.user
if not (u and u.is_authenticated and u.role == Role.TEACHER):
return False
tp = getattr(u, "teacher_profile", None)
return bool(tp and tp.is_approved)
class IsCourseOwner(BasePermission):
"""Object-level: the course (or its parent for sections/lessons) must be owned by the request user."""
message = "You do not own this course."
def has_object_permission(self, request, view, obj):
from apps.courses.models import Course, Lesson, Section
if isinstance(obj, Course):
return obj.instructor_id == request.user.id
if isinstance(obj, Section):
return obj.course.instructor_id == request.user.id
if isinstance(obj, Lesson):
return obj.section.course.instructor_id == request.user.id
return False
class ReadOnly(BasePermission):
def has_permission(self, request, view):
return request.method in SAFE_METHODS

View File

@ -0,0 +1,70 @@
"""Reusable queryset annotations for the Course API.
Centralised here so list + detail views always return the same shape, and
expensive aggregations are computed via correlated subqueries (single SQL
statement, no Cartesian explosion across multi-relation joins).
"""
from datetime import timedelta
from django.db.models import Count, IntegerField, OuterRef, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
def _published_lesson_qs():
from apps.courses.models import Lesson, LessonStatus
return Lesson.objects.filter(status=LessonStatus.PUBLISHED)
def annotate_course_stats(qs, *, bestseller_window_days: int = 90):
"""Annotate Course queryset with `lesson_count`, `total_minutes_seconds`,
`enrolled_count`, and `popularity` (= paid orders in the last
`bestseller_window_days`). Each is a correlated subquery so they don't
interact safe to combine."""
from apps.courses.models import Lesson, LessonStatus
from apps.enrollments.models import Enrollment
from apps.payments.models import Order, OrderStatus
cutoff = timezone.now() - timedelta(days=bestseller_window_days)
lesson_count_sq = (
Lesson.objects.filter(section__course=OuterRef("pk"), status=LessonStatus.PUBLISHED)
.order_by()
.values("section__course")
.annotate(c=Count("id"))
.values("c")[:1]
)
duration_sum_sq = (
Lesson.objects.filter(section__course=OuterRef("pk"), status=LessonStatus.PUBLISHED)
.order_by()
.values("section__course")
.annotate(s=Coalesce(Sum("duration_seconds"), 0))
.values("s")[:1]
)
enrolled_count_sq = (
Enrollment.objects.filter(course=OuterRef("pk"))
.order_by()
.values("course")
.annotate(c=Count("id"))
.values("c")[:1]
)
popularity_sq = (
Order.objects.filter(
course=OuterRef("pk"),
status=OrderStatus.PAID,
created_at__gte=cutoff,
)
.order_by()
.values("course")
.annotate(c=Count("id"))
.values("c")[:1]
)
return qs.annotate(
lesson_count=Coalesce(Subquery(lesson_count_sq, output_field=IntegerField()), 0),
total_minutes_seconds=Coalesce(Subquery(duration_sum_sq, output_field=IntegerField()), 0),
enrolled_count=Coalesce(Subquery(enrolled_count_sq, output_field=IntegerField()), 0),
popularity=Coalesce(Subquery(popularity_sq, output_field=IntegerField()), 0),
)

View File

@ -0,0 +1,434 @@
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from rest_framework import serializers
from apps.courses.models import (
Category,
Course,
CourseFAQ,
CourseHighlight,
Lesson,
Section,
)
# --- Categories ---------------------------------------------------------------
class CategorySerializer(serializers.ModelSerializer):
course_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Category
fields = ["id", "slug", "name", "name_fa", "name_en", "order", "course_count"]
read_only_fields = ["id", "course_count"]
# --- Course bits --------------------------------------------------------------
class CourseHighlightSerializer(serializers.ModelSerializer):
"""Read-only nested representation."""
class Meta:
model = CourseHighlight
fields = ["id", "text", "order"]
read_only_fields = ["id"]
class CourseHighlightWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = CourseHighlight
fields = ["id", "course", "text", "order"]
read_only_fields = ["id"]
class CourseFAQSerializer(serializers.ModelSerializer):
"""Read-only nested representation."""
class Meta:
model = CourseFAQ
fields = ["id", "question", "answer", "order"]
read_only_fields = ["id"]
class CourseFAQWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = CourseFAQ
fields = ["id", "course", "question", "answer", "order"]
read_only_fields = ["id"]
# --- Lessons ------------------------------------------------------------------
class LessonWriteSerializer(serializers.ModelSerializer):
"""Used for create/update by the instructor."""
section = serializers.PrimaryKeyRelatedField(queryset=Section.objects.all())
class Meta:
model = Lesson
fields = [
"id",
"section",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"status",
"order",
]
read_only_fields = ["id"]
def validate(self, attrs):
kind = attrs.get("kind") or getattr(self.instance, "kind", None)
body = attrs.get("body") or (getattr(self.instance, "body", "") if self.instance else "")
video_url = attrs.get("video_url") or (
getattr(self.instance, "video_url", "") if self.instance else ""
)
pdf_file = attrs.get("pdf_file") or (
getattr(self.instance, "pdf_file", None) if self.instance else None
)
if kind == "video" and not video_url:
raise serializers.ValidationError({"video_url": "Required when kind=video."})
if kind == "pdf" and not pdf_file:
raise serializers.ValidationError({"pdf_file": "Required when kind=pdf."})
if kind in {"text", "task"} and not body:
raise serializers.ValidationError({"body": "Required when kind=text or kind=task."})
return attrs
class LessonReadSerializer(serializers.ModelSerializer):
"""Used for nested reads. Hides body/video_url/pdf for non-preview lessons in public detail
(the public detail serializer overrides those with a PreviewLessonSerializer below)."""
class Meta:
model = Lesson
fields = [
"id",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"status",
"order",
]
class PublicLessonSerializer(serializers.ModelSerializer):
"""For public catalog: only expose content for free previews; otherwise hide it."""
body = serializers.SerializerMethodField()
video_url = serializers.SerializerMethodField()
pdf_file = serializers.SerializerMethodField()
class Meta:
model = Lesson
fields = [
"id",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"order",
]
def _gated(self, obj, attr):
return getattr(obj, attr) if obj.is_preview else None
def get_body(self, obj):
return self._gated(obj, "body")
def get_video_url(self, obj):
return self._gated(obj, "video_url")
def get_pdf_file(self, obj):
f = self._gated(obj, "pdf_file")
request = self.context.get("request")
if f and request:
return request.build_absolute_uri(f.url)
return f.url if f else None
# --- Sections -----------------------------------------------------------------
class SectionWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = Section
fields = ["id", "course", "title", "order"]
read_only_fields = ["id"]
class SectionReadSerializer(serializers.ModelSerializer):
lessons = LessonReadSerializer(many=True, read_only=True)
class Meta:
model = Section
fields = ["id", "title", "order", "lessons"]
class PublicSectionSerializer(serializers.ModelSerializer):
lessons = serializers.SerializerMethodField()
class Meta:
model = Section
fields = ["id", "title", "order", "lessons"]
def get_lessons(self, obj):
published = obj.lessons.filter(status="published")
return PublicLessonSerializer(published, many=True, context=self.context).data
# --- Courses ------------------------------------------------------------------
class _CourseStatsMixin(serializers.Serializer):
"""Adds annotated stats + computed badges to any Course serializer.
Requires the queryset to be passed through `annotate_course_stats()` first;
otherwise the values default to 0 / False."""
lesson_count = serializers.IntegerField(read_only=True, default=0)
enrolled_count = serializers.IntegerField(read_only=True, default=0)
total_minutes_seconds = serializers.IntegerField(read_only=True, default=0)
is_new = serializers.SerializerMethodField()
is_bestseller = serializers.SerializerMethodField()
discount_active = serializers.SerializerMethodField()
def get_is_new(self, obj):
window = getattr(settings, "COURSE_NEW_WINDOW_DAYS", 30)
return (timezone.now() - obj.created_at) < timedelta(days=window)
def get_is_bestseller(self, obj):
threshold = getattr(settings, "COURSE_BESTSELLER_THRESHOLD", 10)
return getattr(obj, "popularity", 0) >= threshold
def get_discount_active(self, obj):
return obj.discount_is_active
class CourseListSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Shape used in /api/instructor/courses/ list and /api/courses/ list."""
instructor_id = serializers.IntegerField(read_only=True)
instructor_name = serializers.CharField(source="instructor.full_name", read_only=True)
category = CategorySerializer(read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"level",
"language",
"color",
"cover_image",
"status",
"is_coming_soon",
"category",
"instructor_id",
"instructor_name",
"price_toman",
"original_price_toman",
"created_at",
"updated_at",
"published_at",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
]
read_only_fields = fields
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
class CourseInstructorWriteSerializer(serializers.ModelSerializer):
"""Used by the instructor for create / update. Slug is auto-generated if omitted."""
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"category",
"cover_image",
"color",
"is_coming_soon",
"price_toman",
"original_price_toman",
]
read_only_fields = ["id", "slug"]
class CourseInstructorDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Full instructor-side view: includes drafts, sections, lessons, highlights, FAQ."""
category = CategorySerializer(read_only=True)
sections = SectionReadSerializer(many=True, read_only=True)
highlights = CourseHighlightSerializer(many=True, read_only=True)
faqs = CourseFAQSerializer(many=True, read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"color",
"cover_image",
"status",
"is_coming_soon",
"published_at",
"created_at",
"updated_at",
"category",
"price_toman",
"original_price_toman",
"sections",
"highlights",
"faqs",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
]
read_only_fields = fields
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
class CoursePublicDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Public course detail: only published sections + published lessons; preview-gated content."""
instructor_id = serializers.IntegerField(read_only=True)
instructor_name = serializers.CharField(source="instructor.full_name", read_only=True)
instructor = serializers.SerializerMethodField()
category = CategorySerializer(read_only=True)
sections = serializers.SerializerMethodField()
highlights = CourseHighlightSerializer(many=True, read_only=True)
faqs = CourseFAQSerializer(many=True, read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"color",
"cover_image",
"published_at",
"created_at",
"updated_at",
"category",
"instructor_id",
"instructor_name",
"instructor",
"price_toman",
"original_price_toman",
"highlights",
"faqs",
"sections",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
"is_coming_soon",
]
read_only_fields = fields
def get_instructor(self, obj):
# Embedded "About instructor" card. Falls back gracefully if no profile yet.
u = obj.instructor
tp = getattr(u, "teacher_profile", None)
request = self.context.get("request")
avatar_url = None
if tp and tp.avatar:
avatar_url = request.build_absolute_uri(tp.avatar.url) if request else tp.avatar.url
return {
"id": u.id,
"full_name": u.full_name,
"headline": getattr(tp, "headline", "") if tp else "",
"bio": getattr(tp, "bio", "") if tp else "",
"is_verified": getattr(tp, "is_verified", False) if tp else False,
"years_experience": getattr(tp, "years_experience", None) if tp else None,
"avatar": avatar_url,
}
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
def get_sections(self, obj):
published = obj.sections.all().prefetch_related("lessons")
return PublicSectionSerializer(published, many=True, context=self.context).data
# --- Reorder ------------------------------------------------------------------
class IDListSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False)

29
apps/courses/api/urls.py Normal file
View File

@ -0,0 +1,29 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
CategoryListView,
InstructorCourseViewSet,
InstructorFAQViewSet,
InstructorHighlightViewSet,
InstructorLessonViewSet,
InstructorSectionViewSet,
PublicCourseDetailView,
PublicCourseListView,
)
instructor_router = DefaultRouter()
instructor_router.register(r"courses", InstructorCourseViewSet, basename="instructor-course")
instructor_router.register(r"sections", InstructorSectionViewSet, basename="instructor-section")
instructor_router.register(r"lessons", InstructorLessonViewSet, basename="instructor-lesson")
instructor_router.register(r"highlights", InstructorHighlightViewSet, basename="instructor-highlight")
instructor_router.register(r"faqs", InstructorFAQViewSet, basename="instructor-faq")
urlpatterns = [
# public
path("categories/", CategoryListView.as_view(), name="categories"),
path("courses/", PublicCourseListView.as_view(), name="public-courses"),
path("courses/<slug:slug>/", PublicCourseDetailView.as_view(), name="public-course-detail"),
# instructor surface
path("instructor/", include((instructor_router.urls, "instructor"))),
]

301
apps/courses/api/views.py Normal file
View File

@ -0,0 +1,301 @@
from django.db import transaction
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from django.db.models import Count, Q
from apps.courses.models import (
Category,
Course,
CourseFAQ,
CourseHighlight,
CourseStatus,
Lesson,
Section,
)
from .filters import PublicCourseFilter
from .permissions import IsApprovedTeacher, IsCourseOwner, IsTeacher
from .queries import annotate_course_stats
from .serializers import (
CategorySerializer,
CourseFAQSerializer,
CourseFAQWriteSerializer,
CourseHighlightSerializer,
CourseHighlightWriteSerializer,
CourseInstructorDetailSerializer,
CourseInstructorWriteSerializer,
CourseListSerializer,
CoursePublicDetailSerializer,
IDListSerializer,
LessonReadSerializer,
LessonWriteSerializer,
SectionReadSerializer,
SectionWriteSerializer,
)
# =============================================================================
# Public surface
# =============================================================================
@extend_schema(
tags=["categories"],
summary="List categories",
description="Includes a `course_count` of currently-published courses per category.",
)
class CategoryListView(ListAPIView):
permission_classes = [AllowAny]
pagination_class = None
serializer_class = CategorySerializer
def get_queryset(self):
return Category.objects.annotate(
course_count=Count(
"courses",
filter=Q(courses__status=CourseStatus.PUBLISHED),
distinct=True,
)
).order_by("order", "slug")
@extend_schema(
tags=["courses-public"],
summary="Browse published courses",
description=(
"Public catalog. Filters: `category` (slug), `level`, `instructor` (id), `language`. "
"Search: `?search=...` over title/tagline. "
"Ordering: `created_at`, `published_at`, `popularity` (paid orders in the last "
"`COURSE_BESTSELLER_WINDOW_DAYS`). Use `?ordering=-popularity` for the bestsellers feed."
),
)
class PublicCourseListView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = CourseListSerializer
filterset_class = PublicCourseFilter
search_fields = ["title", "title_en", "title_fa", "tagline", "tagline_en", "tagline_fa"]
ordering_fields = ["created_at", "published_at", "popularity"]
ordering = ["-published_at"]
def get_queryset(self):
qs = (
Course.objects.filter(status=CourseStatus.PUBLISHED)
.select_related("instructor", "category")
)
return annotate_course_stats(qs)
@extend_schema(
tags=["courses-public"],
summary="Public course detail",
description=(
"Returns the full structure (sections + published lessons + highlights + FAQ) plus "
"an embedded `instructor` card (bio, headline, verified badge, years_experience, avatar) "
"and the same stats/badges as the list endpoint (`lesson_count`, `enrolled_count`, "
"`total_minutes_seconds`, `is_new`, `is_bestseller`, `is_coming_soon`). "
"Locked lesson content is null unless `is_preview=true`."
),
)
class PublicCourseDetailView(RetrieveAPIView):
permission_classes = [AllowAny]
serializer_class = CoursePublicDetailSerializer
lookup_field = "slug"
def get_queryset(self):
qs = (
Course.objects.filter(status=CourseStatus.PUBLISHED)
.select_related("instructor", "instructor__teacher_profile", "category")
.prefetch_related("highlights", "faqs", "sections__lessons")
)
return annotate_course_stats(qs)
# =============================================================================
# Instructor surface
# =============================================================================
class InstructorCourseViewSet(viewsets.ModelViewSet):
"""CRUD over the instructor's own courses, plus publish/unpublish actions."""
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
queryset = Course.objects.all().select_related("instructor", "category")
lookup_field = "pk"
filterset_fields = ["status", "category", "is_coming_soon"]
search_fields = ["title", "title_en", "title_fa"]
ordering_fields = ["created_at", "updated_at", "published_at", "popularity"]
def get_queryset(self):
qs = super().get_queryset().filter(instructor=self.request.user)
return annotate_course_stats(qs)
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseInstructorWriteSerializer
if self.action == "list":
return CourseListSerializer
return CourseInstructorDetailSerializer
def perform_create(self, serializer):
serializer.save(instructor=self.request.user)
@extend_schema(
tags=["courses-instructor"],
summary="Publish course",
description="Sets status=published. Requires the instructor's TeacherProfile to be approved.",
request=None,
responses={
200: CourseInstructorDetailSerializer,
403: OpenApiResponse(description="Teacher profile not approved or not owner."),
},
)
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsApprovedTeacher, IsCourseOwner])
def publish(self, request, pk=None):
course = self.get_object()
course.publish()
return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data)
@extend_schema(
tags=["courses-instructor"],
summary="Unpublish course (back to draft)",
request=None,
responses={200: CourseInstructorDetailSerializer},
)
@action(detail=True, methods=["post"])
def unpublish(self, request, pk=None):
course = self.get_object()
course.unpublish()
return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data)
@extend_schema(
tags=["courses-instructor"],
summary="Reorder sections within a course",
description="Body: `{\"ids\": [section_id, ...]}` — array order = new order.",
request=IDListSerializer,
responses={204: OpenApiResponse(description="Reordered.")},
)
@action(detail=True, methods=["post"], url_path="sections/reorder")
def reorder_sections(self, request, pk=None):
course = self.get_object()
ser = IDListSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ids = ser.validated_data["ids"]
sections = list(course.sections.filter(id__in=ids))
if {s.id for s in sections} != set(ids):
raise ValidationError({"ids": "Some sections do not belong to this course."})
with transaction.atomic():
for order, sid in enumerate(ids):
Section.objects.filter(id=sid, course=course).update(order=order)
return Response(status=status.HTTP_204_NO_CONTENT)
class InstructorSectionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
serializer_class = SectionWriteSerializer
queryset = Section.objects.all().select_related("course")
filterset_fields = ["course"]
ordering_fields = ["order"]
ordering = ["order"]
def get_queryset(self):
return super().get_queryset().filter(course__instructor=self.request.user)
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return SectionReadSerializer
return SectionWriteSerializer
def perform_create(self, serializer):
course = serializer.validated_data["course"]
if course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
@extend_schema(
tags=["courses-instructor"],
summary="Reorder lessons within a section",
description="Body: `{\"ids\": [lesson_id, ...]}` — array order = new order.",
request=IDListSerializer,
responses={204: OpenApiResponse(description="Reordered.")},
)
@action(detail=True, methods=["post"], url_path="lessons/reorder")
def reorder_lessons(self, request, pk=None):
section = self.get_object()
ser = IDListSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ids = ser.validated_data["ids"]
lessons = list(section.lessons.filter(id__in=ids))
if {l.id for l in lessons} != set(ids):
raise ValidationError({"ids": "Some lessons do not belong to this section."})
with transaction.atomic():
for order, lid in enumerate(ids):
Lesson.objects.filter(id=lid, section=section).update(order=order)
return Response(status=status.HTTP_204_NO_CONTENT)
class InstructorLessonViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
serializer_class = LessonWriteSerializer
queryset = Lesson.objects.all().select_related("section__course")
filterset_fields = ["section", "section__course", "kind", "status", "is_preview"]
ordering_fields = ["order"]
ordering = ["order"]
def get_queryset(self):
return super().get_queryset().filter(section__course__instructor=self.request.user)
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return LessonReadSerializer
return LessonWriteSerializer
def perform_create(self, serializer):
section = serializer.validated_data["section"]
if section.course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
class _CourseScopedViewSet(viewsets.ModelViewSet):
"""Shared logic for highlights + FAQs: filter to the requester's courses, enforce ownership on write."""
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
filterset_fields = ["course"]
def get_queryset(self):
return super().get_queryset().filter(course__instructor=self.request.user)
def perform_create(self, serializer):
course = serializer.validated_data["course"]
if course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
@extend_schema(tags=["courses-instructor"])
class InstructorHighlightViewSet(_CourseScopedViewSet):
"""Bullet-list highlights shown on the public course detail."""
queryset = CourseHighlight.objects.all().select_related("course")
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseHighlightWriteSerializer
return CourseHighlightSerializer
@extend_schema(tags=["courses-instructor"])
class InstructorFAQViewSet(_CourseScopedViewSet):
"""FAQ entries shown on the public course detail."""
queryset = CourseFAQ.objects.all().select_related("course")
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseFAQWriteSerializer
return CourseFAQSerializer

7
apps/courses/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoursesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.courses"
label = "courses"

View File

@ -0,0 +1,158 @@
# Generated by Django 5.1.15 on 2026-05-02 15:11
import django.db.models.deletion
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='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=64, unique=True, verbose_name='slug')),
('name', models.CharField(max_length=120, verbose_name='name')),
('name_en', models.CharField(max_length=120, null=True, verbose_name='name')),
('name_fa', models.CharField(max_length=120, null=True, verbose_name='name')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
],
options={
'verbose_name': 'category',
'verbose_name_plural': 'categories',
'ordering': ['order', 'slug'],
},
),
migrations.CreateModel(
name='Course',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=80, unique=True, verbose_name='slug')),
('title', models.CharField(max_length=200, verbose_name='title')),
('title_en', models.CharField(max_length=200, null=True, verbose_name='title')),
('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')),
('tagline', models.CharField(blank=True, max_length=300, verbose_name='tagline')),
('tagline_en', models.CharField(blank=True, max_length=300, null=True, verbose_name='tagline')),
('tagline_fa', models.CharField(blank=True, max_length=300, null=True, verbose_name='tagline')),
('description', models.TextField(blank=True, verbose_name='description')),
('description_en', models.TextField(blank=True, null=True, verbose_name='description')),
('description_fa', models.TextField(blank=True, null=True, verbose_name='description')),
('level', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'), ('all', 'All levels')], default='beginner', max_length=16, verbose_name='level')),
('language', models.CharField(default='fa', max_length=8, verbose_name='language')),
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='cover image')),
('color', models.CharField(blank=True, help_text='CSS color token used by the UI.', max_length=64, verbose_name='color')),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=16, verbose_name='status')),
('published_at', models.DateTimeField(blank=True, null=True, verbose_name='published at')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='courses', to='courses.category')),
('instructor', models.ForeignKey(limit_choices_to={'role': 'teacher'}, on_delete=django.db.models.deletion.PROTECT, related_name='courses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'course',
'verbose_name_plural': 'courses',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CourseFAQ',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(max_length=300, verbose_name='question')),
('question_en', models.CharField(max_length=300, null=True, verbose_name='question')),
('question_fa', models.CharField(max_length=300, null=True, verbose_name='question')),
('answer', models.TextField(verbose_name='answer')),
('answer_en', models.TextField(null=True, verbose_name='answer')),
('answer_fa', models.TextField(null=True, verbose_name='answer')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='faqs', to='courses.course')),
],
options={
'verbose_name': 'FAQ',
'verbose_name_plural': 'FAQs',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='CourseHighlight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(max_length=200, verbose_name='text')),
('text_en', models.CharField(max_length=200, null=True, verbose_name='text')),
('text_fa', models.CharField(max_length=200, null=True, verbose_name='text')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='courses.course')),
],
options={
'verbose_name': 'highlight',
'verbose_name_plural': 'highlights',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='title')),
('title_en', models.CharField(max_length=200, null=True, verbose_name='title')),
('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='courses.course')),
],
options={
'verbose_name': 'section',
'verbose_name_plural': 'sections',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='Lesson',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200, verbose_name='title')),
('title_en', models.CharField(max_length=200, null=True, verbose_name='title')),
('title_fa', models.CharField(max_length=200, null=True, verbose_name='title')),
('kind', models.CharField(choices=[('video', 'Video'), ('text', 'Text'), ('pdf', 'PDF'), ('task', 'Task'), ('quiz', 'Quiz')], max_length=16, verbose_name='kind')),
('body', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', verbose_name='body')),
('body_en', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', null=True, verbose_name='body')),
('body_fa', models.TextField(blank=True, help_text='Markdown body for text/task lessons.', null=True, verbose_name='body')),
('video_url', models.URLField(blank=True, max_length=500, verbose_name='video URL')),
('pdf_file', models.FileField(blank=True, null=True, upload_to='courses/pdfs/', verbose_name='PDF')),
('duration_seconds', models.PositiveIntegerField(blank=True, null=True, verbose_name='duration (seconds)')),
('is_preview', models.BooleanField(default=False, verbose_name='free preview')),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=16, verbose_name='status')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='courses.section')),
],
options={
'verbose_name': 'lesson',
'verbose_name_plural': 'lessons',
'ordering': ['order', 'id'],
},
),
migrations.AddIndex(
model_name='course',
index=models.Index(fields=['instructor', 'status'], name='courses_cou_instruc_98570d_idx'),
),
migrations.AddIndex(
model_name='course',
index=models.Index(fields=['status', '-created_at'], name='courses_cou_status_41eebb_idx'),
),
migrations.AddIndex(
model_name='section',
index=models.Index(fields=['course', 'order'], name='courses_sec_course__60bff3_idx'),
),
migrations.AddIndex(
model_name='lesson',
index=models.Index(fields=['section', 'order'], name='courses_les_section_573283_idx'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-05-02 15:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='course',
name='original_price_toman',
field=models.PositiveIntegerField(blank=True, help_text='Optional strike-through price for displaying a discount.', null=True, verbose_name='original price (Toman)'),
),
migrations.AddField(
model_name='course',
name='price_toman',
field=models.PositiveIntegerField(blank=True, help_text='Null or 0 = free course.', null=True, verbose_name='price (Toman)'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-05-02 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0002_course_original_price_toman_course_price_toman'),
]
operations = [
migrations.AddField(
model_name='course',
name='is_coming_soon',
field=models.BooleanField(default=False, help_text="Show a 'coming soon' badge on the catalog (e.g. course is being prepared but is already listed).", verbose_name='coming soon'),
),
migrations.AddField(
model_name='lesson',
name='is_downloadable',
field=models.BooleanField(default=False, help_text='If true, students can download the asset (mostly for video lessons).', verbose_name='downloadable'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-05-02 16:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0003_course_is_coming_soon_lesson_is_downloadable'),
]
operations = [
migrations.AddField(
model_name='course',
name='discount_ends_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='discount ends at'),
),
migrations.AddField(
model_name='course',
name='discount_starts_at',
field=models.DateTimeField(blank=True, help_text='If set together with discount_ends_at, the catalog shows an active discount countdown.', null=True, verbose_name='discount starts at'),
),
]

View File

231
apps/courses/models.py Normal file
View File

@ -0,0 +1,231 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
# Ensure timezone is referenced (used by Course.discount_is_active)
_ = _
class Level(models.TextChoices):
BEGINNER = "beginner", _("Beginner")
INTERMEDIATE = "intermediate", _("Intermediate")
ADVANCED = "advanced", _("Advanced")
ALL = "all", _("All levels")
class CourseStatus(models.TextChoices):
DRAFT = "draft", _("Draft")
PUBLISHED = "published", _("Published")
class LessonKind(models.TextChoices):
VIDEO = "video", _("Video")
TEXT = "text", _("Text")
PDF = "pdf", _("PDF")
TASK = "task", _("Task")
QUIZ = "quiz", _("Quiz")
class LessonStatus(models.TextChoices):
DRAFT = "draft", _("Draft")
PUBLISHED = "published", _("Published")
class Category(models.Model):
slug = models.SlugField(_("slug"), max_length=64, unique=True)
name = models.CharField(_("name"), max_length=120)
order = models.PositiveIntegerField(_("order"), default=0)
class Meta:
verbose_name = _("category")
verbose_name_plural = _("categories")
ordering = ["order", "slug"]
def __str__(self):
return self.name or self.slug
class Course(models.Model):
instructor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="courses",
limit_choices_to={"role": "teacher"},
)
category = models.ForeignKey(
Category,
on_delete=models.SET_NULL,
related_name="courses",
null=True,
blank=True,
)
slug = models.SlugField(_("slug"), max_length=80, unique=True)
title = models.CharField(_("title"), max_length=200)
tagline = models.CharField(_("tagline"), max_length=300, blank=True)
description = models.TextField(_("description"), blank=True)
level = models.CharField(
_("level"), max_length=16, choices=Level.choices, default=Level.BEGINNER
)
language = models.CharField(_("language"), max_length=8, default="fa")
cover_image = models.ImageField(_("cover image"), upload_to="courses/covers/", blank=True, null=True)
color = models.CharField(_("color"), max_length=64, blank=True, help_text=_("CSS color token used by the UI."))
is_coming_soon = models.BooleanField(
_("coming soon"), default=False,
help_text=_("Show a 'coming soon' badge on the catalog (e.g. course is being prepared but is already listed)."),
)
price_toman = models.PositiveIntegerField(
_("price (Toman)"), null=True, blank=True,
help_text=_("Null or 0 = free course."),
)
original_price_toman = models.PositiveIntegerField(
_("original price (Toman)"), null=True, blank=True,
help_text=_("Optional strike-through price for displaying a discount."),
)
discount_starts_at = models.DateTimeField(
_("discount starts at"), null=True, blank=True,
help_text=_("If set together with discount_ends_at, the catalog shows an active discount countdown."),
)
discount_ends_at = models.DateTimeField(
_("discount ends at"), null=True, blank=True,
)
status = models.CharField(
_("status"), max_length=16, choices=CourseStatus.choices, default=CourseStatus.DRAFT
)
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("course")
verbose_name_plural = _("courses")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["instructor", "status"]),
models.Index(fields=["status", "-created_at"]),
]
def __str__(self):
return self.title or self.slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self._generate_slug()
super().save(*args, **kwargs)
def _generate_slug(self) -> str:
base = slugify(self.title or "course", allow_unicode=False) or "course"
candidate = base[:64]
n = 2
while Course.objects.filter(slug=candidate).exclude(pk=self.pk).exists():
suffix = f"-{n}"
candidate = (base[: 64 - len(suffix)]) + suffix
n += 1
return candidate
@property
def is_published(self) -> bool:
return self.status == CourseStatus.PUBLISHED
@property
def is_free(self) -> bool:
return not self.price_toman
@property
def discount_is_active(self) -> bool:
if not (self.original_price_toman and self.price_toman and self.original_price_toman > self.price_toman):
return False
now = timezone.now()
if self.discount_starts_at and now < self.discount_starts_at:
return False
if self.discount_ends_at and now >= self.discount_ends_at:
return False
return True
def publish(self):
if self.status != CourseStatus.PUBLISHED:
self.status = CourseStatus.PUBLISHED
self.published_at = self.published_at or timezone.now()
self.save(update_fields=["status", "published_at", "updated_at"])
def unpublish(self):
if self.status != CourseStatus.DRAFT:
self.status = CourseStatus.DRAFT
self.save(update_fields=["status", "updated_at"])
class CourseHighlight(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="highlights")
text = models.CharField(_("text"), max_length=200)
order = models.PositiveIntegerField(_("order"), default=0)
class Meta:
verbose_name = _("highlight")
verbose_name_plural = _("highlights")
ordering = ["order", "id"]
def __str__(self):
return self.text[:60]
class CourseFAQ(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="faqs")
question = models.CharField(_("question"), max_length=300)
answer = models.TextField(_("answer"))
order = models.PositiveIntegerField(_("order"), default=0)
class Meta:
verbose_name = _("FAQ")
verbose_name_plural = _("FAQs")
ordering = ["order", "id"]
def __str__(self):
return self.question[:60]
class Section(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="sections")
title = models.CharField(_("title"), max_length=200)
order = models.PositiveIntegerField(_("order"), default=0)
class Meta:
verbose_name = _("section")
verbose_name_plural = _("sections")
ordering = ["order", "id"]
indexes = [models.Index(fields=["course", "order"])]
def __str__(self):
return self.title or f"section #{self.pk}"
class Lesson(models.Model):
section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="lessons")
title = models.CharField(_("title"), max_length=200)
kind = models.CharField(_("kind"), max_length=16, choices=LessonKind.choices)
body = models.TextField(_("body"), blank=True, help_text=_("Markdown body for text/task lessons."))
video_url = models.URLField(_("video URL"), blank=True, max_length=500)
pdf_file = models.FileField(_("PDF"), upload_to="courses/pdfs/", blank=True, null=True)
duration_seconds = models.PositiveIntegerField(
_("duration (seconds)"), null=True, blank=True
)
is_preview = models.BooleanField(_("free preview"), default=False)
is_downloadable = models.BooleanField(
_("downloadable"), default=False,
help_text=_("If true, students can download the asset (mostly for video lessons)."),
)
status = models.CharField(
_("status"), max_length=16, choices=LessonStatus.choices, default=LessonStatus.DRAFT
)
order = models.PositiveIntegerField(_("order"), default=0)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("lesson")
verbose_name_plural = _("lessons")
ordering = ["order", "id"]
indexes = [models.Index(fields=["section", "order"])]
def __str__(self):
return self.title or f"lesson #{self.pk}"

505
apps/courses/tests.py Normal file
View File

@ -0,0 +1,505 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Role, TeacherProfile
from apps.courses.models import (
Category,
Course,
CourseFAQ,
CourseHighlight,
CourseStatus,
Lesson,
LessonKind,
LessonStatus,
Section,
)
User = get_user_model()
# --------------------------------------------------------------------------- helpers
def _make_teacher(phone="09120000001", name="Teacher One", approved=False):
u = User.objects.create_user(phone_number=phone, full_name=name, role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved)
return u
def _make_student(phone="09120000099", name="Student"):
return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT)
def _auth(client, user):
# tests bypass OTP and just attach the user via force_authenticate
client.force_authenticate(user=user)
def _course(instructor, **overrides):
fields = {
"title": "Course Title",
"tagline": "Tagline",
"description": "Description",
"level": "beginner",
"language": "fa",
}
fields.update(overrides)
return Course.objects.create(instructor=instructor, **fields)
# --------------------------------------------------------------------------- model tests
class CourseModelTests(TestCase):
def test_slug_auto_generated_on_create(self):
t = _make_teacher()
c = _course(t, title="Hello World")
self.assertTrue(c.slug)
self.assertNotIn(" ", c.slug)
def test_slug_uniqueness_collision(self):
t = _make_teacher()
a = _course(t, title="Same Title")
b = _course(t, title="Same Title")
self.assertNotEqual(a.slug, b.slug)
def test_publish_sets_status_and_published_at(self):
t = _make_teacher()
c = _course(t)
self.assertEqual(c.status, CourseStatus.DRAFT)
self.assertIsNone(c.published_at)
c.publish()
c.refresh_from_db()
self.assertEqual(c.status, CourseStatus.PUBLISHED)
self.assertIsNotNone(c.published_at)
# --------------------------------------------------------------------------- public catalog
class PublicCatalogTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _make_teacher(approved=True)
self.cat = Category.objects.create(slug="design", name="Design", order=0)
self.published = _course(self.t, title="Published", category=self.cat)
self.published.publish()
self.draft = _course(self.t, title="Draft", category=self.cat)
def test_list_excludes_drafts(self):
r = self.client.get("/api/courses/")
self.assertEqual(r.status_code, 200)
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(self.published.id, ids)
self.assertNotIn(self.draft.id, ids)
def test_filter_by_category_slug(self):
other = Category.objects.create(slug="dev", name="Dev")
c2 = _course(self.t, title="Other", category=other)
c2.publish()
r = self.client.get("/api/courses/?category=design")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(self.published.id, ids)
self.assertNotIn(c2.id, ids)
def test_search_by_title(self):
r = self.client.get("/api/courses/?search=Publish")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(self.published.id, ids)
def test_detail_404_for_draft(self):
r = self.client.get(f"/api/courses/{self.draft.slug}/")
self.assertEqual(r.status_code, 404)
def test_detail_for_published(self):
r = self.client.get(f"/api/courses/{self.published.slug}/")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(body["id"], self.published.id)
self.assertIn("sections", body)
self.assertIn("highlights", body)
self.assertIn("faqs", body)
def test_category_includes_course_count(self):
r = self.client.get("/api/categories/")
self.assertEqual(r.status_code, 200)
rows = r.json()
design_row = next(c for c in rows if c["slug"] == "design")
# Only 1 published course in `design` (`self.published`); `self.draft` is draft
self.assertEqual(design_row["course_count"], 1)
def test_course_list_includes_stats_and_badges(self):
r = self.client.get("/api/courses/")
row = next(c for c in r.json()["results"] if c["id"] == self.published.id)
self.assertIn("lesson_count", row)
self.assertIn("enrolled_count", row)
self.assertIn("total_minutes_seconds", row)
self.assertIn("is_new", row)
self.assertIn("is_bestseller", row)
self.assertIn("is_coming_soon", row)
# Newly created course → is_new=True
self.assertTrue(row["is_new"])
# No paid orders → is_bestseller=False
self.assertFalse(row["is_bestseller"])
def test_course_list_ordering_by_popularity(self):
from apps.payments.models import Gateway, Order, OrderStatus
other = _course(self.t, title="Hot", category=self.cat)
other.publish()
# Create 12 paid orders for `other` to push it above the bestseller threshold (10)
from django.contrib.auth import get_user_model
u_model = get_user_model()
for i in range(12):
stu = u_model.objects.create_user(
phone_number=f"0911000{i:04d}", full_name=f"S{i}", role="student"
)
Order.objects.create(
student=stu, course=other, amount_toman=1000, gateway=Gateway.CONSOLE,
status=OrderStatus.PAID,
)
r = self.client.get("/api/courses/?ordering=-popularity")
results = r.json()["results"]
ids = [c["id"] for c in results]
self.assertEqual(ids[0], other.id)
# `other` should now be marked bestseller
hot = next(c for c in results if c["id"] == other.id)
self.assertTrue(hot["is_bestseller"])
def test_lesson_content_gated_unless_preview(self):
sec = Section.objects.create(course=self.published, title="S1", order=0)
free = Lesson.objects.create(
section=sec,
title="Free preview",
kind=LessonKind.VIDEO,
video_url="https://example.com/v.mp4",
status=LessonStatus.PUBLISHED,
is_preview=True,
is_downloadable=True,
order=0,
)
locked = Lesson.objects.create(
section=sec,
title="Locked",
kind=LessonKind.VIDEO,
video_url="https://example.com/secret.mp4",
status=LessonStatus.PUBLISHED,
is_preview=False,
order=1,
)
r = self.client.get(f"/api/courses/{self.published.slug}/")
sections = r.json()["sections"]
lessons = sections[0]["lessons"]
free_payload = next(l for l in lessons if l["id"] == free.id)
locked_payload = next(l for l in lessons if l["id"] == locked.id)
self.assertEqual(free_payload["video_url"], "https://example.com/v.mp4")
self.assertTrue(free_payload["is_downloadable"])
self.assertIsNone(locked_payload["video_url"])
self.assertFalse(locked_payload["is_downloadable"])
def test_public_detail_includes_about_instructor_card(self):
# set bio + headline + verified
tp = self.t.teacher_profile
tp.bio = "10 years of UX design"
tp.headline = "Senior Product Designer"
tp.is_verified = True
tp.years_experience = 10
tp.save()
r = self.client.get(f"/api/courses/{self.published.slug}/")
self.assertEqual(r.status_code, 200)
instr = r.json()["instructor"]
self.assertEqual(instr["id"], self.t.id)
self.assertEqual(instr["headline"], "Senior Product Designer")
self.assertEqual(instr["bio"], "10 years of UX design")
self.assertTrue(instr["is_verified"])
self.assertEqual(instr["years_experience"], 10)
# --------------------------------------------------------------------------- instructor CRUD
class InstructorCourseAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _make_teacher(phone="09120000001", name="Teacher One", approved=False)
self.t_approved = _make_teacher(phone="09120000002", name="Approved One", approved=True)
self.student = _make_student()
def test_anon_blocked(self):
r = self.client.get("/api/instructor/courses/")
self.assertEqual(r.status_code, 401)
def test_student_forbidden(self):
_auth(self.client, self.student)
r = self.client.get("/api/instructor/courses/")
self.assertEqual(r.status_code, 403)
def test_teacher_creates_course(self):
_auth(self.client, self.t)
r = self.client.post(
"/api/instructor/courses/",
{"title": "New", "tagline": "T", "description": "D", "level": "beginner", "language": "fa"},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
self.assertEqual(Course.objects.filter(instructor=self.t).count(), 1)
def test_teacher_only_lists_own_courses(self):
a = _course(self.t, title="Mine")
b = _course(self.t_approved, title="Theirs")
_auth(self.client, self.t)
r = self.client.get("/api/instructor/courses/")
self.assertEqual(r.status_code, 200)
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(a.id, ids)
self.assertNotIn(b.id, ids)
def test_publish_requires_approved_profile(self):
c = _course(self.t) # not approved
_auth(self.client, self.t)
r = self.client.post(f"/api/instructor/courses/{c.id}/publish/")
self.assertEqual(r.status_code, 403)
c.refresh_from_db()
self.assertEqual(c.status, CourseStatus.DRAFT)
def test_publish_succeeds_when_approved(self):
c = _course(self.t_approved)
_auth(self.client, self.t_approved)
r = self.client.post(f"/api/instructor/courses/{c.id}/publish/")
self.assertEqual(r.status_code, 200, r.content)
c.refresh_from_db()
self.assertEqual(c.status, CourseStatus.PUBLISHED)
def test_unpublish(self):
c = _course(self.t_approved)
c.publish()
_auth(self.client, self.t_approved)
r = self.client.post(f"/api/instructor/courses/{c.id}/unpublish/")
self.assertEqual(r.status_code, 200)
c.refresh_from_db()
self.assertEqual(c.status, CourseStatus.DRAFT)
def test_cannot_modify_others_course(self):
c = _course(self.t_approved, title="His")
_auth(self.client, self.t)
r = self.client.patch(
f"/api/instructor/courses/{c.id}/", {"title": "Hijacked"}, format="json"
)
# not visible in queryset → 404
self.assertIn(r.status_code, (403, 404))
# --------------------------------------------------------------------------- section + lesson
class InstructorSectionLessonAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _make_teacher(approved=True)
self.course = _course(self.t)
_auth(self.client, self.t)
def test_create_section_in_owned_course(self):
r = self.client.post(
"/api/instructor/sections/",
{"course": self.course.id, "title": "S1", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
def test_cannot_create_section_in_others_course(self):
other = _make_teacher(phone="09120000050", approved=True)
other_course = _course(other)
r = self.client.post(
"/api/instructor/sections/",
{"course": other_course.id, "title": "S1", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 403)
def test_create_video_lesson_validates_video_url(self):
sec = Section.objects.create(course=self.course, title="S1")
r = self.client.post(
"/api/instructor/lessons/",
{"section": sec.id, "title": "L1", "kind": "video", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("video_url", r.json())
def test_create_text_lesson_requires_body(self):
sec = Section.objects.create(course=self.course, title="S1")
r = self.client.post(
"/api/instructor/lessons/",
{"section": sec.id, "title": "L1", "kind": "text", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("body", r.json())
def test_create_video_lesson_ok(self):
sec = Section.objects.create(course=self.course, title="S1")
r = self.client.post(
"/api/instructor/lessons/",
{
"section": sec.id,
"title": "L1",
"kind": "video",
"video_url": "https://example.com/v.mp4",
"order": 0,
},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
def test_reorder_sections(self):
s1 = Section.objects.create(course=self.course, title="A", order=0)
s2 = Section.objects.create(course=self.course, title="B", order=1)
s3 = Section.objects.create(course=self.course, title="C", order=2)
r = self.client.post(
f"/api/instructor/courses/{self.course.id}/sections/reorder/",
{"ids": [s3.id, s1.id, s2.id]},
format="json",
)
self.assertEqual(r.status_code, 204, r.content)
s3.refresh_from_db(); s1.refresh_from_db(); s2.refresh_from_db()
self.assertEqual(s3.order, 0)
self.assertEqual(s1.order, 1)
self.assertEqual(s2.order, 2)
def test_reorder_rejects_foreign_section_ids(self):
s1 = Section.objects.create(course=self.course, title="A", order=0)
other = _make_teacher(phone="09120000050", approved=True)
other_course = _course(other)
foreign = Section.objects.create(course=other_course, title="X", order=0)
r = self.client.post(
f"/api/instructor/courses/{self.course.id}/sections/reorder/",
{"ids": [s1.id, foreign.id]},
format="json",
)
self.assertEqual(r.status_code, 400)
def test_reorder_lessons(self):
sec = Section.objects.create(course=self.course, title="S1")
l1 = Lesson.objects.create(
section=sec, title="A", kind=LessonKind.VIDEO,
video_url="https://e.com/a", order=0,
)
l2 = Lesson.objects.create(
section=sec, title="B", kind=LessonKind.VIDEO,
video_url="https://e.com/b", order=1,
)
l3 = Lesson.objects.create(
section=sec, title="C", kind=LessonKind.VIDEO,
video_url="https://e.com/c", order=2,
)
r = self.client.post(
f"/api/instructor/sections/{sec.id}/lessons/reorder/",
{"ids": [l3.id, l1.id, l2.id]},
format="json",
)
self.assertEqual(r.status_code, 204, r.content)
l1.refresh_from_db(); l2.refresh_from_db(); l3.refresh_from_db()
self.assertEqual([l3.order, l1.order, l2.order], [0, 1, 2])
# --------------------------------------------------------------------------- highlights + FAQs
class HighlightFAQAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _make_teacher(approved=True)
self.course = _course(self.t)
_auth(self.client, self.t)
def test_create_highlight(self):
r = self.client.post(
"/api/instructor/highlights/",
{"course": self.course.id, "text": "First highlight", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
self.assertEqual(self.course.highlights.count(), 1)
def test_cannot_create_highlight_in_others_course(self):
other = _make_teacher(phone="09120000050", approved=True)
other_course = _course(other)
r = self.client.post(
"/api/instructor/highlights/",
{"course": other_course.id, "text": "Stolen", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 403)
def test_highlight_list_filtered_by_course(self):
other_course = _course(self.t, title="Other")
a = CourseHighlight.objects.create(course=self.course, text="X", order=0)
b = CourseHighlight.objects.create(course=other_course, text="Y", order=0)
r = self.client.get(f"/api/instructor/highlights/?course={self.course.id}")
self.assertEqual(r.status_code, 200)
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(a.id, ids)
self.assertNotIn(b.id, ids)
def test_create_faq(self):
r = self.client.post(
"/api/instructor/faqs/",
{"course": self.course.id, "question": "Q?", "answer": "A.", "order": 0},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
self.assertEqual(self.course.faqs.count(), 1)
def test_faq_appears_in_public_detail(self):
self.course.publish()
CourseFAQ.objects.create(course=self.course, question="Q", answer="A", order=0)
unauth = APIClient()
r = unauth.get(f"/api/courses/{self.course.slug}/")
self.assertEqual(r.status_code, 200)
faqs = r.json()["faqs"]
self.assertEqual(len(faqs), 1)
self.assertEqual(faqs[0]["question"], "Q")
# --------------------------------------------------------------------------- instructor filters
class InstructorFilterTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _make_teacher(approved=True)
_auth(self.client, self.t)
self.draft = _course(self.t, title="Draft One")
self.pub = _course(self.t, title="Pub One")
self.pub.publish()
def test_instructor_courses_filter_by_status(self):
r = self.client.get("/api/instructor/courses/?status=published")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(self.pub.id, ids)
self.assertNotIn(self.draft.id, ids)
r = self.client.get("/api/instructor/courses/?status=draft")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(self.draft.id, ids)
self.assertNotIn(self.pub.id, ids)
def test_instructor_sections_filter_by_course(self):
s_a = Section.objects.create(course=self.draft, title="A")
s_b = Section.objects.create(course=self.pub, title="B")
r = self.client.get(f"/api/instructor/sections/?course={self.draft.id}")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(s_a.id, ids)
self.assertNotIn(s_b.id, ids)
def test_instructor_lessons_filter_by_section(self):
sec_a = Section.objects.create(course=self.draft, title="A")
sec_b = Section.objects.create(course=self.draft, title="B")
la = Lesson.objects.create(
section=sec_a, title="LA", kind=LessonKind.VIDEO,
video_url="https://e.com/la", order=0,
)
lb = Lesson.objects.create(
section=sec_b, title="LB", kind=LessonKind.VIDEO,
video_url="https://e.com/lb", order=0,
)
r = self.client.get(f"/api/instructor/lessons/?section={sec_a.id}")
ids = [row["id"] for row in r.json()["results"]]
self.assertIn(la.id, ids)
self.assertNotIn(lb.id, ids)

View File

@ -0,0 +1,33 @@
from modeltranslation.translator import TranslationOptions, register
from .models import Category, Course, CourseFAQ, CourseHighlight, Lesson, Section
@register(Category)
class CategoryTranslationOptions(TranslationOptions):
fields = ("name",)
@register(Course)
class CourseTranslationOptions(TranslationOptions):
fields = ("title", "tagline", "description")
@register(CourseHighlight)
class CourseHighlightTranslationOptions(TranslationOptions):
fields = ("text",)
@register(CourseFAQ)
class CourseFAQTranslationOptions(TranslationOptions):
fields = ("question", "answer")
@register(Section)
class SectionTranslationOptions(TranslationOptions):
fields = ("title",)
@register(Lesson)
class LessonTranslationOptions(TranslationOptions):
fields = ("title", "body")

View File

29
apps/enrollments/admin.py Normal file
View File

@ -0,0 +1,29 @@
from django.contrib import admin
from .models import ActivityEvent, Enrollment, LessonProgress
@admin.register(Enrollment)
class EnrollmentAdmin(admin.ModelAdmin):
list_display = ("student", "course", "enrolled_at", "completed_at")
list_filter = ("course",)
search_fields = ("student__phone_number", "student__full_name", "course__slug")
raw_id_fields = ("student", "course")
readonly_fields = ("enrolled_at",)
@admin.register(LessonProgress)
class LessonProgressAdmin(admin.ModelAdmin):
list_display = ("enrollment", "lesson", "completed_at")
list_filter = ("completed_at",)
raw_id_fields = ("enrollment", "lesson")
@admin.register(ActivityEvent)
class ActivityEventAdmin(admin.ModelAdmin):
list_display = ("kind", "course", "actor", "created_at")
list_filter = ("kind", "course")
search_fields = ("course__slug", "actor__full_name", "actor__phone_number")
raw_id_fields = ("course", "actor")
readonly_fields = ("created_at",)
date_hierarchy = "created_at"

View File

View File

@ -0,0 +1,324 @@
"""Instructor analytics endpoints — KPIs, monthly revenue chart, activity feed."""
from datetime import timedelta
from decimal import Decimal
from typing import Optional
from django.db.models import Avg, Count, F, FloatField, IntegerField, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce, TruncMonth
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import ListAPIView
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from django.utils import timezone
from apps.accounts.models import Role, User
from apps.courses.models import Course, Lesson, LessonStatus
from apps.enrollments.models import ActivityEvent, Enrollment, LessonProgress
from apps.payments.models import Order, OrderStatus
# --------------------------------------------------------------------------- helpers
def _ensure_teacher(request):
if request.user.role != Role.TEACHER:
raise PermissionDenied("Only teachers can access this analytics view.")
def _instructor_courses(user):
return Course.objects.filter(instructor=user)
def _start_of_month(dt):
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _percent_delta(current: int, previous: int) -> Optional[int]:
if previous == 0:
return None
return int(round((current - previous) * 100 / previous))
# --------------------------------------------------------------------------- KPIs
class KPISparkSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
value = serializers.IntegerField()
unit = serializers.CharField(allow_null=True)
delta_percent = serializers.IntegerField(allow_null=True)
good = serializers.BooleanField(allow_null=True)
spark = serializers.ListField(child=serializers.IntegerField())
class InstructorKPIsView(APIView):
"""Four KPI cards for the instructor home: revenue, active students, avg progress, satisfaction."""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["analytics"],
summary="Instructor KPI snapshot (4 cards)",
description=(
"Returns the four KPI cards shown on the instructor home, in the design's shape: "
"revenue this month (Toman, instructor share), active students (last 7 days), "
"average progress %, satisfaction (review average — null until reviews ship). "
"Each card includes a `delta_percent` vs the previous period and a 12-point sparkline."
),
responses={200: KPISparkSerializer(many=True)},
)
def get(self, request):
_ensure_teacher(request)
user = request.user
now = timezone.now()
this_month_start = _start_of_month(now)
prev_month_start = _start_of_month(this_month_start - timedelta(days=1))
seven_days_ago = now - timedelta(days=7)
prev_seven_days_ago = now - timedelta(days=14)
# ---- Revenue (this month vs last month) -----
rev_q = Order.objects.filter(
course__instructor=user, status=OrderStatus.PAID, paid_at__isnull=False
)
revenue_this = rev_q.filter(paid_at__gte=this_month_start).aggregate(
t=Coalesce(Sum("instructor_share_toman"), 0)
)["t"]
revenue_prev = rev_q.filter(
paid_at__gte=prev_month_start, paid_at__lt=this_month_start
).aggregate(t=Coalesce(Sum("instructor_share_toman"), 0))["t"]
# 12-month sparkline
spark_revenue = self._monthly_spark(
queryset=rev_q,
month_field="paid_at",
agg=Sum("instructor_share_toman"),
)
# ---- Active students -----
active_now = User.objects.filter(
role=Role.STUDENT,
last_active_at__gte=seven_days_ago,
enrollments__course__instructor=user,
).distinct().count()
active_prev = User.objects.filter(
role=Role.STUDENT,
last_active_at__gte=prev_seven_days_ago,
last_active_at__lt=seven_days_ago,
enrollments__course__instructor=user,
).distinct().count()
# Sparkline: enrollments per month over the last 12 months (proxy for "students" growth)
spark_students = self._monthly_spark(
queryset=Enrollment.objects.filter(course__instructor=user),
month_field="enrolled_at",
agg=Count("id"),
)
# ---- Average progress % -----
enrollments = list(
Enrollment.objects.filter(course__instructor=user).select_related("course")
)
if enrollments:
avg_progress = int(round(sum(e.progress_percent() for e in enrollments) / len(enrollments)))
else:
avg_progress = 0
spark_progress = [avg_progress] * 12 # no historical snapshot model yet — flat line
# ---- Satisfaction (placeholder until Reviews ship) -----
satisfaction_value = 0
satisfaction_unit = None
spark_satisfaction = [0] * 12
kpis = [
{
"id": "revenue",
"label": "Revenue this month",
"value": int(revenue_this or 0),
"unit": "Toman",
"delta_percent": _percent_delta(revenue_this, revenue_prev),
"good": (revenue_this or 0) >= (revenue_prev or 0),
"spark": spark_revenue,
},
{
"id": "active_students",
"label": "Active students (7d)",
"value": active_now,
"unit": "students",
"delta_percent": _percent_delta(active_now, active_prev),
"good": active_now >= active_prev,
"spark": spark_students,
},
{
"id": "avg_progress",
"label": "Average progress",
"value": avg_progress,
"unit": "%",
"delta_percent": None,
"good": None,
"spark": spark_progress,
},
{
"id": "satisfaction",
"label": "Student satisfaction",
"value": satisfaction_value,
"unit": satisfaction_unit,
"delta_percent": None,
"good": None,
"spark": spark_satisfaction,
},
]
return Response(KPISparkSerializer(kpis, many=True).data)
@staticmethod
def _monthly_spark(*, queryset, month_field: str, agg) -> list[int]:
"""Return a 12-element list of monthly aggregate values, oldest-to-newest."""
now = timezone.now()
first_month = _start_of_month(now) - timedelta(days=365) # roughly 12 months back
first_month = _start_of_month(first_month + timedelta(days=2))
rows = (
queryset.filter(**{f"{month_field}__gte": first_month})
.annotate(_m=TruncMonth(month_field))
.values("_m")
.annotate(v=Coalesce(agg, 0))
.order_by("_m")
)
bucket = {row["_m"].replace(day=1).date(): int(row["v"] or 0) for row in rows}
out = []
cursor = _start_of_month(now)
# Build oldest → newest, 12 entries
months = []
for i in range(11, -1, -1):
month_start = _start_of_month(cursor - timedelta(days=30 * i))
months.append(month_start.date().replace(day=1))
for m in months:
out.append(bucket.get(m, 0))
return out
# --------------------------------------------------------------------------- Monthly revenue
class MonthlyRevenueSerializer(serializers.Serializer):
month = serializers.DateField()
label = serializers.CharField()
revenue_toman = serializers.IntegerField()
orders_count = serializers.IntegerField()
class InstructorMonthlyRevenueView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["analytics"],
summary="Monthly revenue chart",
description="Returns the last 12 calendar months of revenue (instructor share) and order count.",
responses={200: MonthlyRevenueSerializer(many=True)},
)
def get(self, request):
_ensure_teacher(request)
now = timezone.now()
# Build the 12 month buckets oldest → newest
cursor = _start_of_month(now)
months = []
for i in range(11, -1, -1):
month_start = _start_of_month(cursor - timedelta(days=30 * i))
months.append(month_start.replace(day=1))
rows = (
Order.objects.filter(
course__instructor=request.user,
status=OrderStatus.PAID,
paid_at__gte=months[0],
)
.annotate(_m=TruncMonth("paid_at"))
.values("_m")
.annotate(
rev=Coalesce(Sum("instructor_share_toman"), 0),
cnt=Count("id"),
)
)
bucket = {row["_m"].replace(day=1).date(): row for row in rows}
out = []
for m in months:
key = m.date().replace(day=1)
row = bucket.get(key)
out.append({
"month": key,
"label": key.strftime("%Y-%m"),
"revenue_toman": int(row["rev"] if row else 0),
"orders_count": int(row["cnt"] if row else 0),
})
return Response(MonthlyRevenueSerializer(out, many=True).data)
# --------------------------------------------------------------------------- Activity feed
class ActivityEventSerializer(serializers.ModelSerializer):
actor_name = serializers.CharField(source="actor.full_name", read_only=True, allow_null=True)
course_slug = serializers.CharField(source="course.slug", read_only=True)
course_title = serializers.CharField(source="course.title", read_only=True)
class Meta:
model = ActivityEvent
fields = [
"id",
"kind",
"course",
"course_slug",
"course_title",
"actor",
"actor_name",
"payload",
"created_at",
]
read_only_fields = fields
class _ActivityPagination(PageNumberPagination):
page_size = 30
page_size_query_param = "page_size"
max_page_size = 100
class InstructorActivityFeedView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = ActivityEventSerializer
pagination_class = _ActivityPagination
@extend_schema(
tags=["analytics"],
summary="Activity feed (instructor home)",
description=(
"Most recent events across the instructor's courses. Filter by `?course=<id>` or "
"`?kind=enrolled|paid|lesson_completed|course_completed|announcement_posted`."
),
parameters=[
OpenApiParameter(name="course", type=int, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="kind", type=str, location=OpenApiParameter.QUERY, required=False),
],
)
def get(self, request, *args, **kwargs):
_ensure_teacher(request)
return super().get(request, *args, **kwargs)
def get_queryset(self):
qs = (
ActivityEvent.objects
.filter(course__instructor=self.request.user)
.select_related("course", "actor")
)
course_id = self.request.query_params.get("course")
if course_id:
qs = qs.filter(course_id=course_id)
kind = self.request.query_params.get("kind")
if kind:
qs = qs.filter(kind=kind)
return qs

View File

@ -0,0 +1,11 @@
from rest_framework.permissions import BasePermission
from apps.accounts.models import Role
class IsStudent(BasePermission):
message = "Only students can perform this action."
def has_permission(self, request, view):
u = request.user
return bool(u and u.is_authenticated and u.role == Role.STUDENT)

View File

@ -0,0 +1,172 @@
from rest_framework import serializers
from apps.courses.api.serializers import (
CategorySerializer,
CourseFAQSerializer,
CourseHighlightSerializer,
LessonReadSerializer,
SectionReadSerializer,
)
from apps.courses.models import Lesson, LessonStatus
from apps.enrollments.models import Enrollment, LessonProgress
class EnrolledCourseSummarySerializer(serializers.Serializer):
"""Used by the my-courses dashboard list. Mirrors the design's MY_COURSES shape."""
id = serializers.IntegerField()
slug = serializers.CharField()
title = serializers.CharField()
tagline = serializers.CharField()
color = serializers.CharField()
cover_image = serializers.SerializerMethodField()
instructor_id = serializers.IntegerField()
instructor_name = serializers.SerializerMethodField()
enrolled_at = serializers.DateTimeField()
completed_at = serializers.DateTimeField(allow_null=True)
progress_percent = serializers.IntegerField()
completed_lessons = serializers.IntegerField()
total_lessons = serializers.IntegerField()
last_lesson_id = serializers.IntegerField(allow_null=True)
last_lesson_title = serializers.CharField(allow_null=True)
next_lesson_id = serializers.IntegerField(allow_null=True)
next_lesson_title = serializers.CharField(allow_null=True)
def get_cover_image(self, obj):
url = obj.get("cover_image_url")
request = self.context.get("request")
if url and request:
return request.build_absolute_uri(url)
return url
def get_instructor_name(self, obj):
return obj.get("instructor_name")
class StudentCourseDetailSerializer(serializers.Serializer):
"""Full course detail for an enrolled student — all published lesson content unlocked."""
id = serializers.IntegerField()
slug = serializers.CharField()
title = serializers.CharField()
tagline = serializers.CharField()
description = serializers.CharField()
level = serializers.CharField()
language = serializers.CharField()
color = serializers.CharField()
cover_image = serializers.SerializerMethodField()
instructor_id = serializers.IntegerField()
instructor_name = serializers.CharField()
category = CategorySerializer(allow_null=True)
highlights = CourseHighlightSerializer(many=True)
faqs = CourseFAQSerializer(many=True)
sections = serializers.SerializerMethodField()
enrollment = serializers.SerializerMethodField()
def get_cover_image(self, obj):
course = obj["course"]
if not course.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(course.cover_image.url)
return course.cover_image.url
def get_sections(self, obj):
course = obj["course"]
completed_lesson_ids = obj["completed_lesson_ids"]
sections = course.sections.all().prefetch_related(
"lessons"
)
out = []
for sec in sections:
published = [l for l in sec.lessons.all() if l.status == LessonStatus.PUBLISHED]
published.sort(key=lambda l: (l.order, l.id))
out.append({
"id": sec.id,
"title": sec.title,
"order": sec.order,
"lessons": LessonReadSerializer(
published, many=True
).data,
"lessons_completed": [
l.id for l in published if l.id in completed_lesson_ids
],
})
return out
def get_enrollment(self, obj):
e = obj["enrollment"]
return {
"enrolled_at": e.enrolled_at,
"completed_at": e.completed_at,
"progress_percent": e.progress_percent(),
"completed_lessons": e.completed_lessons_count(),
"total_lessons": e.total_lessons_count(),
}
class EnrollResponseSerializer(serializers.Serializer):
enrolled = serializers.BooleanField()
course_id = serializers.IntegerField()
course_slug = serializers.CharField()
enrolled_at = serializers.DateTimeField()
class LessonProgressResponseSerializer(serializers.ModelSerializer):
progress_percent = serializers.SerializerMethodField()
course_completed = serializers.SerializerMethodField()
class Meta:
model = LessonProgress
fields = ["id", "lesson", "completed_at", "progress_percent", "course_completed"]
read_only_fields = fields
def get_progress_percent(self, obj):
return obj.enrollment.progress_percent()
def get_course_completed(self, obj):
return obj.enrollment.is_completed
class InstructorEnrollmentSerializer(serializers.ModelSerializer):
"""Compact list view for the instructor's CRM-style student panel."""
student_id = serializers.IntegerField(source="student.id", read_only=True)
student_name = serializers.CharField(source="student.full_name", read_only=True)
student_phone = serializers.CharField(source="student.phone_number", read_only=True)
student_last_active_at = serializers.DateTimeField(source="student.last_active_at", read_only=True)
progress_percent = serializers.SerializerMethodField()
completed_lessons = serializers.SerializerMethodField()
total_lessons = serializers.SerializerMethodField()
engagement_status = serializers.SerializerMethodField()
class Meta:
model = Enrollment
fields = [
"id",
"student_id",
"student_name",
"student_phone",
"student_last_active_at",
"course_id",
"enrolled_at",
"completed_at",
"progress_percent",
"completed_lessons",
"total_lessons",
"engagement_status",
]
read_only_fields = fields
def get_progress_percent(self, obj):
return obj.progress_percent()
def get_completed_lessons(self, obj):
return obj.completed_lessons_count()
def get_total_lessons(self, obj):
return obj.total_lessons_count()
def get_engagement_status(self, obj):
return obj.engagement_status()

View File

@ -0,0 +1,30 @@
from django.urls import path
from .analytics import (
InstructorActivityFeedView,
InstructorKPIsView,
InstructorMonthlyRevenueView,
)
from .views import (
CourseEnrollmentsView,
EnrollView,
LessonCompleteView,
LessonUncompleteView,
MyCourseDetailView,
MyCoursesView,
)
urlpatterns = [
# student surface
path("courses/<slug:slug>/enroll/", EnrollView.as_view(), name="enroll"),
path("me/courses/", MyCoursesView.as_view(), name="my-courses"),
path("me/courses/<slug:slug>/", MyCourseDetailView.as_view(), name="my-course-detail"),
path("me/lessons/<int:lesson_id>/complete/", LessonCompleteView.as_view(), name="lesson-complete"),
path("me/lessons/<int:lesson_id>/uncomplete/", LessonUncompleteView.as_view(), name="lesson-uncomplete"),
# instructor surface (course-scoped)
path("instructor/courses/<int:course_id>/enrollments/", CourseEnrollmentsView.as_view(), name="instructor-course-enrollments"),
# instructor analytics
path("instructor/kpis/", InstructorKPIsView.as_view(), name="instructor-kpis"),
path("instructor/revenue/monthly/", InstructorMonthlyRevenueView.as_view(), name="instructor-monthly-revenue"),
path("instructor/activity/", InstructorActivityFeedView.as_view(), name="instructor-activity"),
]

View File

@ -0,0 +1,307 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus
from apps.enrollments.models import ActivityKind, Enrollment, LessonProgress
from apps.enrollments.services.events import record_event
from .permissions import IsStudent
from .serializers import (
EnrolledCourseSummarySerializer,
EnrollResponseSerializer,
InstructorEnrollmentSerializer,
LessonProgressResponseSerializer,
StudentCourseDetailSerializer,
)
# --- helpers ---------------------------------------------------------------------
def _get_or_404_published(slug: str) -> Course:
return get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED)
def _next_and_last(enrollment: Enrollment, completed_lesson_ids: set):
"""Return (last_lesson, next_lesson) tuples (lesson_id, lesson_title) given a course's
published lessons in (section.order, lesson.order) sequence."""
lessons = list(
Lesson.objects.filter(
section__course_id=enrollment.course_id, status=LessonStatus.PUBLISHED
)
.select_related("section")
.order_by("section__order", "section__id", "order", "id")
)
last = next_l = None
for lesson in lessons:
if lesson.id in completed_lesson_ids:
last = lesson
elif next_l is None:
next_l = lesson
return last, next_l
# --- student-facing -------------------------------------------------------------
class EnrollView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="Enroll in a published course (free only)",
description=(
"Free-course path. Creates an Enrollment idempotently. "
"If the course has a non-zero `price_toman`, returns 402 — caller must use "
"`POST /api/courses/<slug>/checkout/` instead."
),
request=None,
responses={
200: EnrollResponseSerializer,
201: EnrollResponseSerializer,
402: OpenApiResponse(description="Course is paid; use checkout."),
403: OpenApiResponse(description="Not a student."),
404: OpenApiResponse(description="Course not found or not published."),
},
)
def post(self, request, slug):
course = _get_or_404_published(slug)
if not course.is_free:
return Response(
{
"detail": "This course requires payment.",
"checkout_url": f"/api/courses/{course.slug}/checkout/",
"price_toman": course.price_toman,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
enrollment, created = Enrollment.objects.get_or_create(
student=request.user, course=course
)
if created:
record_event(
course=course,
actor=request.user,
kind=ActivityKind.ENROLLED.value,
payload={"price_toman": 0, "via": "free"},
)
body = {
"enrolled": True,
"course_id": course.id,
"course_slug": course.slug,
"enrolled_at": enrollment.enrolled_at,
}
return Response(
EnrollResponseSerializer(body).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
class MyCoursesView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="List my enrolled courses",
description="Returns each enrolled course with progress %, completed/total lessons, last/next lesson — the shape the student dashboard expects.",
responses={200: EnrolledCourseSummarySerializer(many=True)},
)
def get(self, request):
qs = (
Enrollment.objects
.filter(student=request.user)
.select_related("course", "course__instructor")
.order_by("-enrolled_at")
)
results = []
for e in qs:
completed_ids = set(
e.lesson_progress.filter(completed_at__isnull=False).values_list("lesson_id", flat=True)
)
last, nxt = _next_and_last(e, completed_ids)
results.append({
"id": e.course.id,
"slug": e.course.slug,
"title": e.course.title,
"tagline": e.course.tagline,
"color": e.course.color,
"cover_image_url": e.course.cover_image.url if e.course.cover_image else None,
"instructor_id": e.course.instructor_id,
"instructor_name": e.course.instructor.full_name,
"enrolled_at": e.enrolled_at,
"completed_at": e.completed_at,
"progress_percent": e.progress_percent(),
"completed_lessons": e.completed_lessons_count(),
"total_lessons": e.total_lessons_count(),
"last_lesson_id": last.id if last else None,
"last_lesson_title": last.title if last else None,
"next_lesson_id": nxt.id if nxt else None,
"next_lesson_title": nxt.title if nxt else None,
})
ser = EnrolledCourseSummarySerializer(results, many=True, context={"request": request})
return Response(ser.data)
class MyCourseDetailView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="My course detail (full content unlocked)",
description="Returns the full structure of an enrolled course with all published lesson content (no preview gating). 403 if not enrolled, 404 if course is not published.",
responses={
200: StudentCourseDetailSerializer,
403: OpenApiResponse(description="Not enrolled."),
},
)
def get(self, request, slug):
course = _get_or_404_published(slug)
try:
enrollment = Enrollment.objects.get(student=request.user, course=course)
except Enrollment.DoesNotExist:
raise PermissionDenied("You are not enrolled in this course.")
completed_lesson_ids = set(
enrollment.lesson_progress.filter(completed_at__isnull=False).values_list(
"lesson_id", flat=True
)
)
payload = {
"id": course.id,
"slug": course.slug,
"title": course.title,
"tagline": course.tagline,
"description": course.description,
"level": course.level,
"language": course.language,
"color": course.color,
"instructor_id": course.instructor_id,
"instructor_name": course.instructor.full_name,
"category": course.category,
"highlights": list(course.highlights.all()),
"faqs": list(course.faqs.all()),
"course": course,
"enrollment": enrollment,
"completed_lesson_ids": completed_lesson_ids,
}
ser = StudentCourseDetailSerializer(payload, context={"request": request})
return Response(ser.data)
class _LessonProgressBase(APIView):
permission_classes = [IsAuthenticated, IsStudent]
def _get_enrollment_and_lesson(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id, status=LessonStatus.PUBLISHED)
try:
enrollment = Enrollment.objects.get(
student=request.user, course=lesson.section.course
)
except Enrollment.DoesNotExist:
raise PermissionDenied("You are not enrolled in this course.")
return enrollment, lesson
class LessonCompleteView(_LessonProgressBase):
@extend_schema(
tags=["enrollments"],
summary="Mark a lesson complete",
description="Marks the lesson complete for the current student. Idempotent. Auto-marks the enrollment as completed when all published lessons are done.",
request=None,
responses={200: LessonProgressResponseSerializer},
)
def post(self, request, lesson_id):
from django.utils import timezone
enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id)
course_just_completed = False
with transaction.atomic():
progress, _ = LessonProgress.objects.get_or_create(
enrollment=enrollment, lesson=lesson
)
newly_completed = progress.completed_at is None
if newly_completed:
progress.completed_at = timezone.now()
progress.save(update_fields=["completed_at"])
was_completed_before = enrollment.completed_at is not None
enrollment.maybe_mark_completed()
course_just_completed = (not was_completed_before) and enrollment.completed_at is not None
if newly_completed:
record_event(
course=enrollment.course,
actor=request.user,
kind=ActivityKind.LESSON_COMPLETED.value,
payload={"lesson_id": lesson.id, "lesson_title": lesson.title},
)
if course_just_completed:
record_event(
course=enrollment.course,
actor=request.user,
kind=ActivityKind.COURSE_COMPLETED.value,
payload={},
)
return Response(LessonProgressResponseSerializer(progress).data)
class LessonUncompleteView(_LessonProgressBase):
@extend_schema(
tags=["enrollments"],
summary="Un-mark a lesson complete",
description="Reverses a previous mark-complete. Also clears `enrollment.completed_at` if the course was previously fully completed.",
request=None,
responses={
200: LessonProgressResponseSerializer,
404: OpenApiResponse(description="No progress record exists for this lesson."),
},
)
def post(self, request, lesson_id):
enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id)
try:
progress = LessonProgress.objects.get(enrollment=enrollment, lesson=lesson)
except LessonProgress.DoesNotExist:
return Response({"detail": "No progress yet."}, status=status.HTTP_404_NOT_FOUND)
if progress.completed_at is not None:
progress.completed_at = None
progress.save(update_fields=["completed_at"])
enrollment.maybe_unmark_completed()
return Response(LessonProgressResponseSerializer(progress).data)
# --- instructor surface ---------------------------------------------------------
class CourseEnrollmentsView(APIView):
"""Instructor view of the students enrolled in one of their courses."""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["enrollments"],
summary="List students enrolled in my course",
description=(
"Instructor-only. Owner of the course only. "
"Filter `?engagement_status=active|at_risk|inactive|completed` to scope the CRM table."
),
responses={
200: InstructorEnrollmentSerializer(many=True),
403: OpenApiResponse(description="Not the course owner."),
},
)
def get(self, request, course_id):
course = get_object_or_404(Course, pk=course_id)
if course.instructor_id != request.user.id:
raise PermissionDenied("You don't own this course.")
qs = (
Enrollment.objects
.filter(course=course)
.select_related("student")
.order_by("-enrolled_at")
)
engagement_filter = request.query_params.get("engagement_status")
rows = InstructorEnrollmentSerializer(qs, many=True).data
if engagement_filter:
rows = [r for r in rows if r["engagement_status"] == engagement_filter]
return Response(rows)

7
apps/enrollments/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class EnrollmentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.enrollments"
label = "enrollments"

View File

@ -0,0 +1,66 @@
# Generated by Django 5.1.15 on 2026-05-02 15:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('courses', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Enrollment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enrolled_at', models.DateTimeField(auto_now_add=True, verbose_name='enrolled at')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='courses.course')),
('student', models.ForeignKey(limit_choices_to={'role': 'student'}, on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'enrollment',
'verbose_name_plural': 'enrollments',
'ordering': ['-enrolled_at'],
},
),
migrations.CreateModel(
name='LessonProgress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='completed at')),
('enrollment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_progress', to='enrollments.enrollment')),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_records', to='courses.lesson')),
],
options={
'verbose_name': 'lesson progress',
'verbose_name_plural': 'lesson progress',
},
),
migrations.AddIndex(
model_name='enrollment',
index=models.Index(fields=['student', '-enrolled_at'], name='enrollments_student_dfac97_idx'),
),
migrations.AddIndex(
model_name='enrollment',
index=models.Index(fields=['course', '-enrolled_at'], name='enrollments_course__c1239d_idx'),
),
migrations.AddConstraint(
model_name='enrollment',
constraint=models.UniqueConstraint(fields=('student', 'course'), name='unique_enrollment_per_student_course'),
),
migrations.AddIndex(
model_name='lessonprogress',
index=models.Index(fields=['enrollment', 'lesson'], name='enrollments_enrollm_a65958_idx'),
),
migrations.AddConstraint(
model_name='lessonprogress',
constraint=models.UniqueConstraint(fields=('enrollment', 'lesson'), name='unique_progress_per_enrollment_lesson'),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.1.15 on 2026-05-02 16:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0003_course_is_coming_soon_lesson_is_downloadable'),
('enrollments', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ActivityEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('enrolled', 'Enrolled'), ('paid', 'Order paid'), ('lesson_completed', 'Lesson completed'), ('course_completed', 'Course completed'), ('announcement_posted', 'Announcement posted')], max_length=32, verbose_name='kind')),
('payload', models.JSONField(blank=True, default=dict, verbose_name='payload')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_events', to=settings.AUTH_USER_MODEL)),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_events', to='courses.course')),
],
options={
'verbose_name': 'activity event',
'verbose_name_plural': 'activity events',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['course', '-created_at'], name='enrollments_course__73d051_idx'), models.Index(fields=['actor', '-created_at'], name='enrollments_actor_i_34ace1_idx'), models.Index(fields=['kind', '-created_at'], name='enrollments_kind_b92b1b_idx')],
},
),
]

View File

161
apps/enrollments/models.py Normal file
View File

@ -0,0 +1,161 @@
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class EngagementStatus(models.TextChoices):
ACTIVE = "active", _("Active")
AT_RISK = "at_risk", _("At risk")
INACTIVE = "inactive", _("Inactive")
COMPLETED = "completed", _("Completed")
class Enrollment(models.Model):
student = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="enrollments",
limit_choices_to={"role": "student"},
)
course = models.ForeignKey(
"courses.Course",
on_delete=models.CASCADE,
related_name="enrollments",
)
enrolled_at = models.DateTimeField(_("enrolled at"), auto_now_add=True)
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
class Meta:
verbose_name = _("enrollment")
verbose_name_plural = _("enrollments")
ordering = ["-enrolled_at"]
constraints = [
models.UniqueConstraint(fields=["student", "course"], name="unique_enrollment_per_student_course"),
]
indexes = [
models.Index(fields=["student", "-enrolled_at"]),
models.Index(fields=["course", "-enrolled_at"]),
]
def __str__(self):
return f"{self.student_id}{self.course_id}"
@property
def is_completed(self) -> bool:
return self.completed_at is not None
def total_lessons_count(self) -> int:
from apps.courses.models import Lesson, LessonStatus
return Lesson.objects.filter(
section__course_id=self.course_id, status=LessonStatus.PUBLISHED
).count()
def completed_lessons_count(self) -> int:
return self.lesson_progress.filter(completed_at__isnull=False).count()
def progress_percent(self) -> int:
total = self.total_lessons_count()
if total == 0:
return 0
return int(round(self.completed_lessons_count() * 100 / total))
def maybe_mark_completed(self):
total = self.total_lessons_count()
if total > 0 and self.completed_lessons_count() >= total and self.completed_at is None:
self.completed_at = timezone.now()
self.save(update_fields=["completed_at"])
def maybe_unmark_completed(self):
if self.completed_at is not None and self.completed_lessons_count() < self.total_lessons_count():
self.completed_at = None
self.save(update_fields=["completed_at"])
def engagement_status(self) -> str:
"""Compute engagement bucket based on completion + student's last activity.
Buckets:
- completed enrollment.completed_at is set
- active student active within last 7 days and enrollment isn't completed
- at_risk student inactive 7-14 days OR low progress (<20%) and inactive >7 days
- inactive student last active >14 days ago
Falls back to `active` for brand-new enrollments where last_active_at is null.
"""
if self.completed_at is not None:
return EngagementStatus.COMPLETED.value
last = getattr(self.student, "last_active_at", None)
if last is None:
return EngagementStatus.ACTIVE.value
delta = timezone.now() - last
if delta < timedelta(days=7):
return EngagementStatus.ACTIVE.value
if delta < timedelta(days=14):
return EngagementStatus.AT_RISK.value
return EngagementStatus.INACTIVE.value
# ---------------------------------------------------------------------------
# Activity events — power the instructor's home activity feed and student
# detail timelines. Recorded explicitly via `services.events.record_event`.
class ActivityKind(models.TextChoices):
ENROLLED = "enrolled", _("Enrolled")
PAID = "paid", _("Order paid")
LESSON_COMPLETED = "lesson_completed", _("Lesson completed")
COURSE_COMPLETED = "course_completed", _("Course completed")
ANNOUNCEMENT_POSTED = "announcement_posted", _("Announcement posted")
class ActivityEvent(models.Model):
course = models.ForeignKey(
"courses.Course", on_delete=models.CASCADE, related_name="activity_events"
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="activity_events",
)
kind = models.CharField(_("kind"), max_length=32, choices=ActivityKind.choices)
payload = models.JSONField(_("payload"), default=dict, blank=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
verbose_name = _("activity event")
verbose_name_plural = _("activity events")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["course", "-created_at"]),
models.Index(fields=["actor", "-created_at"]),
models.Index(fields=["kind", "-created_at"]),
]
def __str__(self):
return f"{self.kind}@{self.course_id}#{self.pk}"
class LessonProgress(models.Model):
enrollment = models.ForeignKey(
Enrollment, on_delete=models.CASCADE, related_name="lesson_progress"
)
lesson = models.ForeignKey(
"courses.Lesson", on_delete=models.CASCADE, related_name="progress_records"
)
completed_at = models.DateTimeField(_("completed at"), null=True, blank=True)
class Meta:
verbose_name = _("lesson progress")
verbose_name_plural = _("lesson progress")
constraints = [
models.UniqueConstraint(
fields=["enrollment", "lesson"],
name="unique_progress_per_enrollment_lesson",
),
]
indexes = [models.Index(fields=["enrollment", "lesson"])]
def __str__(self):
return f"progress<{self.enrollment_id}:{self.lesson_id}>"

View File

View File

@ -0,0 +1,17 @@
"""Helper for recording activity events from views/services."""
from __future__ import annotations
from typing import Optional
from apps.enrollments.models import ActivityEvent, ActivityKind
def record_event(*, course, actor, kind: str, payload: Optional[dict] = None) -> ActivityEvent:
"""Create an ActivityEvent. `kind` must be one of ActivityKind.values."""
return ActivityEvent.objects.create(
course=course,
actor=actor,
kind=kind,
payload=payload or {},
)

524
apps/enrollments/tests.py Normal file
View File

@ -0,0 +1,524 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from datetime import timedelta
from django.utils import timezone
from apps.accounts.models import Role, TeacherProfile
from apps.announcements.models import Announcement
from apps.courses.models import (
Course,
CourseStatus,
Lesson,
LessonKind,
LessonStatus,
Section,
)
from apps.enrollments.models import (
ActivityEvent,
ActivityKind,
EngagementStatus,
Enrollment,
LessonProgress,
)
from apps.payments.models import Gateway, Order, OrderStatus
User = get_user_model()
def _teacher(phone="09120000001", approved=True):
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved)
return u
def _student(phone="09120000099", name="Student"):
return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT)
def _course_with_lessons(instructor, *, n_lessons=3, published=True):
course = Course.objects.create(
instructor=instructor, title="C", tagline="T", description="D",
level="beginner", language="fa",
)
if published:
course.publish()
sec = Section.objects.create(course=course, title="S1", order=0)
lessons = []
for i in range(n_lessons):
l = Lesson.objects.create(
section=sec,
title=f"L{i+1}",
kind=LessonKind.VIDEO,
video_url=f"https://e.com/{i}",
status=LessonStatus.PUBLISHED,
order=i,
)
lessons.append(l)
return course, sec, lessons
# ======================================================================== ENROLL
class EnrollAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, _ = _course_with_lessons(self.teacher)
def test_anon_blocked(self):
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 401)
def test_teacher_cannot_enroll(self):
self.client.force_authenticate(self.teacher)
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 403)
def test_student_enrolls_201_then_200(self):
self.client.force_authenticate(self.student)
r1 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r1.status_code, 201, r1.content)
self.assertTrue(Enrollment.objects.filter(student=self.student, course=self.course).exists())
r2 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r2.status_code, 200) # idempotent
self.assertEqual(Enrollment.objects.filter(student=self.student, course=self.course).count(), 1)
def test_enroll_404_for_unpublished(self):
draft, _, _ = _course_with_lessons(self.teacher, published=False)
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/courses/{draft.slug}/enroll/")
self.assertEqual(r.status_code, 404)
# ======================================================================== MY COURSES
class MyCoursesAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
def test_my_courses_lists_progress_and_next_lesson(self):
course, sec, lessons = _course_with_lessons(self.teacher, n_lessons=4)
e = Enrollment.objects.create(student=self.student, course=course)
# complete the first 2 lessons
from django.utils import timezone
for l in lessons[:2]:
LessonProgress.objects.create(
enrollment=e, lesson=l, completed_at=timezone.now()
)
self.client.force_authenticate(self.student)
r = self.client.get("/api/me/courses/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 1)
row = body[0]
self.assertEqual(row["progress_percent"], 50)
self.assertEqual(row["completed_lessons"], 2)
self.assertEqual(row["total_lessons"], 4)
self.assertEqual(row["last_lesson_id"], lessons[1].id)
self.assertEqual(row["next_lesson_id"], lessons[2].id)
class MyCourseDetailAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, self.sec, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
def test_403_when_not_enrolled(self):
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
self.assertEqual(r.status_code, 403)
def test_full_content_unlocked_when_enrolled(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body["sections"]), 1)
lessons = body["sections"][0]["lessons"]
# both lessons present, with full video_url (no preview gating)
urls = [l["video_url"] for l in lessons]
self.assertEqual(urls, ["https://e.com/0", "https://e.com/1"])
# enrollment block
self.assertEqual(body["enrollment"]["progress_percent"], 0)
# ======================================================================== PROGRESS
class LessonProgressAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=3)
self.enrollment = Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
def test_complete_lesson(self):
l = self.lessons[0]
r = self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertIsNotNone(body["completed_at"])
self.assertEqual(body["progress_percent"], 33)
self.assertFalse(body["course_completed"])
self.assertTrue(
LessonProgress.objects.filter(enrollment=self.enrollment, lesson=l).exists()
)
def test_complete_idempotent(self):
l = self.lessons[0]
self.client.post(f"/api/me/lessons/{l.id}/complete/")
first_completed = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
self.client.post(f"/api/me/lessons/{l.id}/complete/")
second = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
self.assertEqual(first_completed, second)
self.assertEqual(
LessonProgress.objects.filter(enrollment=self.enrollment).count(), 1
)
def test_completing_all_marks_enrollment_completed(self):
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.enrollment.refresh_from_db()
self.assertIsNotNone(self.enrollment.completed_at)
def test_uncomplete_clears_enrollment_completion(self):
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.enrollment.refresh_from_db()
self.assertIsNotNone(self.enrollment.completed_at)
# un-complete one
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/uncomplete/")
self.assertEqual(r.status_code, 200)
self.enrollment.refresh_from_db()
self.assertIsNone(self.enrollment.completed_at)
def test_progress_for_lesson_in_course_user_is_not_enrolled_in(self):
other_teacher = _teacher(phone="09120000050")
other_course, _, other_lessons = _course_with_lessons(other_teacher)
r = self.client.post(f"/api/me/lessons/{other_lessons[0].id}/complete/")
self.assertEqual(r.status_code, 403)
# ======================================================================== INSTRUCTOR ENROLLMENTS
class InstructorEnrollmentsTests(TestCase):
def test_instructor_sees_their_courses_enrollments(self):
client = APIClient()
teacher = _teacher()
s1 = _student(phone="09120000091")
s2 = _student(phone="09120000092")
course, _, _ = _course_with_lessons(teacher)
Enrollment.objects.create(student=s1, course=course)
Enrollment.objects.create(student=s2, course=course)
client.force_authenticate(teacher)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 200, r.content)
self.assertEqual(len(r.json()), 2)
def test_instructor_cannot_see_others_enrollments(self):
client = APIClient()
owner = _teacher(phone="09120000010")
intruder = _teacher(phone="09120000011")
course, _, _ = _course_with_lessons(owner)
Enrollment.objects.create(student=_student(), course=course)
client.force_authenticate(intruder)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 403)
# ======================================================================== ANNOUNCEMENTS
class AnnouncementAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, _ = _course_with_lessons(self.teacher)
def test_instructor_creates_announcement(self):
self.client.force_authenticate(self.teacher)
r = self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Welcome!"},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
a = Announcement.objects.get(course=self.course)
self.assertEqual(a.posted_by, self.teacher)
def test_instructor_cannot_post_on_others_course(self):
intruder = _teacher(phone="09120000050")
self.client.force_authenticate(intruder)
r = self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Sneaky"},
format="json",
)
self.assertEqual(r.status_code, 403)
def test_enrolled_student_reads_announcements(self):
Enrollment.objects.create(student=self.student, course=self.course)
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["body"], "Hello")
def test_non_enrolled_student_blocked(self):
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
self.assertEqual(r.status_code, 403)
# ============================================================ ENGAGEMENT STATUS
class EngagementStatusTests(TestCase):
def setUp(self):
self.teacher = _teacher()
self.course, _, _ = _course_with_lessons(self.teacher)
def _enroll(self, student, completed=False):
e = Enrollment.objects.create(student=student, course=self.course)
if completed:
e.completed_at = timezone.now()
e.save()
return e
def test_completed_overrides_activity(self):
s = _student(phone="09120000010")
e = self._enroll(s, completed=True)
self.assertEqual(e.engagement_status(), EngagementStatus.COMPLETED.value)
def test_active_when_recent_activity(self):
s = _student(phone="09120000011")
s.last_active_at = timezone.now() - timedelta(days=2)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
def test_at_risk_after_7_days(self):
s = _student(phone="09120000012")
s.last_active_at = timezone.now() - timedelta(days=10)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.AT_RISK.value)
def test_inactive_after_14_days(self):
s = _student(phone="09120000013")
s.last_active_at = timezone.now() - timedelta(days=20)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.INACTIVE.value)
def test_null_last_active_treated_as_active(self):
s = _student(phone="09120000014")
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
# ============================================================ ACTIVITY EVENTS
class ActivityEventRecordingTests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
def test_free_enrollment_records_event(self):
# the course was created with paid_course default, but _course_with_lessons doesn't set price
# so it's free
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 201)
events = ActivityEvent.objects.filter(course=self.course, kind=ActivityKind.ENROLLED.value)
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().payload["via"], "free")
def test_lesson_completion_records_event(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/complete/")
self.assertEqual(r.status_code, 200)
e = ActivityEvent.objects.filter(kind=ActivityKind.LESSON_COMPLETED.value).first()
self.assertIsNotNone(e)
self.assertEqual(e.payload["lesson_id"], self.lessons[0].id)
def test_completing_all_lessons_records_course_completed(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
completed_events = ActivityEvent.objects.filter(
kind=ActivityKind.COURSE_COMPLETED.value
)
self.assertEqual(completed_events.count(), 1)
def test_announcement_post_records_event(self):
self.client.force_authenticate(self.teacher)
self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Welcome"},
format="json",
)
e = ActivityEvent.objects.filter(kind=ActivityKind.ANNOUNCEMENT_POSTED.value).first()
self.assertIsNotNone(e)
self.assertIn("Welcome", e.payload["preview"])
def test_paid_checkout_records_enrolled_and_paid(self):
from django.test import override_settings
paid = Course.objects.create(
instructor=self.teacher, title="Paid", tagline="T", description="D",
level="beginner", language="fa", price_toman=1000,
)
paid.publish()
with override_settings(PAYMENT_BACKEND="apps.payments.services.gateway.ConsolePaymentBackend"):
self.client.force_authenticate(self.student)
co = self.client.post(f"/api/courses/{paid.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
kinds = list(
ActivityEvent.objects.filter(course=paid).values_list("kind", flat=True)
)
self.assertIn(ActivityKind.ENROLLED.value, kinds)
self.assertIn(ActivityKind.PAID.value, kinds)
# ============================================================ ANALYTICS ENDPOINTS
class InstructorAnalyticsTests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=4)
def test_kpis_blocked_for_non_teacher(self):
self.client.force_authenticate(self.student)
r = self.client.get("/api/instructor/kpis/")
self.assertEqual(r.status_code, 403)
def test_kpis_returns_four_cards(self):
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
ids = [c["id"] for c in body]
self.assertEqual(set(ids), {"revenue", "active_students", "avg_progress", "satisfaction"})
for card in body:
self.assertIn("value", card)
self.assertIn("spark", card)
self.assertEqual(len(card["spark"]), 12)
def test_kpi_revenue_reflects_paid_orders(self):
# Create a paid order this month
Order.objects.create(
student=self.student, course=self.course,
amount_toman=1_000_000, ilo_fee_toman=150_000, instructor_share_toman=850_000,
commission_percent_snapshot=15, gateway=Gateway.CONSOLE,
status=OrderStatus.PAID, paid_at=timezone.now(),
)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
revenue_card = next(c for c in r.json() if c["id"] == "revenue")
self.assertEqual(revenue_card["value"], 850_000)
def test_kpi_avg_progress_from_enrollments(self):
# one enrollment with 50% progress
e = Enrollment.objects.create(student=self.student, course=self.course)
for l in self.lessons[:2]:
LessonProgress.objects.create(
enrollment=e, lesson=l, completed_at=timezone.now()
)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
avg_card = next(c for c in r.json() if c["id"] == "avg_progress")
self.assertEqual(avg_card["value"], 50)
def test_monthly_revenue_returns_12_months(self):
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/revenue/monthly/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 12)
for row in body:
self.assertIn("month", row)
self.assertIn("revenue_toman", row)
self.assertIn("orders_count", row)
def test_activity_feed_lists_events_for_my_courses_only(self):
# make 2 events for my course
from apps.enrollments.services.events import record_event
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
# make 1 event on someone else's course
other_t = _teacher(phone="09120000050")
other_course, _, _ = _course_with_lessons(other_t)
record_event(course=other_course, actor=self.student, kind=ActivityKind.ENROLLED.value)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/activity/")
self.assertEqual(r.status_code, 200)
results = r.json()["results"]
self.assertEqual(len(results), 2)
self.assertTrue(all(row["course"] == self.course.id for row in results))
def test_activity_feed_filter_by_kind(self):
from apps.enrollments.services.events import record_event
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/activity/?kind=enrolled")
results = r.json()["results"]
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["kind"], "enrolled")
# ============================================================ ENGAGEMENT FILTER
class CourseEnrollmentsCRMTests(TestCase):
def test_engagement_status_in_payload_and_filter(self):
client = APIClient()
teacher = _teacher()
course, _, _ = _course_with_lessons(teacher)
# an active student
a = _student(phone="09120000091")
a.last_active_at = timezone.now()
a.save()
Enrollment.objects.create(student=a, course=course)
# an at-risk student
r_student = _student(phone="09120000092")
r_student.last_active_at = timezone.now() - timedelta(days=10)
r_student.save()
Enrollment.objects.create(student=r_student, course=course)
client.force_authenticate(teacher)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 200)
rows = r.json()
statuses = sorted(row["engagement_status"] for row in rows)
self.assertEqual(statuses, ["active", "at_risk"])
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/?engagement_status=at_risk")
rows = r.json()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["student_id"], r_student.id)

View File

55
apps/payments/admin.py Normal file
View File

@ -0,0 +1,55 @@
from django.contrib import admin
from .models import Order, PromoCode, Referral
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = (
"id",
"student",
"course",
"base_amount_toman",
"discount_amount_toman",
"amount_toman",
"instructor_share_toman",
"gateway",
"status",
"created_at",
"paid_at",
)
list_filter = ("status", "gateway", "course")
search_fields = ("student__phone_number", "course__slug", "authority", "ref_id", "promo_code__code")
raw_id_fields = ("student", "course", "promo_code", "referrer")
readonly_fields = (
"base_amount_toman",
"discount_amount_toman",
"amount_toman",
"ilo_fee_toman",
"instructor_share_toman",
"commission_percent_snapshot",
"gateway",
"authority",
"ref_id",
"created_at",
"paid_at",
"failed_at",
"last_error",
)
date_hierarchy = "created_at"
@admin.register(PromoCode)
class PromoCodeAdmin(admin.ModelAdmin):
list_display = ("code", "discount_percent", "discount_amount_toman", "course", "used_count", "max_uses", "is_active", "valid_until")
list_filter = ("is_active", "course")
search_fields = ("code",)
raw_id_fields = ("course",)
@admin.register(Referral)
class ReferralAdmin(admin.ModelAdmin):
list_display = ("referrer", "buyer", "course", "buyer_discount_toman", "referrer_credit_toman", "settled_at")
search_fields = ("referrer__phone_number", "buyer__phone_number", "course__slug")
raw_id_fields = ("referrer", "buyer", "order", "course")
readonly_fields = ("settled_at",)

View File

View File

@ -0,0 +1,72 @@
"""Validate-promo endpoint — gives the frontend a preview of price + discount."""
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.courses.models import Course, CourseStatus
from apps.enrollments.api.permissions import IsStudent
from apps.payments.services.orders import OrderError, _resolve_discount
class PromoPreviewRequestSerializer(serializers.Serializer):
promo_code = serializers.CharField(required=False, allow_blank=True)
referral_code = serializers.CharField(required=False, allow_blank=True)
class PromoPreviewResponseSerializer(serializers.Serializer):
course_slug = serializers.CharField()
base_amount_toman = serializers.IntegerField()
discount_amount_toman = serializers.IntegerField()
final_amount_toman = serializers.IntegerField()
promo_code_applied = serializers.CharField(allow_null=True)
referrer_code_applied = serializers.CharField(allow_null=True)
class CheckoutPreviewView(APIView):
"""`POST /api/courses/<slug>/checkout/preview/` — dry-run price computation."""
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["payments"],
summary="Preview checkout price (apply promo / referral code without committing)",
request=PromoPreviewRequestSerializer,
responses={
200: PromoPreviewResponseSerializer,
400: OpenApiResponse(description="Code invalid / not applicable / both supplied / course is free."),
404: OpenApiResponse(description="Course not found or not published."),
},
)
def post(self, request, slug):
course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED)
if course.is_free:
return Response(
{"detail": "Course is free; no checkout needed."},
status=400,
)
ser = PromoPreviewRequestSerializer(data=request.data)
ser.is_valid(raise_exception=True)
base = int(course.price_toman or 0)
try:
discount, pc_obj, referrer, _credit = _resolve_discount(
course, base,
promo_code=ser.validated_data.get("promo_code") or None,
referral_code=ser.validated_data.get("referral_code") or None,
buyer=request.user,
)
except OrderError as e:
return Response({"detail": str(e)}, status=400)
final = max(0, base - discount)
body = {
"course_slug": course.slug,
"base_amount_toman": base,
"discount_amount_toman": discount,
"final_amount_toman": final,
"promo_code_applied": pc_obj.code if pc_obj else None,
"referrer_code_applied": referrer.referral_code if referrer else None,
}
return Response(PromoPreviewResponseSerializer(body).data)

View File

@ -0,0 +1,70 @@
from rest_framework import serializers
from apps.payments.models import Order
class CheckoutResponseSerializer(serializers.Serializer):
order_id = serializers.IntegerField()
redirect_url = serializers.URLField()
amount_toman = serializers.IntegerField()
gateway = serializers.CharField()
class OrderSerializer(serializers.ModelSerializer):
course_id = serializers.IntegerField(read_only=True)
course_slug = serializers.CharField(source="course.slug", read_only=True)
course_title = serializers.CharField(source="course.title", read_only=True)
promo_code = serializers.CharField(source="promo_code.code", read_only=True, allow_null=True)
referrer_code = serializers.CharField(source="referrer.referral_code", read_only=True, allow_null=True)
class Meta:
model = Order
fields = [
"id",
"course_id",
"course_slug",
"course_title",
"base_amount_toman",
"discount_amount_toman",
"amount_toman",
"ilo_fee_toman",
"instructor_share_toman",
"commission_percent_snapshot",
"gateway",
"ref_id",
"status",
"created_at",
"paid_at",
"failed_at",
"last_error",
"promo_code",
"referrer_code",
]
read_only_fields = fields
class InstructorSaleSerializer(serializers.ModelSerializer):
"""Sale entry shown in the instructor's revenue / orders panel."""
course_id = serializers.IntegerField(read_only=True)
course_title = serializers.CharField(source="course.title", read_only=True)
student_id = serializers.IntegerField(source="student.id", read_only=True)
student_name = serializers.CharField(source="student.full_name", read_only=True)
class Meta:
model = Order
fields = [
"id",
"course_id",
"course_title",
"student_id",
"student_name",
"amount_toman",
"ilo_fee_toman",
"instructor_share_toman",
"commission_percent_snapshot",
"status",
"created_at",
"paid_at",
]
read_only_fields = fields

27
apps/payments/api/urls.py Normal file
View File

@ -0,0 +1,27 @@
from django.urls import path
from .preview import CheckoutPreviewView
from .views import (
CheckoutView,
GatewayCallbackView,
InstructorSalesView,
MyOrderDetailView,
MyOrdersView,
)
urlpatterns = [
path("courses/<slug:slug>/checkout/", CheckoutView.as_view(), name="checkout"),
path(
"courses/<slug:slug>/checkout/preview/",
CheckoutPreviewView.as_view(),
name="checkout-preview",
),
path(
"payments/callback/<str:gateway>/",
GatewayCallbackView.as_view(),
name="payment-callback",
),
path("me/orders/", MyOrdersView.as_view(), name="my-orders"),
path("me/orders/<int:pk>/", MyOrderDetailView.as_view(), name="my-order-detail"),
path("instructor/orders/", InstructorSalesView.as_view(), name="instructor-orders"),
]

215
apps/payments/api/views.py Normal file
View File

@ -0,0 +1,215 @@
from urllib.parse import urlencode
from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.courses.models import Course, CourseStatus
from apps.enrollments.api.permissions import IsStudent
from apps.payments.models import Order, OrderStatus
from apps.payments.services.gateway import PaymentGatewayError
from apps.payments.services.orders import (
OrderError,
complete_or_fail,
start_checkout,
)
from .serializers import (
CheckoutResponseSerializer,
InstructorSaleSerializer,
OrderSerializer,
)
class CheckoutView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["payments"],
summary="Start checkout for a paid course",
description=(
"Creates a pending Order, opens an intent on the configured payment gateway, "
"and returns the URL the user's browser must be redirected to in order to pay. "
"After the user finishes (or abandons) at the gateway, they are bounced back to "
"`/api/payments/callback/<gateway>/`, which verifies the payment, marks the order "
"paid, and creates the Enrollment."
),
request=None,
responses={
201: CheckoutResponseSerializer,
400: OpenApiResponse(description="Course is free, or you're already enrolled."),
402: OpenApiResponse(description="Gateway refused the request (auto-failed order)."),
403: OpenApiResponse(description="Not a student."),
404: OpenApiResponse(description="Course not found or not published."),
},
)
def post(self, request, slug):
course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED)
promo = (request.data or {}).get("promo_code") if hasattr(request, "data") else None
ref = (request.data or {}).get("referral_code") if hasattr(request, "data") else None
try:
order, redirect_url = start_checkout(
request.user, course, promo_code=promo, referral_code=ref,
)
except OrderError as e:
raise ValidationError({"detail": str(e)})
except PaymentGatewayError as e:
return Response(
{"detail": "Could not start payment", "error": str(e)},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
body = {
"order_id": order.pk,
"redirect_url": redirect_url,
"amount_toman": order.amount_toman,
"gateway": order.gateway,
}
return Response(CheckoutResponseSerializer(body).data, status=status.HTTP_201_CREATED)
class GatewayCallbackView(APIView):
"""Public endpoint the gateway redirects the browser back to after a payment.
Always responds with an HTTP redirect to the configured success/failure URL
those are frontend pages, not API endpoints.
"""
permission_classes = [AllowAny]
authentication_classes = [] # gateway-redirected; no JWT in flight
@extend_schema(
tags=["payments"],
summary="Gateway callback (server-to-browser-to-server)",
description=(
"ZarinPal (or the configured gateway) redirects the user's browser here after they "
"complete or cancel payment. We look up the Order by `order_id`, verify with the "
"gateway using the stored `authority`, mark the order as paid or failed, create the "
"Enrollment on success, and finally HTTP-redirect the browser to the configured "
"success or failure URL."
),
parameters=[
OpenApiParameter(name="order_id", required=True, type=int, location=OpenApiParameter.QUERY),
OpenApiParameter(name="Authority", required=False, type=str, location=OpenApiParameter.QUERY),
OpenApiParameter(name="Status", required=False, type=str, location=OpenApiParameter.QUERY),
],
responses={302: OpenApiResponse(description="Redirect to success/failure URL.")},
)
def get(self, request, gateway):
order_id = request.GET.get("order_id")
if not order_id:
return self._fail_redirect("missing order_id")
gateway_status = request.GET.get("Status", "")
with transaction.atomic():
try:
order = Order.objects.select_for_update().get(pk=order_id, gateway=gateway)
except Order.DoesNotExist:
return self._fail_redirect("unknown order")
ok, msg = complete_or_fail(order, gateway_status)
if ok:
return self._success_redirect(order)
return self._fail_redirect(msg, order=order)
@staticmethod
def _success_redirect(order: Order):
url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success")
qs = urlencode({"order_id": order.pk, "ref_id": order.ref_id or ""})
return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}")
@staticmethod
def _fail_redirect(reason: str, order: Order | None = None):
url = getattr(settings, "PAYMENT_FAILURE_REDIRECT_URL", "/payment/failed")
params = {"reason": reason}
if order is not None:
params["order_id"] = order.pk
qs = urlencode(params)
return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}")
class MyOrdersView(ListAPIView):
permission_classes = [IsAuthenticated, IsStudent]
serializer_class = OrderSerializer
pagination_class = None
@extend_schema(
tags=["payments"],
summary="My order history",
description="Lists the current student's orders (any status), most recent first.",
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return (
Order.objects.filter(student=self.request.user)
.select_related("course")
.order_by("-created_at")
)
class MyOrderDetailView(RetrieveAPIView):
"""One Order's full receipt — used by the checkout success page."""
permission_classes = [IsAuthenticated, IsStudent]
serializer_class = OrderSerializer
lookup_field = "pk"
@extend_schema(
tags=["payments"],
summary="Order detail (receipt)",
description=(
"Returns the order's amount, fees, instructor share, gateway, ref id, status and "
"course summary. Owner-only — students can only fetch their own orders."
),
responses={
200: OrderSerializer,
404: OpenApiResponse(description="Not found or not owned."),
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return (
Order.objects.filter(student=self.request.user)
.select_related("course")
)
class InstructorSalesView(ListAPIView):
"""Instructor's paid sales across all their courses."""
permission_classes = [IsAuthenticated]
serializer_class = InstructorSaleSerializer
pagination_class = None
filterset_fields = ["status", "course"]
@extend_schema(
tags=["payments"],
summary="My sales (instructor)",
description=(
"Lists orders for the instructor's own courses. Defaults to all statuses; pass "
"`?status=paid` to filter to settled sales."
),
)
def get(self, request, *args, **kwargs):
from apps.accounts.models import Role
if request.user.role != Role.TEACHER:
raise PermissionDenied("Only teachers can view sales.")
return super().get(request, *args, **kwargs)
def get_queryset(self):
return (
Order.objects.filter(course__instructor=self.request.user)
.select_related("student", "course")
.order_by("-created_at")
)

7
apps/payments/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"
label = "payments"

View File

@ -0,0 +1,44 @@
# Generated by Django 5.1.15 on 2026-05-02 15:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('courses', '0002_course_original_price_toman_course_price_toman'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount_toman', models.PositiveIntegerField(verbose_name='amount (Toman)')),
('ilo_fee_toman', models.PositiveIntegerField(default=0, verbose_name='platform fee (Toman)')),
('instructor_share_toman', models.PositiveIntegerField(default=0, verbose_name='instructor share (Toman)')),
('commission_percent_snapshot', models.PositiveSmallIntegerField(default=0, verbose_name='commission % at order time')),
('gateway', models.CharField(choices=[('console', 'Console (dev)'), ('zarinpal', 'ZarinPal')], max_length=16, verbose_name='gateway')),
('authority', models.CharField(blank=True, db_index=True, max_length=100, verbose_name='authority')),
('ref_id', models.CharField(blank=True, max_length=64, verbose_name='ref ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=16, verbose_name='status')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
('failed_at', models.DateTimeField(blank=True, null=True, verbose_name='failed at')),
('last_error', models.CharField(blank=True, max_length=255, verbose_name='last error')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='courses.course')),
('student', models.ForeignKey(limit_choices_to={'role': 'student'}, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'order',
'verbose_name_plural': 'orders',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['student', '-created_at'], name='payments_or_student_4b23c9_idx'), models.Index(fields=['course', '-created_at'], name='payments_or_course__78a038_idx'), models.Index(fields=['status'], name='payments_or_status_daab12_idx')],
},
),
]

View File

@ -0,0 +1,98 @@
# Generated by Django 5.1.15 on 2026-05-02 16:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0004_course_discount_ends_at_course_discount_starts_at'),
('payments', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='order',
name='base_amount_toman',
field=models.PositiveIntegerField(default=0, help_text='Course price at order time, before any discount.', verbose_name='base amount (Toman)'),
),
migrations.AddField(
model_name='order',
name='discount_amount_toman',
field=models.PositiveIntegerField(default=0, verbose_name='discount applied (Toman)'),
),
migrations.AddField(
model_name='order',
name='referrer',
field=models.ForeignKey(blank=True, help_text='The user whose referral_code was applied at checkout.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referred_orders', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='order',
name='amount_toman',
field=models.PositiveIntegerField(help_text='base_amount - discount; what the buyer actually pays.', verbose_name='charged amount (Toman)'),
),
migrations.AlterField(
model_name='order',
name='gateway',
field=models.CharField(choices=[('console', 'Console (dev)'), ('zarinpal', 'ZarinPal'), ('idpay', 'IDPay'), ('free', 'Free (auto)')], max_length=16, verbose_name='gateway'),
),
migrations.CreateModel(
name='PromoCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=32, unique=True, verbose_name='code')),
('discount_percent', models.PositiveSmallIntegerField(blank=True, help_text='Percentage off (1-100). Mutually exclusive with discount_amount_toman.', null=True, verbose_name='discount %')),
('discount_amount_toman', models.PositiveIntegerField(blank=True, help_text='Fixed amount off. Mutually exclusive with discount_percent.', null=True, verbose_name='discount amount (Toman)')),
('max_uses', models.PositiveIntegerField(blank=True, help_text='Total uses across all users. Null = unlimited.', null=True, verbose_name='max uses')),
('used_count', models.PositiveIntegerField(default=0, verbose_name='used count')),
('valid_from', models.DateTimeField(blank=True, null=True, verbose_name='valid from')),
('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='valid until')),
('is_active', models.BooleanField(default=True, verbose_name='active')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('course', models.ForeignKey(blank=True, help_text='If set, code only applies to this course; otherwise applies to any course.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='promo_codes', to='courses.course')),
],
options={
'verbose_name': 'promo code',
'verbose_name_plural': 'promo codes',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='order',
name='promo_code',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='payments.promocode'),
),
migrations.CreateModel(
name='Referral',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('buyer_discount_toman', models.PositiveIntegerField(default=0, verbose_name='buyer discount (Toman)')),
('referrer_credit_toman', models.PositiveIntegerField(default=0, verbose_name='referrer credit (Toman)')),
('settled_at', models.DateTimeField(auto_now_add=True, verbose_name='settled at')),
('buyer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals_received', to=settings.AUTH_USER_MODEL)),
('course', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals', to='courses.course')),
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='referral', to='payments.order')),
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='referrals_given', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'referral',
'verbose_name_plural': 'referrals',
'ordering': ['-settled_at'],
},
),
migrations.AddIndex(
model_name='promocode',
index=models.Index(fields=['code'], name='payments_pr_code_255a5d_idx'),
),
migrations.AddIndex(
model_name='referral',
index=models.Index(fields=['referrer', '-settled_at'], name='payments_re_referre_16a032_idx'),
),
migrations.AddIndex(
model_name='referral',
index=models.Index(fields=['buyer', '-settled_at'], name='payments_re_buyer_i_779881_idx'),
),
]

View File

196
apps/payments/models.py Normal file
View File

@ -0,0 +1,196 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class OrderStatus(models.TextChoices):
PENDING = "pending", _("Pending")
PAID = "paid", _("Paid")
FAILED = "failed", _("Failed")
CANCELLED = "cancelled", _("Cancelled")
class Gateway(models.TextChoices):
CONSOLE = "console", _("Console (dev)")
ZARINPAL = "zarinpal", _("ZarinPal")
IDPAY = "idpay", _("IDPay")
FREE = "free", _("Free (auto)")
class PromoCode(models.Model):
code = models.CharField(_("code"), max_length=32, unique=True)
discount_percent = models.PositiveSmallIntegerField(
_("discount %"), null=True, blank=True,
help_text=_("Percentage off (1-100). Mutually exclusive with discount_amount_toman."),
)
discount_amount_toman = models.PositiveIntegerField(
_("discount amount (Toman)"), null=True, blank=True,
help_text=_("Fixed amount off. Mutually exclusive with discount_percent."),
)
course = models.ForeignKey(
"courses.Course", on_delete=models.CASCADE, related_name="promo_codes",
null=True, blank=True,
help_text=_("If set, code only applies to this course; otherwise applies to any course."),
)
max_uses = models.PositiveIntegerField(
_("max uses"), null=True, blank=True,
help_text=_("Total uses across all users. Null = unlimited."),
)
used_count = models.PositiveIntegerField(_("used count"), default=0)
valid_from = models.DateTimeField(_("valid from"), null=True, blank=True)
valid_until = models.DateTimeField(_("valid until"), null=True, blank=True)
is_active = models.BooleanField(_("active"), default=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
verbose_name = _("promo code")
verbose_name_plural = _("promo codes")
ordering = ["-created_at"]
indexes = [models.Index(fields=["code"])]
def __str__(self):
return self.code
def save(self, *args, **kwargs):
if self.code:
self.code = self.code.strip().upper()
super().save(*args, **kwargs)
def is_valid_now(self) -> bool:
if not self.is_active:
return False
now = timezone.now()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now >= self.valid_until:
return False
if self.max_uses is not None and self.used_count >= self.max_uses:
return False
if (self.discount_percent or 0) <= 0 and (self.discount_amount_toman or 0) <= 0:
return False
return True
def applies_to_course(self, course) -> bool:
return self.course_id is None or self.course_id == course.id
def discount_for(self, base_amount_toman: int) -> int:
"""Return the Toman discount this code yields on `base_amount_toman`."""
if self.discount_percent:
return min(base_amount_toman, (base_amount_toman * int(self.discount_percent)) // 100)
if self.discount_amount_toman:
return min(base_amount_toman, int(self.discount_amount_toman))
return 0
class Order(models.Model):
student = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="orders",
limit_choices_to={"role": "student"},
)
course = models.ForeignKey(
"courses.Course",
on_delete=models.PROTECT,
related_name="orders",
)
# Money — snapshotted at order creation so price/commission changes don't affect old orders.
base_amount_toman = models.PositiveIntegerField(
_("base amount (Toman)"), default=0,
help_text=_("Course price at order time, before any discount."),
)
discount_amount_toman = models.PositiveIntegerField(
_("discount applied (Toman)"), default=0,
)
amount_toman = models.PositiveIntegerField(
_("charged amount (Toman)"),
help_text=_("base_amount - discount; what the buyer actually pays."),
)
ilo_fee_toman = models.PositiveIntegerField(_("platform fee (Toman)"), default=0)
instructor_share_toman = models.PositiveIntegerField(_("instructor share (Toman)"), default=0)
commission_percent_snapshot = models.PositiveSmallIntegerField(
_("commission % at order time"), default=0
)
promo_code = models.ForeignKey(
PromoCode, on_delete=models.SET_NULL, related_name="orders",
null=True, blank=True,
)
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="referred_orders",
null=True, blank=True,
help_text=_("The user whose referral_code was applied at checkout."),
)
gateway = models.CharField(_("gateway"), max_length=16, choices=Gateway.choices)
authority = models.CharField(_("authority"), max_length=100, blank=True, db_index=True)
ref_id = models.CharField(_("ref ID"), max_length=64, blank=True)
status = models.CharField(
_("status"), max_length=16, choices=OrderStatus.choices, default=OrderStatus.PENDING
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
failed_at = models.DateTimeField(_("failed at"), null=True, blank=True)
last_error = models.CharField(_("last error"), max_length=255, blank=True)
class Meta:
verbose_name = _("order")
verbose_name_plural = _("orders")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["student", "-created_at"]),
models.Index(fields=["course", "-created_at"]),
models.Index(fields=["status"]),
]
def __str__(self):
return f"Order#{self.pk} {self.student_id}{self.course_id} {self.status}"
@property
def is_paid(self) -> bool:
return self.status == OrderStatus.PAID
def mark_paid(self, ref_id: str = ""):
self.status = OrderStatus.PAID
self.paid_at = timezone.now()
if ref_id:
self.ref_id = ref_id
self.save(update_fields=["status", "paid_at", "ref_id"])
def mark_failed(self, error: str = ""):
self.status = OrderStatus.FAILED
self.failed_at = timezone.now()
if error:
self.last_error = error[:255]
self.save(update_fields=["status", "failed_at", "last_error"])
class Referral(models.Model):
"""Settled referral attribution — created when a paid order had a referrer."""
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_given",
)
buyer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_received",
)
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="referral")
course = models.ForeignKey("courses.Course", on_delete=models.PROTECT, related_name="referrals")
buyer_discount_toman = models.PositiveIntegerField(_("buyer discount (Toman)"), default=0)
referrer_credit_toman = models.PositiveIntegerField(_("referrer credit (Toman)"), default=0)
settled_at = models.DateTimeField(_("settled at"), auto_now_add=True)
class Meta:
verbose_name = _("referral")
verbose_name_plural = _("referrals")
ordering = ["-settled_at"]
indexes = [
models.Index(fields=["referrer", "-settled_at"]),
models.Index(fields=["buyer", "-settled_at"]),
]
def __str__(self):
return f"Referral<{self.referrer_id}{self.buyer_id} order#{self.order_id}>"

View File

View File

@ -0,0 +1,274 @@
"""Payment gateway abstraction.
Backends implement two operations:
start(order, callback_url) -> redirect_url
Open a payment intent on the gateway and return the URL to redirect
the user's browser to. May raise PaymentGatewayError.
verify(order) -> (ok: bool, ref_id: str | None, error: str | None)
Confirm the payment after the user has been bounced back to our
callback. The Order's `authority` field must already be populated.
Two backends are bundled:
ConsolePaymentBackend
Dev-only. `start()` returns a URL pointing back to our own callback
view with success query parameters, so the dev can click through and
complete the flow without a real gateway. `verify()` always succeeds.
ZarinPalBackend
Production. Talks to ZarinPal v4 (sandbox or live, toggled by
ZARINPAL_SANDBOX). Lifted from the working Vitron-back implementation
with the cleanups identified in the design review (no hardcoded sandbox
URLs, explicit timeouts, no over-logging of merchant id / payloads).
Selection is via the dotted path in `settings.PAYMENT_BACKEND`.
"""
from __future__ import annotations
import logging
from typing import Optional, Protocol, Tuple
import requests
from django.conf import settings
from django.utils.module_loading import import_string
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- exceptions
class PaymentGatewayError(Exception):
"""Raised when a gateway call fails or returns an unexpected response."""
# ----------------------------------------------------------------------------- protocol
class PaymentBackend(Protocol):
gateway_id: str
def start(self, order, callback_url: str) -> str: ...
def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]: ...
# ----------------------------------------------------------------------------- console (dev)
class ConsolePaymentBackend:
"""Dev backend — fakes the gateway round-trip.
`start()` returns the callback URL with a success token, so the user clicks
a link and lands on our callback view as if they'd successfully paid.
"""
gateway_id = "console"
def start(self, order, callback_url: str) -> str:
# Embed a fake authority + Status=OK so the callback succeeds when the
# browser is pointed here. The authority is what we'll match against
# when the callback runs, so we also store it on the order.
authority = f"CONSOLE-{order.pk}"
order.authority = authority
order.save(update_fields=["authority"])
sep = "&" if "?" in callback_url else "?"
return f"{callback_url}{sep}Authority={authority}&Status=OK"
def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]:
# Always succeed in dev. Pretend ZarinPal gave us a ref_id.
return True, f"console-ref-{order.pk}", None
# ----------------------------------------------------------------------------- ZarinPal
class ZarinPalBackend:
"""Hand-rolled ZarinPal v4 client.
Endpoints:
Request: POST {base}/pg/v4/payment/request.json
Verify: POST {base}/pg/v4/payment/verify.json
Redirect: {pg_base}/pg/StartPay/<authority>
Amounts are stored in **Toman** in our DB; ZarinPal expects **Rial**, so we
multiply by 10 on every API call.
"""
gateway_id = "zarinpal"
TIMEOUT = 15
# API endpoints (sandbox vs production)
SANDBOX_API_BASE = "https://sandbox.zarinpal.com"
LIVE_API_BASE = "https://api.zarinpal.com"
SANDBOX_PG_BASE = "https://sandbox.zarinpal.com"
LIVE_PG_BASE = "https://www.zarinpal.com"
def __init__(self):
self.merchant_id = settings.ZARINPAL_MERCHANT_ID
self.sandbox = bool(getattr(settings, "ZARINPAL_SANDBOX", True))
if not self.merchant_id:
raise PaymentGatewayError("ZARINPAL_MERCHANT_ID is not configured")
# -- internal helpers ---------------------------------------------------
@property
def _api_base(self) -> str:
return self.SANDBOX_API_BASE if self.sandbox else self.LIVE_API_BASE
@property
def _pg_base(self) -> str:
return self.SANDBOX_PG_BASE if self.sandbox else self.LIVE_PG_BASE
def _post(self, path: str, payload: dict) -> dict:
url = f"{self._api_base}{path}"
try:
resp = requests.post(url, json=payload, timeout=self.TIMEOUT)
except requests.RequestException as e:
raise PaymentGatewayError(f"ZarinPal network error: {e}") from e
try:
data = resp.json()
except ValueError as e:
raise PaymentGatewayError(
f"ZarinPal non-JSON response (HTTP {resp.status_code})"
) from e
if not (200 <= resp.status_code < 300):
raise PaymentGatewayError(f"ZarinPal HTTP {resp.status_code}: {data}")
return data
# -- protocol -----------------------------------------------------------
def start(self, order, callback_url: str) -> str:
amount_rial = int(order.amount_toman) * 10
payload = {
"merchant_id": self.merchant_id,
"amount": amount_rial,
"callback_url": callback_url,
"description": f"ilo · {order.course.title or 'course'} (#{order.pk})",
"metadata": {
"mobile": getattr(order.student, "phone_number", "") or "",
},
}
data = self._post("/pg/v4/payment/request.json", payload)
body = data.get("data") or {}
if data.get("errors") or body.get("code") != 100 or not body.get("authority"):
errors = data.get("errors") or body
raise PaymentGatewayError(f"ZarinPal request failed: {errors}")
authority = body["authority"]
order.authority = authority
order.save(update_fields=["authority"])
return f"{self._pg_base}/pg/StartPay/{authority}"
def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]:
if not order.authority:
return False, None, "Order has no authority to verify"
amount_rial = int(order.amount_toman) * 10
payload = {
"merchant_id": self.merchant_id,
"amount": amount_rial,
"authority": order.authority,
}
try:
data = self._post("/pg/v4/payment/verify.json", payload)
except PaymentGatewayError as e:
return False, None, str(e)
body = data.get("data") or {}
code = body.get("code")
# 100 = first-time success, 101 = already verified (treat as success)
if code in (100, 101):
return True, str(body.get("ref_id") or ""), None
errors = data.get("errors") or body
return False, None, f"ZarinPal verify failed: {errors}"
# ----------------------------------------------------------------------------- IDPay
class IDPayBackend:
"""Hand-rolled IDPay v1.1 client.
Endpoints:
Create: POST {base}/v1.1/payment body: {order_id, amount, name, phone, desc, callback}
Verify: POST {base}/v1.1/payment/verify body: {id, order_id}
Redirect: response.link (returned by Create)
Like ZarinPal, amounts go in **Rial** (Toman * 10). Sandbox toggled via the
`X-SANDBOX: 1` header rather than a separate URL.
"""
gateway_id = "idpay"
TIMEOUT = 15
BASE_URL = "https://api.idpay.ir"
def __init__(self):
self.api_key = settings.IDPAY_API_KEY
self.sandbox = bool(getattr(settings, "IDPAY_SANDBOX", True))
if not self.api_key:
raise PaymentGatewayError("IDPAY_API_KEY is not configured")
def _headers(self):
h = {"X-API-KEY": self.api_key, "Content-Type": "application/json"}
if self.sandbox:
h["X-SANDBOX"] = "1"
return h
def _post(self, path: str, payload: dict) -> dict:
url = f"{self.BASE_URL}{path}"
try:
resp = requests.post(url, json=payload, headers=self._headers(), timeout=self.TIMEOUT)
except requests.RequestException as e:
raise PaymentGatewayError(f"IDPay network error: {e}") from e
try:
data = resp.json()
except ValueError as e:
raise PaymentGatewayError(f"IDPay non-JSON response (HTTP {resp.status_code})") from e
if not (200 <= resp.status_code < 300):
raise PaymentGatewayError(f"IDPay HTTP {resp.status_code}: {data}")
return data
def start(self, order, callback_url: str) -> str:
amount_rial = int(order.amount_toman) * 10
payload = {
"order_id": str(order.pk),
"amount": amount_rial,
"name": order.student.full_name or "",
"phone": order.student.phone_number or "",
"desc": f"ilo · {order.course.title or 'course'} (#{order.pk})",
"callback": callback_url,
}
data = self._post("/v1.1/payment", payload)
if not data.get("id") or not data.get("link"):
raise PaymentGatewayError(f"IDPay request failed: {data}")
order.authority = data["id"]
order.save(update_fields=["authority"])
return data["link"]
def verify(self, order) -> Tuple[bool, Optional[str], Optional[str]]:
if not order.authority:
return False, None, "Order has no authority to verify"
payload = {"id": order.authority, "order_id": str(order.pk)}
try:
data = self._post("/v1.1/payment/verify", payload)
except PaymentGatewayError as e:
return False, None, str(e)
status_code = data.get("status")
# 100 = first-time success, 101 = already verified
if status_code in (100, 101):
return True, str(data.get("track_id") or ""), None
return False, None, f"IDPay verify failed: {data}"
# ----------------------------------------------------------------------------- factory
_DEFAULT_BACKEND = "apps.payments.services.gateway.ConsolePaymentBackend"
def get_payment_backend() -> PaymentBackend:
return import_string(getattr(settings, "PAYMENT_BACKEND", _DEFAULT_BACKEND))()

View File

@ -0,0 +1,221 @@
"""Order lifecycle helpers — create, complete, fail."""
from __future__ import annotations
from typing import Optional
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from apps.enrollments.models import Enrollment
from ..models import Gateway, Order, OrderStatus, PromoCode, Referral
from .gateway import PaymentGatewayError, get_payment_backend
class OrderError(Exception):
"""Raised by start_checkout when an order can't be created."""
def _split(amount_toman: int, commission_percent: int) -> tuple[int, int]:
fee = (amount_toman * commission_percent) // 100
instructor_share = amount_toman - fee
return fee, instructor_share
def _resolve_discount(course, base_amount: int, *, promo_code: str | None,
referral_code: str | None, buyer):
"""Return (discount_toman, promo_code_obj, referrer_user, referrer_credit_toman).
Raises OrderError on validation failures."""
from apps.accounts.models import User
if promo_code and referral_code:
raise OrderError("Use either a promo code OR a referral code, not both.")
if promo_code:
code = promo_code.strip().upper()
try:
pc = PromoCode.objects.get(code=code)
except PromoCode.DoesNotExist:
raise OrderError("Promo code not found.")
if not pc.is_valid_now():
raise OrderError("Promo code is not currently valid.")
if not pc.applies_to_course(course):
raise OrderError("Promo code does not apply to this course.")
return pc.discount_for(base_amount), pc, None, 0
if referral_code:
code = referral_code.strip().upper()
try:
referrer = User.objects.get(referral_code=code)
except User.DoesNotExist:
raise OrderError("Referral code not found.")
if buyer is not None and referrer.id == buyer.id:
raise OrderError("You cannot apply your own referral code.")
buyer_pct = int(getattr(settings, "REFERRAL_BUYER_DISCOUNT_PERCENT", 10))
ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10))
buyer_discount = (base_amount * buyer_pct) // 100
referrer_credit = ((base_amount - buyer_discount) * ref_pct) // 100
return buyer_discount, None, referrer, referrer_credit
return 0, None, None, 0
def start_checkout(student, course, *, promo_code: str | None = None,
referral_code: str | None = None) -> tuple[Order, str]:
"""Create a pending Order for `student` on `course` and return (order, redirect_url).
Optionally applies a promo code or a referral code (mutually exclusive).
If the discount drives the amount to 0, the Order is created with
gateway=`free`, immediately marked paid, and the Enrollment is created
`redirect_url` will then be the configured PAYMENT_SUCCESS_REDIRECT_URL with
`order_id` so the frontend can show the receipt.
"""
if course.is_free:
raise OrderError("Course is free; use /api/courses/<slug>/enroll/ instead.")
if Enrollment.objects.filter(student=student, course=course).exists():
raise OrderError("Already enrolled in this course.")
base_amount = int(course.price_toman or 0)
discount, pc_obj, referrer, _referrer_credit = _resolve_discount(
course, base_amount, promo_code=promo_code, referral_code=referral_code, buyer=student,
)
final_amount = max(0, base_amount - discount)
commission_percent = int(getattr(settings, "ILO_COMMISSION_PERCENT", 15))
fee, instructor_share = _split(final_amount, commission_percent)
if final_amount == 0:
# Discount/promo dropped the price to zero — skip the gateway entirely.
with transaction.atomic():
order = Order.objects.create(
student=student,
course=course,
base_amount_toman=base_amount,
discount_amount_toman=discount,
amount_toman=0,
ilo_fee_toman=0,
instructor_share_toman=0,
commission_percent_snapshot=commission_percent,
gateway=Gateway.FREE,
status=OrderStatus.PAID,
paid_at=timezone.now(),
promo_code=pc_obj,
referrer=referrer,
ref_id="free-with-discount",
)
Enrollment.objects.get_or_create(student=student, course=course)
if pc_obj:
PromoCode.objects.filter(pk=pc_obj.pk).update(used_count=pc_obj.used_count + 1)
if referrer:
Referral.objects.create(
referrer=referrer, buyer=student, order=order, course=course,
buyer_discount_toman=discount,
referrer_credit_toman=_referrer_credit,
)
_record_paid_events(order, free_via_discount=True)
success_url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success")
sep = "&" if "?" in success_url else "?"
return order, f"{success_url}{sep}order_id={order.pk}&ref_id={order.ref_id}"
backend = get_payment_backend()
order = Order.objects.create(
student=student,
course=course,
base_amount_toman=base_amount,
discount_amount_toman=discount,
amount_toman=final_amount,
ilo_fee_toman=fee,
instructor_share_toman=instructor_share,
commission_percent_snapshot=commission_percent,
gateway=Gateway(backend.gateway_id),
status=OrderStatus.PENDING,
promo_code=pc_obj,
referrer=referrer,
)
callback_url = (
f"{getattr(settings, 'PAYMENT_CALLBACK_BASE_URL', 'http://localhost:8000').rstrip('/')}"
f"/api/payments/callback/{backend.gateway_id}/?order_id={order.pk}"
)
try:
redirect_url = backend.start(order, callback_url)
except PaymentGatewayError as e:
order.mark_failed(str(e))
raise
return order, redirect_url
def _record_paid_events(order: Order, *, free_via_discount: bool = False):
from apps.enrollments.models import ActivityKind
from apps.enrollments.services.events import record_event
record_event(
course=order.course,
actor=order.student,
kind=ActivityKind.ENROLLED.value,
payload={
"price_toman": order.amount_toman,
"via": "free_with_discount" if free_via_discount else "checkout",
"order_id": order.pk,
},
)
record_event(
course=order.course,
actor=order.student,
kind=ActivityKind.PAID.value,
payload={
"order_id": order.pk,
"amount_toman": order.amount_toman,
"discount_toman": order.discount_amount_toman,
"instructor_share_toman": order.instructor_share_toman,
"ref_id": order.ref_id,
},
)
def complete_or_fail(order: Order, callback_status: str) -> tuple[bool, str]:
"""Idempotently verify a callback. See views.GatewayCallbackView for context."""
if order.status == OrderStatus.PAID:
return True, "already paid"
if order.status not in (OrderStatus.PENDING,):
return False, f"order is in terminal state: {order.status}"
if callback_status and callback_status.upper() != "OK":
order.mark_failed(f"Gateway status: {callback_status}")
return False, "user cancelled or gateway reported failure"
backend = get_payment_backend()
ok, ref_id, error = backend.verify(order)
if not ok:
order.mark_failed(error or "verify failed")
return False, error or "verify failed"
with transaction.atomic():
order.mark_paid(ref_id=ref_id or "")
enrollment, created = Enrollment.objects.get_or_create(
student=order.student, course=order.course
)
# Bump promo usage and settle referral exactly once per Order.
if order.promo_code_id:
PromoCode.objects.filter(pk=order.promo_code_id).update(
used_count=PromoCode.objects.get(pk=order.promo_code_id).used_count + 1
)
if order.referrer_id and not Referral.objects.filter(order=order).exists():
ref_pct = int(getattr(settings, "REFERRAL_REFERRER_CREDIT_PERCENT", 10))
Referral.objects.create(
referrer_id=order.referrer_id,
buyer=order.student,
order=order,
course=order.course,
buyer_discount_toman=order.discount_amount_toman,
referrer_credit_toman=(order.amount_toman * ref_pct) // 100,
)
if created:
_record_paid_events(order)
return True, "paid"

285
apps/payments/tests.py Normal file
View File

@ -0,0 +1,285 @@
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from apps.accounts.models import Role, TeacherProfile
from apps.courses.models import (
Course,
Lesson,
LessonKind,
LessonStatus,
Section,
)
from apps.enrollments.models import Enrollment
from apps.payments.models import Gateway, Order, OrderStatus
User = get_user_model()
CONSOLE_PAYMENT = "apps.payments.services.gateway.ConsolePaymentBackend"
# --------------------------------------------------------------------------- helpers
def _teacher(phone="09120000001", approved=True):
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved)
return u
def _student(phone="09120000099"):
return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT)
def _paid_course(instructor, *, price=1_000_000, n_lessons=2, published=True):
c = Course.objects.create(
instructor=instructor, title="Pay Course", tagline="T", description="D",
level="beginner", language="fa", price_toman=price,
)
if published:
c.publish()
sec = Section.objects.create(course=c, title="S1", order=0)
for i in range(n_lessons):
Lesson.objects.create(
section=sec, title=f"L{i+1}", kind=LessonKind.VIDEO,
video_url=f"https://e.com/{i}", status=LessonStatus.PUBLISHED, order=i,
)
return c
def _free_course(instructor, *, published=True):
c = Course.objects.create(
instructor=instructor, title="Free", tagline="T", description="D",
level="beginner", language="fa", price_toman=None,
)
if published:
c.publish()
return c
# --------------------------------------------------------------------------- order math
class OrderMathTests(TestCase):
@override_settings(ILO_COMMISSION_PERCENT=15, PAYMENT_BACKEND=CONSOLE_PAYMENT)
def test_commission_split_at_15_percent(self):
from apps.payments.services.orders import start_checkout
t = _teacher()
s = _student()
course = _paid_course(t, price=1_000_000)
order, redirect_url = start_checkout(s, course)
self.assertEqual(order.amount_toman, 1_000_000)
self.assertEqual(order.ilo_fee_toman, 150_000)
self.assertEqual(order.instructor_share_toman, 850_000)
self.assertEqual(order.commission_percent_snapshot, 15)
self.assertEqual(order.gateway, Gateway.CONSOLE)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertTrue(order.authority.startswith("CONSOLE-"))
self.assertIn("Authority=CONSOLE-", redirect_url)
self.assertIn("Status=OK", redirect_url)
@override_settings(ILO_COMMISSION_PERCENT=20, PAYMENT_BACKEND=CONSOLE_PAYMENT)
def test_commission_uses_setting(self):
from apps.payments.services.orders import start_checkout
t = _teacher()
s = _student()
course = _paid_course(t, price=500_000)
order, _ = start_checkout(s, course)
self.assertEqual(order.ilo_fee_toman, 100_000)
self.assertEqual(order.instructor_share_toman, 400_000)
# --------------------------------------------------------------------------- checkout endpoint
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class CheckoutAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.paid = _paid_course(self.t, price=2_000_000)
self.free = _free_course(self.t)
def test_anon_blocked(self):
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 401)
def test_teacher_blocked(self):
self.client.force_authenticate(self.t)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 403)
def test_student_checkout_returns_redirect(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 201, r.content)
body = r.json()
self.assertEqual(body["amount_toman"], 2_000_000)
self.assertEqual(body["gateway"], "console")
self.assertIn("Authority=CONSOLE-", body["redirect_url"])
self.assertEqual(Order.objects.filter(student=self.s).count(), 1)
def test_checkout_for_free_course_400(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.free.slug}/checkout/")
self.assertEqual(r.status_code, 400)
def test_checkout_when_already_enrolled_400(self):
Enrollment.objects.create(student=self.s, course=self.paid)
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/checkout/")
self.assertEqual(r.status_code, 400)
def test_enroll_endpoint_returns_402_for_paid_course(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.paid.slug}/enroll/")
self.assertEqual(r.status_code, 402)
body = r.json()
self.assertIn("checkout_url", body)
self.assertEqual(body["price_toman"], 2_000_000)
# --------------------------------------------------------------------------- callback
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT, PAYMENT_SUCCESS_REDIRECT_URL="/p/ok", PAYMENT_FAILURE_REDIRECT_URL="/p/fail")
class CallbackAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_000_000)
def _open_pending_order(self):
self.client.force_authenticate(self.s)
r = self.client.post(f"/api/courses/{self.course.slug}/checkout/")
order_id = r.json()["order_id"]
# The console redirect URL has Authority=CONSOLE-<id>&Status=OK&order_id=<id>
return Order.objects.get(pk=order_id)
def test_full_flow_success(self):
order = self._open_pending_order()
r = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/ok", r["Location"])
order.refresh_from_db()
self.assertEqual(order.status, OrderStatus.PAID)
self.assertTrue(
Enrollment.objects.filter(student=self.s, course=self.course).exists()
)
def test_callback_idempotent(self):
order = self._open_pending_order()
r1 = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
r2 = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=OK&Authority={order.authority}"
)
self.assertEqual(r1.status_code, 302)
self.assertEqual(r2.status_code, 302)
# Still exactly one enrollment
self.assertEqual(
Enrollment.objects.filter(student=self.s, course=self.course).count(), 1
)
def test_callback_user_cancel_marks_failed(self):
order = self._open_pending_order()
r = self.client.get(
f"/api/payments/callback/console/?order_id={order.pk}&Status=NOK&Authority={order.authority}"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/fail", r["Location"])
order.refresh_from_db()
self.assertEqual(order.status, OrderStatus.FAILED)
self.assertFalse(
Enrollment.objects.filter(student=self.s, course=self.course).exists()
)
def test_callback_unknown_order(self):
r = self.client.get(
"/api/payments/callback/console/?order_id=999999&Status=OK&Authority=X"
)
self.assertEqual(r.status_code, 302)
self.assertIn("/p/fail", r["Location"])
# --------------------------------------------------------------------------- order lists
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class OrderListAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_500_000)
def test_my_orders_lists_own(self):
# complete a full flow first
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
r = self.client.get("/api/me/orders/")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["status"], "paid")
self.assertEqual(body[0]["amount_toman"], 1_500_000)
self.assertEqual(body[0]["course_slug"], self.course.slug)
def test_instructor_sales(self):
# student buys
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
# teacher sees the sale
self.client.force_authenticate(self.t)
r = self.client.get("/api/instructor/orders/?status=paid")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["amount_toman"], 1_500_000)
self.assertEqual(body[0]["ilo_fee_toman"], 225_000) # 15%
self.assertEqual(body[0]["instructor_share_toman"], 1_275_000)
self.assertEqual(body[0]["student_id"], self.s.id)
def test_student_blocked_from_instructor_sales(self):
self.client.force_authenticate(self.s)
r = self.client.get("/api/instructor/orders/")
self.assertEqual(r.status_code, 403)
@override_settings(PAYMENT_BACKEND=CONSOLE_PAYMENT)
class OrderDetailAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=500_000)
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/").json()
self.order_id = co["order_id"]
self.client.get(
f"/api/payments/callback/console/?order_id={self.order_id}&Status=OK&Authority=CONSOLE-{self.order_id}"
)
def test_get_my_order_detail(self):
r = self.client.get(f"/api/me/orders/{self.order_id}/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(body["id"], self.order_id)
self.assertEqual(body["status"], "paid")
self.assertEqual(body["amount_toman"], 500_000)
self.assertTrue(body["ref_id"])
def test_cannot_get_others_order(self):
other = _student(phone="09120000022")
self.client.force_authenticate(other)
r = self.client.get(f"/api/me/orders/{self.order_id}/")
self.assertEqual(r.status_code, 404)

View File

@ -0,0 +1,392 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import Role, TeacherProfile
from apps.courses.models import Course, Lesson, LessonKind, LessonStatus, Section
from apps.enrollments.models import Enrollment
from apps.payments.models import Order, OrderStatus, PromoCode, Referral
User = get_user_model()
CONSOLE = "apps.payments.services.gateway.ConsolePaymentBackend"
def _teacher(phone="09120000001"):
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=True)
return u
def _student(phone="09120000099"):
return User.objects.create_user(phone_number=phone, full_name="Student", role=Role.STUDENT)
def _paid_course(instructor, *, price=1_000_000):
c = Course.objects.create(
instructor=instructor, title="C", tagline="T", description="D",
level="beginner", language="fa", price_toman=price,
)
c.publish()
sec = Section.objects.create(course=c, title="S")
Lesson.objects.create(section=sec, title="L", kind=LessonKind.VIDEO, video_url="https://e.com/x", status=LessonStatus.PUBLISHED)
return c
# ==================================================================== referral code
class ReferralCodeAutoTests(TestCase):
def test_referral_code_auto_generated_on_save(self):
u = User.objects.create_user(phone_number="09120000010", full_name="X", role=Role.STUDENT)
self.assertEqual(len(u.referral_code), 6)
u2 = User.objects.create_user(phone_number="09120000011", full_name="Y", role=Role.STUDENT)
self.assertNotEqual(u.referral_code, u2.referral_code)
# ==================================================================== promo codes
@override_settings(PAYMENT_BACKEND=CONSOLE)
class PromoCodeCheckoutTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_000_000)
def test_percent_promo_applied(self):
PromoCode.objects.create(code="WELCOME10", discount_percent=10, is_active=True)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "welcome10"}, format="json",
)
self.assertEqual(r.status_code, 201, r.content)
order = Order.objects.get(pk=r.json()["order_id"])
self.assertEqual(order.base_amount_toman, 1_000_000)
self.assertEqual(order.discount_amount_toman, 100_000)
self.assertEqual(order.amount_toman, 900_000)
self.assertEqual(order.promo_code.code, "WELCOME10")
def test_fixed_promo_applied(self):
PromoCode.objects.create(code="MINUS200K", discount_amount_toman=200_000, is_active=True)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "MINUS200K"}, format="json",
)
self.assertEqual(r.status_code, 201)
order = Order.objects.get(pk=r.json()["order_id"])
self.assertEqual(order.amount_toman, 800_000)
def test_invalid_promo_400(self):
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "NOTREAL"}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_expired_promo_400(self):
PromoCode.objects.create(
code="OLD", discount_percent=10,
valid_until=timezone.now() - timedelta(days=1),
)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "OLD"}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_max_uses_exhausted(self):
PromoCode.objects.create(code="ONCE", discount_percent=10, max_uses=1, used_count=1)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "ONCE"}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_promo_scoped_to_other_course_400(self):
other = _paid_course(self.t, price=500_000)
other.slug = "other"; other.save()
PromoCode.objects.create(code="OC10", discount_percent=10, course=other)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "OC10"}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_promo_used_count_bumped_on_paid(self):
pc = PromoCode.objects.create(code="W10", discount_percent=10)
self.client.force_authenticate(self.s)
co = self.client.post(f"/api/courses/{self.course.slug}/checkout/", {"promo_code": "W10"}, format="json").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
pc.refresh_from_db()
self.assertEqual(pc.used_count, 1)
def test_full_discount_skips_gateway(self):
PromoCode.objects.create(code="FREE100", discount_percent=100)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "FREE100"}, format="json",
)
self.assertEqual(r.status_code, 201)
body = r.json()
self.assertEqual(body["amount_toman"], 0)
self.assertEqual(body["gateway"], "free")
order = Order.objects.get(pk=body["order_id"])
self.assertEqual(order.status, OrderStatus.PAID)
self.assertTrue(
Enrollment.objects.filter(student=self.s, course=self.course).exists()
)
# ==================================================================== referral codes
@override_settings(PAYMENT_BACKEND=CONSOLE, REFERRAL_BUYER_DISCOUNT_PERCENT=10, REFERRAL_REFERRER_CREDIT_PERCENT=10)
class ReferralCheckoutTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.referrer = _student(phone="09120000050")
self.course = _paid_course(self.t, price=1_000_000)
def test_referral_applies_buyer_discount(self):
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"referral_code": self.referrer.referral_code}, format="json",
)
self.assertEqual(r.status_code, 201, r.content)
order = Order.objects.get(pk=r.json()["order_id"])
self.assertEqual(order.discount_amount_toman, 100_000)
self.assertEqual(order.amount_toman, 900_000)
self.assertEqual(order.referrer_id, self.referrer.id)
def test_self_referral_rejected(self):
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"referral_code": self.s.referral_code}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_promo_and_referral_together_rejected(self):
PromoCode.objects.create(code="X", discount_percent=10)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"promo_code": "X", "referral_code": self.referrer.referral_code}, format="json",
)
self.assertEqual(r.status_code, 400)
def test_referral_row_created_on_paid(self):
self.client.force_authenticate(self.s)
co = self.client.post(
f"/api/courses/{self.course.slug}/checkout/",
{"referral_code": self.referrer.referral_code}, format="json",
).json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
ref = Referral.objects.get(order_id=co["order_id"])
self.assertEqual(ref.referrer_id, self.referrer.id)
self.assertEqual(ref.buyer_id, self.s.id)
self.assertEqual(ref.buyer_discount_toman, 100_000)
# 10% of 900_000 = 90_000
self.assertEqual(ref.referrer_credit_toman, 90_000)
# ==================================================================== preview
@override_settings(PAYMENT_BACKEND=CONSOLE)
class CheckoutPreviewTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
self.s = _student()
self.course = _paid_course(self.t, price=1_000_000)
def test_preview_with_promo_returns_discount(self):
PromoCode.objects.create(code="W10", discount_percent=10)
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/preview/",
{"promo_code": "W10"}, format="json",
)
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(body["base_amount_toman"], 1_000_000)
self.assertEqual(body["discount_amount_toman"], 100_000)
self.assertEqual(body["final_amount_toman"], 900_000)
self.assertEqual(body["promo_code_applied"], "W10")
# Preview must NOT have created an Order
self.assertFalse(Order.objects.exists())
def test_preview_with_invalid_promo_400(self):
self.client.force_authenticate(self.s)
r = self.client.post(
f"/api/courses/{self.course.slug}/checkout/preview/",
{"promo_code": "BAD"}, format="json",
)
self.assertEqual(r.status_code, 400)
# ==================================================================== discount countdown
class DiscountCountdownTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
def test_discount_active_when_in_window(self):
c = Course.objects.create(
instructor=self.t, title="C", tagline="", description="",
level="beginner", language="fa",
price_toman=80, original_price_toman=100,
discount_starts_at=timezone.now() - timedelta(hours=1),
discount_ends_at=timezone.now() + timedelta(hours=1),
)
c.publish()
r = self.client.get(f"/api/courses/{c.slug}/")
body = r.json()
self.assertTrue(body["discount_active"])
self.assertIsNotNone(body["discount_ends_at"])
def test_discount_inactive_after_window(self):
c = Course.objects.create(
instructor=self.t, title="C2", tagline="", description="",
level="beginner", language="fa",
price_toman=80, original_price_toman=100,
discount_ends_at=timezone.now() - timedelta(hours=1),
)
c.publish()
r = self.client.get(f"/api/courses/{c.slug}/")
self.assertFalse(r.json()["discount_active"])
def test_discount_inactive_when_no_original_price(self):
c = Course.objects.create(
instructor=self.t, title="C3", tagline="", description="",
level="beginner", language="fa",
price_toman=100,
)
c.publish()
r = self.client.get(f"/api/courses/{c.slug}/")
self.assertFalse(r.json()["discount_active"])
# ==================================================================== featured instructors filter
class FeaturedInstructorsTests(TestCase):
def test_is_featured_filter(self):
client = APIClient()
a = _teacher(phone="09120000001")
b = _teacher(phone="09120000002")
a.teacher_profile.is_featured = True
a.teacher_profile.save()
r = client.get("/api/instructors/?is_featured=true")
ids = [row["id"] for row in r.json()]
self.assertIn(a.id, ids)
self.assertNotIn(b.id, ids)
# ==================================================================== global search
class GlobalSearchTests(TestCase):
def setUp(self):
self.client = APIClient()
self.t = _teacher()
Course.objects.create(
instructor=self.t, title="Python Basics", tagline="", description="",
level="beginner", language="fa", price_toman=None,
).publish()
Course.objects.create(
instructor=self.t, title="Persian Typography", tagline="", description="",
level="beginner", language="fa",
).publish()
def test_missing_q_400(self):
r = self.client.get("/api/search/")
self.assertEqual(r.status_code, 400)
def test_search_courses_default(self):
r = self.client.get("/api/search/?q=Python")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertIn("courses", body)
titles = [c["title"] for c in body["courses"]]
self.assertIn("Python Basics", titles)
def test_search_instructors_only(self):
r = self.client.get("/api/search/?q=Teacher&types=instructors")
body = r.json()
self.assertNotIn("courses", body)
self.assertIn("instructors", body)
# ==================================================================== IDPay backend
class IDPayBackendUnitTests(TestCase):
"""Unit-test IDPay's start/verify with mocked HTTP — we can't reach api.idpay.ir from dev."""
@override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True)
def test_start_stores_authority_and_returns_link(self):
from unittest.mock import patch
from apps.payments.services.gateway import IDPayBackend
t = _teacher()
s = _student()
course = _paid_course(t, price=1000)
order = Order.objects.create(
student=s, course=course,
base_amount_toman=1000, amount_toman=1000,
gateway="idpay", status=OrderStatus.PENDING,
)
with patch("apps.payments.services.gateway.requests.post") as mock_post:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"id": "FAKE-AUTH", "link": "https://idpay.ir/p/ws/FAKE-AUTH"}
backend = IDPayBackend()
redirect = backend.start(order, "https://example/cb")
order.refresh_from_db()
self.assertEqual(order.authority, "FAKE-AUTH")
self.assertEqual(redirect, "https://idpay.ir/p/ws/FAKE-AUTH")
@override_settings(IDPAY_API_KEY="fake-key", IDPAY_SANDBOX=True)
def test_verify_success(self):
from unittest.mock import patch
from apps.payments.services.gateway import IDPayBackend
t = _teacher()
s = _student()
course = _paid_course(t, price=1000)
order = Order.objects.create(
student=s, course=course,
base_amount_toman=1000, amount_toman=1000,
gateway="idpay", status=OrderStatus.PENDING, authority="AUTH-1",
)
with patch("apps.payments.services.gateway.requests.post") as mock_post:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"status": 100, "track_id": "TRACK-1"}
backend = IDPayBackend()
ok, ref_id, err = backend.verify(order)
self.assertTrue(ok)
self.assertEqual(ref_id, "TRACK-1")
self.assertIsNone(err)
@override_settings(IDPAY_API_KEY="")
def test_init_without_key_fails(self):
from apps.payments.services.gateway import IDPayBackend, PaymentGatewayError
with self.assertRaises(PaymentGatewayError):
IDPayBackend()

625
design/Ilo Auth.html Normal file
View File

@ -0,0 +1,625 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ایلو · ورود و ثبت‌نام</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css" />
<style>
body { background: var(--bg); }
.auth-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
}
.auth-side {
background: linear-gradient(150deg, oklch(0.94 0.04 40), oklch(0.96 0.02 60));
position: relative;
overflow: hidden;
padding: 48px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.auth-side::before {
content: "";
position: absolute;
inset: -20% -10% auto auto;
width: 480px; height: 480px;
border-radius: 50%;
background: radial-gradient(circle, oklch(0.78 0.10 40 / 0.4), transparent 70%);
filter: blur(40px);
}
.auth-side::after {
content: "";
position: absolute;
inset: auto auto -20% -10%;
width: 380px; height: 380px;
border-radius: 50%;
background: radial-gradient(circle, oklch(0.78 0.08 80 / 0.4), transparent 70%);
filter: blur(40px);
}
.auth-side > * { position: relative; z-index: 1; }
.brand-row {
display: flex; align-items: center; gap: 12px;
}
.brand-mark {
width: 44px; height: 44px;
border-radius: 13px;
background: linear-gradient(140deg, var(--accent), var(--accent-2));
display: grid; place-items: center;
color: white;
font-weight: 700;
font-family: var(--font-en);
font-size: 18px;
letter-spacing: -0.02em;
box-shadow: 0 4px 16px oklch(0.72 0.14 40 / 0.3);
}
.auth-pitch {
max-width: 480px;
}
.auth-pitch h1 {
font-size: 40px;
line-height: 1.25;
letter-spacing: -0.02em;
font-weight: 700;
margin: 0 0 16px;
}
.auth-pitch h1 em {
font-style: normal;
background: linear-gradient(120deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.auth-pitch p {
color: var(--ink-2);
font-size: 16px;
line-height: 1.7;
margin: 0 0 24px;
}
.auth-bullets {
display: flex; flex-direction: column; gap: 14px;
}
.auth-bullet {
display: flex; align-items: center; gap: 12px;
font-size: 14px;
color: var(--ink-2);
}
.auth-bullet .b-ic {
width: 28px; height: 28px;
border-radius: 8px;
background: white;
color: var(--accent);
display: grid; place-items: center;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
flex-shrink: 0;
}
.auth-foot {
display: flex; gap: 24px;
color: var(--ink-3);
font-size: 12.5px;
}
.auth-main {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: var(--surface);
}
.auth-card {
width: 100%;
max-width: 420px;
display: flex; flex-direction: column;
}
.auth-stepper {
display: flex; align-items: center; gap: 8px;
margin-bottom: 32px;
}
.step-dot {
width: 26px; height: 26px;
border-radius: 50%;
display: grid; place-items: center;
font-size: 12px; font-weight: 600;
background: var(--surface-2);
color: var(--ink-3);
border: 1px solid var(--line);
}
.step-dot.active {
background: var(--accent);
color: white;
border-color: transparent;
}
.step-dot.done {
background: var(--accent-soft);
color: var(--accent-ink);
border-color: transparent;
}
.step-line {
flex: 1; height: 2px;
background: var(--line);
border-radius: 999px;
}
.step-line.done { background: var(--accent); }
.auth-h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.015em;
margin: 0 0 8px;
}
.auth-sub {
color: var(--ink-3);
font-size: 14px;
margin: 0 0 28px;
}
.field-stack { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
.field-lbl {
font-size: 12.5px;
font-weight: 500;
color: var(--ink-2);
margin-bottom: 6px;
display: block;
}
.phone-inp {
display: flex;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-sm);
overflow: hidden;
transition: border-color .15s, box-shadow .15s;
}
.phone-inp:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.phone-cc {
padding: 12px 14px;
background: var(--surface-2);
border-inline-end: 1px solid var(--line);
display: flex; align-items: center; gap: 8px;
color: var(--ink-2);
font-family: var(--font-num);
font-weight: 600;
font-size: 14px;
}
.phone-inp input {
flex: 1;
border: 0; outline: 0; background: transparent;
padding: 12px 14px;
font: inherit; font-size: 16px;
letter-spacing: 0.5px;
color: var(--ink);
direction: ltr;
text-align: start;
font-family: var(--font-num);
}
.role-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.role-card {
border: 1.5px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
padding: 20px;
cursor: pointer;
transition: all .15s;
text-align: start;
display: flex; flex-direction: column; gap: 12px;
}
.role-card:hover {
border-color: var(--ink-4);
background: var(--surface-2);
}
.role-card.selected {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.role-ic {
width: 36px; height: 36px;
border-radius: 10px;
background: color-mix(in oklch, var(--accent) 18%, transparent);
color: var(--accent);
display: grid; place-items: center;
}
.role-card.selected .role-ic {
background: var(--accent);
color: white;
}
.role-card h3 { margin: 0; font-size: 15px; font-weight: 600; }
.role-card p { margin: 0; font-size: 12px; color: var(--ink-3); line-height: 1.55; }
.channel-row {
display: flex; gap: 8px;
padding: 4px;
background: var(--surface-2);
border-radius: var(--r-sm);
margin-bottom: 24px;
}
.channel-btn {
flex: 1;
appearance: none;
border: 0;
background: transparent;
padding: 10px;
border-radius: 6px;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--ink-3);
cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.channel-btn.active {
background: var(--surface);
color: var(--ink);
box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.otp-row {
display: flex; gap: 8px;
direction: ltr;
margin-bottom: 8px;
}
.otp-cell {
flex: 1 1 0;
min-width: 0;
width: 0;
height: 64px;
border: 1.5px solid var(--line);
background: var(--surface);
border-radius: var(--r-sm);
font: inherit;
font-size: 22px;
font-weight: 700;
text-align: center;
color: var(--ink);
outline: none;
transition: all .15s;
font-family: var(--font-num);
}
.otp-cell:focus,
.otp-cell.filled {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.resend-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 12.5px;
color: var(--ink-3);
margin-bottom: 24px;
}
.resend-row b { color: var(--ink); font-weight: 600; font-family: var(--font-num); }
.resend-btn {
appearance: none; border: 0; background: 0;
color: var(--accent);
font: inherit; font-weight: 600; font-size: 13px;
cursor: pointer;
}
.resend-btn:disabled { color: var(--ink-4); cursor: default; }
.btn.full { width: 100%; justify-content: center; padding: 12px 16px; font-size: 14px; font-weight: 600; }
.legal {
font-size: 11.5px;
color: var(--ink-3);
text-align: center;
margin-top: 24px;
line-height: 1.6;
}
.legal a { color: var(--ink-2); text-decoration: underline; }
.switch-row {
text-align: center;
font-size: 13px;
color: var(--ink-3);
margin-top: 28px;
}
.switch-row a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.success-icon {
width: 64px; height: 64px;
border-radius: 50%;
background: oklch(0.94 0.05 155);
color: oklch(0.55 0.14 155);
display: grid; place-items: center;
margin: 0 auto 24px;
}
@media (max-width: 900px) {
.auth-shell { grid-template-columns: 1fr; }
.auth-side { display: none; }
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel">
function Stepper({ step, total }) {
return (
<div className="auth-stepper">
{Array.from({ length: total }).map((_, i) => (
<React.Fragment key={i}>
<div className={`step-dot ${i === step ? 'active' : i < step ? 'done' : ''}`}>
{i < step ? <Icon name="check" className="ic-sm" /> : String(i+1).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d])}
</div>
{i < total - 1 && <div className={`step-line ${i < step ? 'done' : ''}`} />}
</React.Fragment>
))}
</div>
);
}
// OTP digit input row
function OtpInput({ value, onChange }) {
const refs = React.useRef([]);
const handle = (i, v) => {
const digit = v.replace(/\D/g, '').slice(-1);
const next = (value + '').padEnd(5, ' ').split('');
next[i] = digit || ' ';
const joined = next.join('').replace(/ /g, '');
onChange(joined);
if (digit && i < 4) refs.current[i + 1]?.focus();
};
const handleKey = (i, e) => {
if (e.key === 'Backspace' && !value[i] && i > 0) refs.current[i - 1]?.focus();
};
const handlePaste = (e) => {
const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, 5);
if (text) { e.preventDefault(); onChange(text); refs.current[Math.min(text.length, 4)]?.focus(); }
};
return (
<div className="otp-row" onPaste={handlePaste}>
{[0,1,2,3,4].map(i => (
<input key={i} ref={el => refs.current[i] = el}
className={`otp-cell ${value[i] ? 'filled' : ''}`}
inputMode="numeric" maxLength={1}
value={value[i] || ''}
onChange={e => handle(i, e.target.value)}
onKeyDown={e => handleKey(i, e)} />
))}
</div>
);
}
function App() {
const [step, setStep] = React.useState(0); // 0=role, 1=phone, 2=otp, 3=done
const [mode, setMode] = React.useState('signup'); // signup | signin
const [role, setRole] = React.useState('instructor');
const [channel, setChannel] = React.useState('sms');
const [phone, setPhone] = React.useState('');
const [otp, setOtp] = React.useState('');
const [resendIn, setResendIn] = React.useState(0);
React.useEffect(() => {
if (resendIn > 0) {
const t = setTimeout(() => setResendIn(resendIn - 1), 1000);
return () => clearTimeout(t);
}
}, [resendIn]);
const phoneOk = phone.length === 10 && phone.startsWith('9');
const otpOk = otp.length === 5;
const sendOtp = () => {
setOtp('');
setResendIn(60);
setStep(2);
};
const fa = (s) => String(s).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);
// — Step 0: welcome / role —
const StepRole = (
<>
<h1 className="auth-h1">{mode === 'signup' ? 'به ایلو خوش اومدی 👋' : 'دوباره خوش اومدی'}</h1>
<p className="auth-sub">
{mode === 'signup' ? 'برای شروع، نقش خودت رو انتخاب کن.' : 'وارد حساب کاربری‌ات شو.'}
</p>
{mode === 'signup' && (
<div className="role-grid">
<button className={`role-card ${role === 'instructor' ? 'selected' : ''}`}
onClick={() => setRole('instructor')}>
<div className="role-ic"><Icon name="trophy" /></div>
<h3>مدرس هستم</h3>
<p>می‌خوام دوره بسازم و بفروشم</p>
</button>
<button className={`role-card ${role === 'student' ? 'selected' : ''}`}
onClick={() => setRole('student')}>
<div className="role-ic"><Icon name="book" /></div>
<h3>دانشجو هستم</h3>
<p>می‌خوام چیز جدیدی یاد بگیرم</p>
</button>
</div>
)}
<button className="btn primary full" onClick={() => setStep(1)}>
ادامه
<Icon name="chevron" className="ic-sm flipx" />
</button>
<div className="switch-row">
{mode === 'signup' ? 'قبلاً ثبت‌نام کردی؟ ' : 'حساب نداری؟ '}
<a onClick={() => setMode(mode === 'signup' ? 'signin' : 'signup')}>
{mode === 'signup' ? 'وارد شو' : 'ثبت‌نام کن'}
</a>
</div>
</>
);
// — Step 1: phone —
const StepPhone = (
<>
<h1 className="auth-h1">شماره موبایلت رو وارد کن</h1>
<p className="auth-sub">
یک کد ۵ رقمی برای تأیید برات ارسال می‌کنیم.
</p>
<div className="channel-row">
<button className={`channel-btn ${channel === 'sms' ? 'active' : ''}`}
onClick={() => setChannel('sms')}>
<Icon name="phone" className="ic-sm" />
پیامک
</button>
<button className={`channel-btn ${channel === 'bale' ? 'active' : ''}`}
onClick={() => setChannel('bale')}>
<Icon name="send" className="ic-sm" />
بله
</button>
</div>
<div className="field-stack">
<div>
<label className="field-lbl">شماره موبایل</label>
<div className="phone-inp">
<div className="phone-cc">
<span>🇮🇷</span>
<span>+۹۸</span>
</div>
<input value={phone} onChange={e => setPhone(e.target.value.replace(/\D/g,'').slice(0,10))}
placeholder="9XX XXX XXXX" inputMode="numeric" autoFocus />
</div>
</div>
</div>
<button className="btn primary full" disabled={!phoneOk}
style={{ opacity: phoneOk ? 1 : 0.5, cursor: phoneOk ? 'pointer' : 'not-allowed' }}
onClick={sendOtp}>
ارسال کد تأیید
<Icon name="chevron" className="ic-sm flipx" />
</button>
<div className="legal">
با ادامه دادن، با <a>قوانین استفاده</a> و <a>سیاست حریم خصوصی</a> ایلو موافقت می‌کنی.
</div>
</>
);
// — Step 2: OTP —
const StepOtp = (
<>
<h1 className="auth-h1">کد تأیید</h1>
<p className="auth-sub">
کد ۵ رقمی به {channel === 'sms' ? 'پیامک' : 'پیام بله'} شماره <b className="num" style={{ direction: 'ltr', display: 'inline-block' }}>+۹۸ {fa(phone)}</b> ارسال شد.
</p>
<OtpInput value={otp} onChange={setOtp} />
<div className="resend-row" style={{ marginTop: 16 }}>
{resendIn > 0
? <span>ارسال مجدد در <b>{fa(resendIn)} ثانیه</b></span>
: <span>کد رو دریافت نکردی؟</span>}
<button className="resend-btn" disabled={resendIn > 0} onClick={() => setResendIn(60)}>
ارسال مجدد
</button>
</div>
<button className="btn primary full" disabled={!otpOk}
style={{ opacity: otpOk ? 1 : 0.5, cursor: otpOk ? 'pointer' : 'not-allowed' }}
onClick={() => setStep(3)}>
تأیید و ورود
<Icon name="check" className="ic-sm" />
</button>
<div className="switch-row">
<a onClick={() => setStep(1)}>← تغییر شماره موبایل</a>
</div>
</>
);
// — Step 3: done —
const StepDone = (
<div style={{ textAlign: 'center' }}>
<div className="success-icon">
<Icon name="check" style={{ width: 32, height: 32, strokeWidth: 2.5 }} />
</div>
<h1 className="auth-h1">حساب با موفقیت ساخته شد</h1>
<p className="auth-sub" style={{ marginBottom: 32 }}>
{role === 'instructor'
? 'حالا می‌تونی اولین دوره‌ات رو بسازی و دانشجوها رو دعوت کنی.'
: 'به دنیای ایلو خوش اومدی! بیا یه دوره عالی پیدا کنیم.'}
</p>
<a className="btn primary full"
href={role === 'instructor' ? 'Ilo Instructor Panel.html' : 'Ilo Student.html'}
style={{ textDecoration: 'none' }}>
{role === 'instructor' ? 'ورود به پنل مدرس' : 'مشاهده‌ی دوره‌ها'}
<Icon name="chevron" className="ic-sm flipx" />
</a>
</div>
);
return (
<div className="auth-shell">
<aside className="auth-side">
<div className="brand-row">
<div className="brand-mark">il</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>ایلو</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-3)' }} className="en">platform for teachers</div>
</div>
</div>
<div className="auth-pitch">
<h1>پلتفرمی برای <em>مدرس‌ها</em>،<br/>که می‌خوان حرفه‌ای‌تر یاد بدن.</h1>
<p>محتوات رو آپلود کن، مسیر یادگیری بساز، با دانشجوها در ارتباط باش و درآمدت رو رشد بده — همه در یک‌جا.</p>
<div className="auth-bullets">
<div className="auth-bullet"><div className="b-ic"><Icon name="upload" className="ic-sm" /></div>آپلود راحت ویدیو، PDF و فایل صوتی</div>
<div className="auth-bullet"><div className="b-ic"><Icon name="users" className="ic-sm" /></div>مدیریت دانشجوها و پیگیری پیشرفت</div>
<div className="auth-bullet"><div className="b-ic"><Icon name="store" className="ic-sm" /></div>سایت اختصاصی با نام و برند خودت</div>
<div className="auth-bullet"><div className="b-ic"><Icon name="chart" className="ic-sm" /></div>تحلیل درآمد و رفتار دانشجویان</div>
</div>
</div>
<div className="auth-foot">
<span>© ۱۴۰۳ ایلو</span>
<span>·</span>
<span>پشتیبانی: <span className="num" dir="ltr">۰۲۱-۹۱۰۰۰۰۰۰</span></span>
</div>
</aside>
<main className="auth-main">
<div className="auth-card">
{step < 3 && mode === 'signup' && <Stepper step={step} total={3} />}
{step < 3 && mode === 'signin' && <Stepper step={Math.max(0, step - 1)} total={2} />}
{step === 0 && StepRole}
{step === 1 && StepPhone}
{step === 2 && StepOtp}
{step === 3 && StepDone}
</div>
</main>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

608
design/Ilo Checkout.html Normal file
View File

@ -0,0 +1,608 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>پرداخت — ایلو</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css" />
<style>
body { background: var(--bg); }
.nav { background: var(--surface); border-bottom: 1px solid var(--line); }
.nav-inner { max-width: 1100px; margin: 0 auto; padding: 14px 24px; display: flex; align-items: center; gap: 24px; }
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 16px; text-decoration: none; color: inherit; }
.nav-mark { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(140deg, var(--accent), var(--accent-2)); color: white; font-family: var(--font-en); font-weight: 700; font-size: 12px; display: grid; place-items: center; }
.secure-pill { margin-inline-start: auto; display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: oklch(0.94 0.05 155); color: oklch(0.45 0.10 155); border-radius: 999px; font-size: 12px; font-weight: 500; }
.checkout-stepper { max-width: 1100px; margin: 32px auto; padding: 0 24px; display: flex; align-items: center; gap: 8px; }
.cs-step { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.cs-num { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 600; background: var(--surface-2); color: var(--ink-3); border: 1.5px solid var(--line); font-family: var(--font-num); }
.cs-step.active .cs-num { background: var(--accent); color: white; border-color: transparent; }
.cs-step.done .cs-num { background: var(--accent-soft); color: var(--accent-ink); border-color: transparent; }
.cs-label { font-size: 13px; color: var(--ink-3); }
.cs-step.active .cs-label, .cs-step.done .cs-label { color: var(--ink); font-weight: 600; }
.cs-line { flex: 1; height: 2px; background: var(--line); border-radius: 999px; }
.cs-line.done { background: var(--accent); }
.checkout-grid {
max-width: 1100px; margin: 0 auto;
display: grid; grid-template-columns: 1.5fr 1fr; gap: 32px;
padding: 0 24px 56px;
align-items: flex-start;
}
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 28px; }
.panel h2 { margin: 0 0 6px; font-size: 20px; }
.panel .sub { margin: 0 0 24px; font-size: 13px; color: var(--ink-3); }
/* method cards */
.method-grid { display: grid; gap: 10px; }
.method-card {
border: 1.5px solid var(--line);
background: var(--surface);
border-radius: 12px;
padding: 14px 18px;
display: flex; align-items: center; gap: 14px;
cursor: pointer;
transition: all .15s;
}
.method-card:hover { border-color: var(--ink-4); }
.method-card.selected {
border-color: var(--accent);
background: color-mix(in oklch, var(--accent-soft) 60%, var(--surface));
}
.method-radio {
width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; display: grid; place-items: center;
}
.method-card.selected .method-radio { border-color: var(--accent); }
.method-card.selected .method-radio::after { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
.method-logo {
width: 44px; height: 44px; border-radius: 10px;
background: var(--surface-2);
display: grid; place-items: center;
flex-shrink: 0;
}
.method-meta { flex: 1; }
.method-title { font-size: 14.5px; font-weight: 600; }
.method-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
.method-extra { font-size: 11.5px; color: var(--ink-3); }
/* card form */
.field-stack { display: flex; flex-direction: column; gap: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field-lbl { font-size: 12.5px; font-weight: 500; color: var(--ink-2); margin-bottom: 6px; display: flex; justify-content: space-between; }
.text-input {
width: 100%; box-sizing: border-box;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 10px;
padding: 12px 14px;
font: inherit; font-size: 14px; color: var(--ink);
outline: 0;
transition: border-color .15s, box-shadow .15s;
direction: ltr;
text-align: start;
font-family: var(--font-num);
}
.text-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.pan-wrap { position: relative; }
.pan-wrap .brand-mark {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
font-size: 11px; padding: 3px 10px; border-radius: 6px;
background: var(--surface-2); color: var(--ink-3); font-weight: 600;
}
/* coupon */
.coupon-row { display: flex; gap: 8px; }
.coupon-row .text-input { flex: 1; direction: rtl; font-family: inherit; }
/* summary card */
.summary { position: sticky; top: 24px; }
.summary-h { padding: 18px 22px 14px; border-bottom: 1px solid var(--line-soft); }
.course-row { display: flex; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--line-soft); }
.course-thumb { width: 64px; height: 48px; border-radius: 8px; background: oklch(0.78 0.12 40); flex-shrink: 0; }
.summary-body { padding: 18px 22px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.sb-row { display: flex; justify-content: space-between; }
.sb-row .num { font-family: var(--font-num); }
.sb-row.muted { color: var(--ink-3); }
.sb-row.total { padding-top: 14px; border-top: 1px solid var(--line); font-size: 15px; font-weight: 700; margin-top: 6px; }
.sb-row.discount { color: oklch(0.55 0.13 155); }
.save-banner {
margin: 0 22px;
padding: 10px 12px;
background: oklch(0.94 0.05 155);
color: oklch(0.45 0.10 155);
border-radius: 8px;
font-size: 12px;
display: flex; align-items: center; gap: 8px;
}
.pay-btn {
width: calc(100% - 44px);
margin: 14px 22px 18px;
padding: 14px;
justify-content: center;
font-size: 14px; font-weight: 600;
}
.trust-row {
padding: 12px 22px 18px;
display: flex; gap: 8px; flex-wrap: wrap;
border-top: 1px solid var(--line-soft);
color: var(--ink-3);
font-size: 11.5px;
}
/* otp & processing & success states */
.center-box {
max-width: 480px; margin: 56px auto;
padding: 0 24px;
}
.panel-center {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 40px 32px;
text-align: center;
}
.otp-row { display: flex; gap: 8px; direction: ltr; justify-content: center; margin: 24px 0 8px; }
.otp-cell {
width: 52px; height: 60px;
border: 1.5px solid var(--line);
border-radius: 10px;
font-size: 22px; font-weight: 700;
text-align: center; outline: none;
font-family: var(--font-num);
background: var(--surface);
}
.otp-cell:focus, .otp-cell.filled { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.spinner {
width: 56px; height: 56px;
border-radius: 50%;
border: 3px solid var(--line);
border-top-color: var(--accent);
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.success-ic {
width: 80px; height: 80px;
border-radius: 50%;
background: oklch(0.94 0.05 155);
color: oklch(0.55 0.14 155);
margin: 0 auto 24px;
display: grid; place-items: center;
animation: pop .4s cubic-bezier(.18,.9,.32,1.4);
}
@keyframes pop { 0% { transform: scale(0); } 100% { transform: scale(1); } }
@media (max-width: 900px) {
.checkout-grid { grid-template-columns: 1fr; }
.summary { position: static; }
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel" src="student-data.jsx"></script>
<script type="text/babel">
const fa = (s) => String(s).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);
const PAYMENT_METHODS = [
{ id: 'zarinpal', title: 'درگاه پرداخت زرین‌پال', desc: 'کارت بانکی · سریع‌ترین روش', logo: 'ZP', logoBg: 'oklch(0.78 0.16 30)', recommended: true },
{ id: 'idpay', title: 'درگاه آی‌دی‌پی', desc: 'کارت بانکی · بدون کارمزد', logo: 'IDP', logoBg: 'oklch(0.74 0.14 145)' },
{ id: 'wallet', title: 'کیف پول ایلو', desc: 'موجودی فعلی: ۱۲۰٬۰۰۰ تومان', logo: 'IL', logoBg: 'var(--accent)', extra: 'برای پرداخت کامل، ۲٬۳۳۰٬۰۰۰ تومان دیگر نیاز است' },
{ id: 'install', title: 'پرداخت اقساطی (اسنپ‌پی)', desc: '۴ قسط · بدون کارمزد', logo: 'SP', logoBg: 'oklch(0.74 0.14 320)' },
];
const PROMO_CODES = { 'WELCOME10': 0.10, 'STUDENT': 0.15 };
function OtpInput({ value, onChange, autoFocus }) {
const refs = React.useRef([]);
React.useEffect(() => { if (autoFocus) refs.current[0]?.focus(); }, [autoFocus]);
const handle = (i, v) => {
const digit = v.replace(/\D/g, '').slice(-1);
const next = (value + '').padEnd(5, ' ').split('');
next[i] = digit || ' ';
onChange(next.join('').replace(/ /g, ''));
if (digit && i < 4) refs.current[i + 1]?.focus();
};
const handleKey = (i, e) => {
if (e.key === 'Backspace' && !value[i] && i > 0) refs.current[i - 1]?.focus();
};
return (
<div className="otp-row">
{[0,1,2,3,4].map(i => (
<input key={i} ref={el => refs.current[i] = el}
className={`otp-cell ${value[i] ? 'filled' : ''}`}
inputMode="numeric" maxLength={1}
value={value[i] || ''}
onChange={e => handle(i, e.target.value)}
onKeyDown={e => handleKey(i, e)} />
))}
</div>
);
}
function App() {
const c = window.STUDENT_DATA.COURSE_DETAIL;
const [step, setStep] = React.useState('cart'); // cart, payment, otp, processing, success
const [method, setMethod] = React.useState('zarinpal');
const [pan, setPan] = React.useState('');
const [exp, setExp] = React.useState('');
const [cvv, setCvv] = React.useState('');
const [cvv2, setCvv2] = React.useState('');
const [coupon, setCoupon] = React.useState('');
const [appliedDiscount, setAppliedDiscount] = React.useState(0);
const [otp, setOtp] = React.useState('');
const subtotal = 3200000;
const courseDiscount = 750000;
const tax = 0;
const couponAmount = Math.round((subtotal - courseDiscount) * appliedDiscount);
const total = subtotal - courseDiscount - couponAmount + tax;
const totalFa = total.toLocaleString('fa-IR');
const applyCoupon = () => {
const code = coupon.trim().toUpperCase();
if (PROMO_CODES[code]) setAppliedDiscount(PROMO_CODES[code]);
else setAppliedDiscount(-1); // mark error
};
const submitPayment = () => {
setStep('otp');
};
const verifyOtp = () => {
setStep('processing');
setTimeout(() => setStep('success'), 2200);
};
const stepIdx = { cart: 0, payment: 1, otp: 1, processing: 2, success: 2 }[step];
return (
<>
<nav className="nav">
<div className="nav-inner">
<a className="nav-brand" href="Ilo Landing.html">
<div className="nav-mark">il</div>
ایلو
</a>
<span style={{ fontSize: 13, color: 'var(--ink-3)' }}>پرداخت امن</span>
<span className="secure-pill">
<Icon name="check" className="ic-sm" />
اتصال SSL · رمزنگاری ۲۵۶ بیت
</span>
</div>
</nav>
{step !== 'success' && step !== 'processing' && (
<div className="checkout-stepper">
{[['cart','سبد خرید'],['payment','پرداخت'],['success','تکمیل']].map(([k, l], i) => (
<React.Fragment key={k}>
<div className={`cs-step ${stepIdx === i ? 'active' : stepIdx > i ? 'done' : ''}`}>
<div className="cs-num">{stepIdx > i ? <Icon name="check" className="ic-sm" /> : fa(i+1)}</div>
<span className="cs-label">{l}</span>
</div>
{i < 2 && <div className={`cs-line ${stepIdx > i ? 'done' : ''}`} />}
</React.Fragment>
))}
</div>
)}
{/* CART STEP */}
{step === 'cart' && (
<div className="checkout-grid">
<div className="panel">
<h2>سبد خرید</h2>
<p className="sub">۱ دوره · بازنگری و تأیید</p>
<div style={{ display: 'flex', gap: 14, padding: '16px', border: '1px solid var(--line)', borderRadius: 12, marginBottom: 24 }}>
<div style={{ width: 96, height: 64, borderRadius: 10, background: c.color, flexShrink: 0, display: 'grid', placeItems: 'center', color: 'white' }}>
<Icon name="play" />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14.5, fontWeight: 600 }}>{c.title}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>توسط {c.instructor.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-3)', marginTop: 6, display: 'flex', gap: 12 }}>
<span><Icon name="clock" className="ic-sm" /> {fa(c.hours)} ساعت</span>
<span><Icon name="video" className="ic-sm" /> {fa(c.lessons)} درس</span>
</div>
</div>
<div style={{ textAlign: 'end' }}>
<div style={{ fontSize: 11, color: 'var(--ink-3)', textDecoration: 'line-through', fontFamily: 'var(--font-num)' }}>{c.oldPrice}</div>
<div style={{ fontSize: 16, fontWeight: 700, fontFamily: 'var(--font-num)' }}>{c.price}</div>
</div>
</div>
<div style={{ marginBottom: 18 }}>
<label className="field-lbl">کد تخفیف</label>
<div className="coupon-row">
<input className="text-input" placeholder="مثلاً: WELCOME10" value={coupon}
onChange={e => { setCoupon(e.target.value); setAppliedDiscount(0); }} />
<button className="btn primary" onClick={applyCoupon} disabled={!coupon.trim()}>اعمال</button>
</div>
{appliedDiscount > 0 && (
<div style={{ marginTop: 8, fontSize: 12, color: 'oklch(0.55 0.13 155)' }}>
<Icon name="check" className="ic-sm" /> کد «{coupon.toUpperCase()}» اعمال شد · {fa(Math.round(appliedDiscount * 100))}٪ تخفیف بیشتر
</div>
)}
{appliedDiscount === -1 && (
<div style={{ marginTop: 8, fontSize: 12, color: 'oklch(0.55 0.18 25)' }}>
✗ کد تخفیف نامعتبر است
</div>
)}
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: 14, fontSize: 12.5, color: 'var(--ink-2)', display: 'flex', gap: 10 }}>
<Icon name="check" className="ic-sm" style={{ color: 'oklch(0.55 0.13 155)' }} />
<span>گارانتی بازگشت ۷ روزه‌ی وجه — اگر از دوره راضی نبودی، تمام مبلغ پرداختی به حسابت برمی‌گرده.</span>
</div>
</div>
<Summary {...{ c, subtotal, courseDiscount, couponAmount, total, totalFa, appliedDiscount, coupon }}
cta="ادامه به پرداخت" onContinue={() => setStep('payment')} />
</div>
)}
{/* PAYMENT STEP */}
{step === 'payment' && (
<div className="checkout-grid">
<div>
<div className="panel" style={{ marginBottom: 16 }}>
<h2>روش پرداخت</h2>
<p className="sub">یک روش پرداخت انتخاب کن</p>
<div className="method-grid">
{PAYMENT_METHODS.map(m => (
<div key={m.id} className={`method-card ${method === m.id ? 'selected' : ''}`}
onClick={() => setMethod(m.id)}>
<div className="method-radio" />
<div className="method-logo" style={{ background: m.logoBg, color: 'white', fontWeight: 700, fontSize: 12 }}>{m.logo}</div>
<div className="method-meta">
<div className="method-title">{m.title} {m.recommended && <span className="pill accent" style={{ fontSize: 10, marginInlineStart: 6 }}>پیشنهادی</span>}</div>
<div className="method-desc">{m.desc}</div>
{m.extra && <div className="method-extra" style={{ marginTop: 4 }}>{m.extra}</div>}
</div>
</div>
))}
</div>
</div>
{/* card form (zarinpal/idpay) */}
{(method === 'zarinpal' || method === 'idpay') && (
<div className="panel">
<h2>اطلاعات کارت</h2>
<p className="sub">اطلاعات کارت روی درگاه بانک وارد می‌شود — این صفحه فقط برای پیش‌نمایش است.</p>
<div className="field-stack">
<div>
<label className="field-lbl">
<span>شماره کارت</span>
<span style={{ color: 'var(--ink-3)' }}>۱۶ رقم</span>
</label>
<div className="pan-wrap">
<input className="text-input" style={{ paddingInlineStart: 60 }}
placeholder="۶۰۳۷ ۹۹۷۰ ۱۲۳۴ ۵۶۷۸"
value={pan}
maxLength={19}
onChange={e => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 16);
setPan(digits.replace(/(.{4})/g, '$1 ').trim());
}} />
<span className="brand-mark">{pan.startsWith('6037') ? 'ملی' : pan.startsWith('6219') ? 'سامان' : 'BANK'}</span>
</div>
</div>
<div className="form-row">
<div>
<label className="field-lbl">تاریخ انقضا</label>
<input className="text-input" placeholder="MM / YY" value={exp}
onChange={e => {
let v = e.target.value.replace(/\D/g, '').slice(0, 4);
if (v.length > 2) v = v.slice(0, 2) + ' / ' + v.slice(2);
setExp(v);
}} />
</div>
<div>
<label className="field-lbl">CVV2</label>
<input className="text-input" placeholder="۱۲۳" value={cvv}
maxLength={4}
onChange={e => setCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} />
</div>
</div>
<div>
<label className="field-lbl">رمز دوم پویا</label>
<input className="text-input" placeholder="رمز ۶ یا ۸ رقمی" value={cvv2}
onChange={e => setCvv2(e.target.value.replace(/\D/g, '').slice(0, 8))} />
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ink-3)' }}>
رمز دوم پویا را از اپ بانک یا با کد دستوری <b className="num" dir="ltr">*۷۲۰*</b> دریافت کن.
</div>
</div>
</div>
</div>
)}
{method === 'wallet' && (
<div className="panel">
<div style={{ padding: '24px', textAlign: 'center' }}>
<Icon name="store" style={{ width: 40, height: 40, color: 'var(--accent)', margin: '0 auto 12px', display: 'block' }} />
<div style={{ fontSize: 16, fontWeight: 700 }}>پرداخت با کیف پول ایلو</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 8 }}>موجودی شما: <b className="num">۱۲۰٬۰۰۰</b> تومان</div>
<div style={{ fontSize: 13, color: 'oklch(0.55 0.18 25)', marginTop: 4 }}>برای پرداخت کامل، نیاز به <b className="num">۲٬۳۳۰٬۰۰۰</b> تومان شارژ بیشتر است</div>
<button className="btn primary" style={{ marginTop: 16 }}>شارژ کیف پول</button>
</div>
</div>
)}
{method === 'install' && (
<div className="panel">
<h2>پرداخت اقساطی</h2>
<p className="sub">مبلغ کل به ۴ قسط مساوی تقسیم می‌شود</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1,2,3,4].map(n => (
<div key={n} style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 16px', background: n === 1 ? 'var(--accent-soft)' : 'var(--surface-2)', borderRadius: 10, fontSize: 13 }}>
<span>قسط {fa(n)} {n === 1 ? '· امروز' : `· ${fa(n-1)} ماه دیگر`}</span>
<b className="num">{Math.round(total/4).toLocaleString('fa-IR')} تومان</b>
</div>
))}
</div>
</div>
)}
</div>
<Summary {...{ c, subtotal, courseDiscount, couponAmount, total, totalFa, appliedDiscount, coupon }}
cta={`پرداخت ${totalFa} تومان`} onContinue={submitPayment} />
</div>
)}
{/* OTP STEP */}
{step === 'otp' && (
<div className="center-box">
<div className="panel-center">
<div style={{ width: 56, height: 56, borderRadius: 14, background: 'var(--accent-soft)', color: 'var(--accent)', display: 'grid', placeItems: 'center', margin: '0 auto 20px' }}>
<Icon name="phone" />
</div>
<h2 style={{ margin: '0 0 8px', fontSize: 20 }}>تأیید پرداخت</h2>
<p style={{ margin: 0, fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.7 }}>
کد ۵ رقمی به شماره <b className="num" dir="ltr">+۹۸ ۹۱۲...۴۵۶۷</b> ارسال شد.<br/>
این کد را برای تأیید تراکنش وارد کنید.
</p>
<OtpInput value={otp} onChange={setOtp} autoFocus />
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 20 }}>کد رو دریافت نکردی؟ <a style={{ color: 'var(--accent)', cursor: 'pointer' }}>ارسال مجدد</a></div>
<button className="btn primary full" disabled={otp.length !== 5}
style={{ width: '100%', justifyContent: 'center', padding: '12px', opacity: otp.length === 5 ? 1 : 0.5, cursor: otp.length === 5 ? 'pointer' : 'not-allowed' }}
onClick={verifyOtp}>
تأیید و پرداخت
</button>
</div>
</div>
)}
{/* PROCESSING */}
{step === 'processing' && (
<div className="center-box">
<div className="panel-center">
<div className="spinner" />
<h2 style={{ margin: '0 0 8px', fontSize: 20 }}>در حال پردازش پرداخت…</h2>
<p style={{ margin: 0, fontSize: 13, color: 'var(--ink-3)' }}>لطفاً صفحه را نبند یا رفرش نکن</p>
</div>
</div>
)}
{/* SUCCESS */}
{step === 'success' && (
<div className="center-box">
<div className="panel-center">
<div className="success-ic">
<Icon name="check" style={{ width: 36, height: 36, strokeWidth: 2.5 }} />
</div>
<h2 style={{ margin: '0 0 8px', fontSize: 24 }}>تبریک! خریدت موفق بود 🎉</h2>
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--ink-2)' }}>
از این لحظه به محتوای دوره <b>«{c.title}»</b> دسترسی داری.
</p>
<div style={{ background: 'var(--surface-2)', borderRadius: 12, padding: 16, marginBottom: 24, textAlign: 'start' }}>
<div className="sb-row muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>کد پیگیری تراکنش</span>
<b className="num" dir="ltr" style={{ color: 'var(--ink)' }}>IL-2024-AB7392X</b>
</div>
<div className="sb-row muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginTop: 6 }}>
<span>مبلغ پرداختی</span>
<b className="num" style={{ color: 'var(--ink)' }}>{totalFa} تومان</b>
</div>
<div className="sb-row muted" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginTop: 6 }}>
<span>روش پرداخت</span>
<b style={{ color: 'var(--ink)' }}>{PAYMENT_METHODS.find(m => m.id === method)?.title}</b>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<a className="btn primary" href="Ilo Student.html" style={{ flex: 1, justifyContent: 'center', padding: '12px', textDecoration: 'none' }}>
شروع یادگیری
<Icon name="chevron" className="ic-sm flipx" />
</a>
<a className="btn" href="Ilo Landing.html" style={{ justifyContent: 'center', padding: '12px', textDecoration: 'none' }}>
دانلود رسید
</a>
</div>
<div style={{ marginTop: 24, fontSize: 12, color: 'var(--ink-3)' }}>
رسید خرید به ایمیل و پیامک شما ارسال شد.
</div>
</div>
</div>
)}
</>
);
}
function Summary({ c, subtotal, courseDiscount, couponAmount, total, totalFa, appliedDiscount, coupon, cta, onContinue }) {
return (
<div className="summary">
<div className="panel" style={{ padding: 0 }}>
<div className="summary-h">
<h2 style={{ margin: 0, fontSize: 16 }}>خلاصه سفارش</h2>
</div>
<div className="course-row">
<div className="course-thumb" style={{ background: c.color }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.4 }}>{c.title}</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-3)', marginTop: 4 }}>دسترسی مادام‌العمر</div>
</div>
</div>
<div className="summary-body">
<div className="sb-row muted">
<span>قیمت دوره</span>
<span className="num">{subtotal.toLocaleString('fa-IR')} تومان</span>
</div>
<div className="sb-row discount">
<span>تخفیف دوره</span>
<span className="num">{courseDiscount.toLocaleString('fa-IR')}</span>
</div>
{appliedDiscount > 0 && (
<div className="sb-row discount">
<span>کد «{coupon.toUpperCase()}»</span>
<span className="num">{couponAmount.toLocaleString('fa-IR')}</span>
</div>
)}
<div className="sb-row muted">
<span>مالیات بر ارزش افزوده</span>
<span>۰</span>
</div>
<div className="sb-row total">
<span>مبلغ قابل پرداخت</span>
<span className="num">{totalFa} تومان</span>
</div>
</div>
{courseDiscount + couponAmount > 0 && (
<div className="save-banner">
<Icon name="trend" className="ic-sm" />
در این خرید <b className="num" style={{ marginInline: 4 }}>{(courseDiscount + couponAmount).toLocaleString('fa-IR')}</b> تومان صرفه‌جویی کردی!
</div>
)}
<button className="btn primary pay-btn" onClick={onContinue}>
{cta}
<Icon name="chevron" className="ic-sm flipx" />
</button>
<div className="trust-row">
<span>🔒 پرداخت امن</span>
<span>·</span>
<span>گارانتی ۷ روزه</span>
<span>·</span>
<span>دسترسی مادام‌العمر</span>
</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

521
design/Ilo Course Page.html Normal file
View File

@ -0,0 +1,521 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>مبانی طراحی رابط کاربری — آرش پاکدل · ایلو</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css" />
<style>
:root { --max-w: 1200px; }
body { background: var(--surface); }
.nav { position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line-soft); }
.nav-inner { max-width: var(--max-w); margin: 0 auto; display: flex; align-items: center; gap: 24px; padding: 12px 24px; }
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 16px; text-decoration: none; color: inherit; }
.nav-mark { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(140deg, var(--accent), var(--accent-2)); color: white; font-family: var(--font-en); font-weight: 700; font-size: 12px; display: grid; place-items: center; }
.crumbs-mini { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-3); margin-inline-start: 12px; }
.crumbs-mini span:last-child { color: var(--ink-2); }
.ctop { max-width: var(--max-w); margin: 0 auto; padding: 28px 24px 0; }
.share-bar {
background: linear-gradient(135deg, var(--accent-soft), oklch(0.97 0.03 80));
border-radius: 12px;
padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
font-size: 13px;
margin-bottom: 24px;
}
.share-bar .ic-wrap { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: white; display: grid; place-items: center; }
/* hero */
.chero {
max-width: var(--max-w); margin: 0 auto;
padding: 0 24px;
display: grid; grid-template-columns: 1.5fr 1fr;
gap: 32px;
align-items: flex-start;
}
.chero-left { display: flex; flex-direction: column; gap: 18px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chero h1 { font-size: 40px; line-height: 1.2; letter-spacing: -0.02em; font-weight: 700; margin: 0; }
.chero .lead { font-size: 17px; line-height: 1.65; color: var(--ink-2); margin: 0; }
.chero-stats { display: flex; gap: 18px; font-size: 13px; color: var(--ink-3); flex-wrap: wrap; }
.chero-stats b { color: var(--ink); font-family: var(--font-num); font-weight: 600; }
.ins-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
background: var(--surface-2);
border-radius: 12px;
border: 1px solid var(--line-soft);
}
/* preview */
.preview {
background: oklch(0.78 0.12 40);
border-radius: 16px;
aspect-ratio: 16/10;
position: relative;
overflow: hidden;
cursor: pointer;
box-shadow: 0 16px 40px oklch(0.6 0.10 40 / .25);
display: grid; place-items: center;
}
.preview .play-big {
width: 80px; height: 80px; border-radius: 50%;
background: rgba(255,255,255,.95); color: var(--accent);
display: grid; place-items: center;
box-shadow: 0 8px 24px rgba(0,0,0,.2);
transition: transform .2s;
}
.preview:hover .play-big { transform: scale(1.08); }
.preview .duration { position: absolute; bottom: 14px; inset-inline-start: 14px; background: rgba(0,0,0,.6); color: white; padding: 5px 12px; border-radius: 999px; font-size: 12px; }
.price-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 20px;
box-shadow: 0 6px 20px rgba(0,0,0,.06);
display: flex; flex-direction: column; gap: 14px;
}
.price-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.price-now { font-size: 30px; font-weight: 700; font-family: var(--font-num); }
.price-unit { color: var(--ink-3); font-size: 13px; }
.price-old { font-family: var(--font-num); font-size: 14px; color: var(--ink-3); text-decoration: line-through; margin-inline-start: auto; }
.price-pct { background: var(--accent-soft); color: var(--accent-ink); padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.timer-pill { background: oklch(0.96 0.04 30); color: oklch(0.45 0.13 25); padding: 8px 12px; border-radius: 8px; font-size: 12px; display: flex; align-items: center; gap: 8px; font-family: var(--font-num); }
.price-ctas { display: flex; flex-direction: column; gap: 8px; }
.price-ctas .btn.full { width: 100%; justify-content: center; padding: 12px; font-size: 14px; font-weight: 600; }
.price-incl { display: flex; flex-direction: column; gap: 10px; padding-top: 14px; border-top: 1px solid var(--line-soft); }
.price-incl-row { display: flex; gap: 10px; align-items: center; font-size: 12.5px; color: var(--ink-2); }
.price-incl-row .ic { color: var(--ink-3); flex-shrink: 0; }
/* sections */
section.s { max-width: var(--max-w); margin: 0 auto; padding: 56px 24px; }
.grid-main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 32px; align-items: flex-start; }
.sticky-rail { position: sticky; top: 80px; }
.block-title { font-size: 22px; font-weight: 700; letter-spacing: -0.015em; margin: 0 0 20px; }
/* highlights box */
.highlights {
background: var(--surface-2);
border-radius: 16px;
padding: 28px;
border: 1px solid var(--line-soft);
}
.highlights-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.h-item { display: flex; gap: 10px; font-size: 14px; line-height: 1.6; }
.h-check { width: 22px; height: 22px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; margin-top: 1px; }
/* curriculum */
.curr-mod { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 10px; overflow: hidden; background: var(--surface); }
.curr-mod-h { padding: 16px 18px; display: flex; align-items: center; gap: 12px; cursor: pointer; }
.curr-mod-num { width: 30px; height: 30px; border-radius: 8px; background: var(--accent-soft); color: var(--accent-ink); display: grid; place-items: center; font-family: var(--font-num); font-weight: 700; font-size: 13px; }
.curr-mod-ttl { font-size: 14.5px; font-weight: 600; }
.curr-mod-meta { font-size: 12px; color: var(--ink-3); margin-inline-start: auto; font-family: var(--font-num); }
.curr-mod-body { border-top: 1px solid var(--line-soft); padding: 4px 0; }
.curr-lesson { display: flex; align-items: center; gap: 12px; padding: 10px 18px; font-size: 13px; }
.curr-lesson .li { width: 22px; height: 22px; border-radius: 6px; background: var(--surface-2); color: var(--ink-3); display: grid; place-items: center; }
.curr-lesson .free { background: var(--accent-soft); color: var(--accent-ink); padding: 2px 8px; border-radius: 999px; font-size: 10.5px; }
.curr-lesson .dur { color: var(--ink-3); margin-inline-start: auto; font-family: var(--font-num); font-size: 12px; }
/* about instructor */
.about-ins {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 28px;
display: grid; grid-template-columns: auto 1fr; gap: 20px;
}
.about-ins .av-xl { width: 88px; height: 88px; border-radius: 50%; display: grid; place-items: center; color: white; font-size: 24px; font-weight: 700; }
.about-ins p { margin: 12px 0 0; font-size: 14px; line-height: 1.8; color: var(--ink-2); }
.about-stats { display: flex; gap: 24px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line-soft); }
.about-stats div { font-size: 12px; color: var(--ink-3); }
.about-stats b { display: block; font-size: 17px; color: var(--ink); font-family: var(--font-num); }
/* reviews */
.reviews-summary {
display: grid; grid-template-columns: auto 1fr; gap: 32px; align-items: center;
padding: 24px 28px;
background: var(--surface-2);
border-radius: 16px;
margin-bottom: 20px;
}
.big-rating { font-size: 56px; font-weight: 700; font-family: var(--font-num); line-height: 1; }
.bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 6px; }
.bar-row .star-num { font-family: var(--font-num); width: 14px; }
.bar-track { flex: 1; height: 6px; background: var(--line); border-radius: 999px; overflow: hidden; }
.bar-track > i { display: block; height: 100%; background: oklch(0.74 0.13 70); border-radius: 999px; }
.bar-row .pct { width: 36px; text-align: end; color: var(--ink-3); font-family: var(--font-num); }
.review-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 18px; margin-bottom: 12px; }
.review-h { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.review-stars { display: flex; gap: 1px; }
/* faq */
.faq-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; margin-bottom: 10px; overflow: hidden; }
.faq-q { padding: 16px 18px; display: flex; justify-content: space-between; cursor: pointer; font-size: 14px; font-weight: 600; }
.faq-a { padding: 0 18px 16px; font-size: 13.5px; color: var(--ink-2); line-height: 1.8; border-top: 1px solid var(--line-soft); padding-top: 12px; }
/* related */
.related-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.rc { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; cursor: pointer; }
.rc:hover { border-color: var(--accent); }
.rc-img { aspect-ratio: 16/9; }
.rc-body { padding: 12px 14px; }
@media (max-width: 900px) {
.chero, .grid-main, .highlights-grid, .related-grid, .reviews-summary { grid-template-columns: 1fr; }
.chero h1 { font-size: 28px; }
.sticky-rail { position: static; }
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="icons.jsx"></script>
<script type="text/babel" src="student-data.jsx"></script>
<script type="text/babel">
const fa = (s) => String(s).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);
function Countdown() {
const [t, setT] = React.useState({ d: 1, h: 18, m: 42, s: 11 });
React.useEffect(() => {
const id = setInterval(() => {
setT(v => {
let { d, h, m, s } = v;
s--; if (s < 0) { s = 59; m--; }
if (m < 0) { m = 59; h--; }
if (h < 0) { h = 23; d--; }
if (d < 0) return v;
return { d, h, m, s };
});
}, 1000);
return () => clearInterval(id);
}, []);
return <span><b>{fa(t.d)}</b> روز <b>{fa(String(t.h).padStart(2,'0'))}</b>:<b>{fa(String(t.m).padStart(2,'0'))}</b>:<b>{fa(String(t.s).padStart(2,'0'))}</b></span>;
}
function App() {
const c = window.STUDENT_DATA.COURSE_DETAIL;
const related = window.STUDENT_DATA.CATALOG.filter(x => x.id !== c.id).slice(0, 3);
const [openMod, setOpenMod] = React.useState(0);
const [openFaq, setOpenFaq] = React.useState(-1);
const [copied, setCopied] = React.useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch (e) {}
};
// first lesson of each module is "free preview" for demo
const sampleLessons = [
['آشنایی با دوره', 'این درس رایگان', 'برنامه‌ی هفت هفته‌ای'],
['اصول ادراک بصری', 'تئوری گشتالت', 'تئوری رنگ کاربردی', 'تایپوگرافی پایه', 'گرید و سلسله‌مراتب'],
['تایپوگرافی فارسی', 'تایپوگرافی انگلیسی', 'ترکیب تایپوگرافی'],
['کامپوننت‌های پایه', 'الگوهای رایج طراحی'],
['پروژه پایانی - بریف', 'تحویل و بازخورد'],
];
return (
<>
{/* nav */}
<nav className="nav">
<div className="nav-inner">
<a className="nav-brand" href="Ilo Landing.html">
<div className="nav-mark">il</div>
ایلو
</a>
<div className="crumbs-mini">
<span>دوره‌ها</span>
<Icon name="chevron" className="ic-sm" />
<span>طراحی</span>
<Icon name="chevron" className="ic-sm" />
<span>{c.title}</span>
</div>
<div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
<a className="btn sm" href="Ilo Auth.html" style={{ textDecoration: 'none' }}>ورود</a>
<a className="btn primary sm" href="Ilo Auth.html" style={{ textDecoration: 'none' }}>ثبت‌نام</a>
</div>
</div>
</nav>
{/* shared-via banner (when arriving from a teacher's link) */}
<div className="ctop">
<div className="share-bar">
<div className="ic-wrap"><Icon name="link" className="ic-sm" /></div>
<div style={{ flex: 1 }}>
<b>این دوره از طرف آرش پاکدل برای شما به اشتراک گذاشته شد</b>
<span style={{ color: 'var(--ink-3)', marginInlineStart: 8, fontSize: 12 }}>· شما به‌عنوان مهمان این صفحه را می‌بینید</span>
</div>
<button className="btn sm" onClick={copy}>
<Icon name="copy" className="ic-sm" />
{copied ? 'کپی شد ✓' : 'کپی لینک صفحه'}
</button>
</div>
</div>
{/* hero */}
<header className="chero">
<div className="chero-left">
<div className="chips">
<span className="pill accent">پرفروش</span>
<span className="pill">طراحی</span>
<span className="pill">{c.level}</span>
<span className="pill">به‌روزرسانی: {c.updated}</span>
</div>
<h1>{c.title}</h1>
<p className="lead">{c.tagline}</p>
<div className="chero-stats">
<span><Icon name="star" className="ic-sm" style={{ color: 'oklch(0.74 0.13 70)' }} /> <b>{fa(c.rating)}</b> ({fa(c.reviews)} نظر)</span>
<span><Icon name="users" className="ic-sm" /> <b>{fa(c.students)}</b> دانشجو</span>
<span><Icon name="clock" className="ic-sm" /> <b>{fa(c.hours)}</b> ساعت محتوا</span>
<span><Icon name="video" className="ic-sm" /> <b>{fa(c.lessons)}</b> درس</span>
<span><Icon name="globe" className="ic-sm" /> {c.language}</span>
</div>
<div className="ins-row">
<div className="av av-lg" style={{ background: c.instructor.color, width: 44, height: 44 }}>{c.instructor.initials}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>توسط {c.instructor.name}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{c.instructor.topic}</div>
</div>
<button className="btn sm">دنبال کردن</button>
</div>
</div>
<div>
<div className="preview" style={{ background: c.color, marginBottom: 16 }}>
<div className="play-big"><Icon name="play" style={{ width: 32, height: 32 }} /></div>
<div className="duration">پیش‌نمایش رایگان · ۲ دقیقه</div>
</div>
<div className="price-card">
<div className="price-row">
<span className="price-now">{c.price}</span>
<span className="price-unit">تومان</span>
<span className="price-old">{c.oldPrice}</span>
<span className="price-pct">{c.discount} تخفیف</span>
</div>
<div className="timer-pill">
<Icon name="clock" className="ic-sm" />
<span>این تخفیف تا <Countdown /> باقی است</span>
</div>
<div className="price-ctas">
<a className="btn primary full" href="Ilo Checkout.html?course=p1" style={{ textDecoration: 'none' }}>
<Icon name="store" className="ic-sm" />
ثبت‌نام در دوره
</a>
<button className="btn full" style={{ width: '100%', justifyContent: 'center', padding: '10px' }}>
<Icon name="play" className="ic-sm" /> پیش‌نمایش رایگان
</button>
</div>
<div className="price-incl">
{[
{ ic: 'video', t: `${fa(c.lessons)} درس · ${fa(c.hours)} ساعت ویدیو` },
{ ic: 'file', t: 'فایل‌های دانلودی + تمرینات عملی' },
{ ic: 'cert', t: 'گواهی پایان دوره با امضای مدرس' },
{ ic: 'phone', t: 'دسترسی مادام‌العمر، روی هر دستگاه' },
{ ic: 'check', t: 'گارانتی بازگشت ۷ روزه‌ی وجه' },
].map((x, i) => (
<div key={i} className="price-incl-row">
<Icon name={x.ic} className="ic ic-sm" />
<span>{x.t}</span>
</div>
))}
</div>
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12, display: 'flex', gap: 6, justifyContent: 'center' }}>
<button className="btn sm" title="اشتراک‌گذاری" onClick={copy}>
<Icon name="link" className="ic-sm" /> اشتراک‌گذاری
</button>
<button className="btn sm"><Icon name="send" className="ic-sm" /> ارسال در بله</button>
</div>
</div>
</div>
</header>
<div className="grid-main" style={{ maxWidth: 'var(--max-w)', margin: '0 auto', padding: '40px 24px 0' }}>
<div>
{/* highlights */}
<div className="highlights" style={{ marginBottom: 32 }}>
<h2 className="block-title" style={{ marginBottom: 16 }}>چی یاد می‌گیری</h2>
<div className="highlights-grid">
{c.highlights.map((h, i) => (
<div key={i} className="h-item">
<div className="h-check"><Icon name="check" className="ic-sm" /></div>
<span>{h}</span>
</div>
))}
</div>
</div>
{/* curriculum */}
<h2 className="block-title">سرفصل دوره</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, fontSize: 13, color: 'var(--ink-3)' }}>
<span><b style={{ color: 'var(--ink)' }}>{fa(c.curriculum.length)}</b> فصل · <b style={{ color: 'var(--ink)' }}>{fa(c.lessons)}</b> درس · <b style={{ color: 'var(--ink)' }}>{fa(c.hours)}</b> ساعت</span>
<button className="btn sm" onClick={() => setOpenMod(openMod === -1 ? 0 : -1)}>{openMod === -1 ? 'باز کردن همه' : 'بستن همه'}</button>
</div>
<div style={{ marginBottom: 32 }}>
{c.curriculum.map((m, i) => (
<div key={i} className="curr-mod">
<div className="curr-mod-h" onClick={() => setOpenMod(openMod === i ? -1 : i)}>
<div className="curr-mod-num">{fa(i+1)}</div>
<div className="curr-mod-ttl">{m.title}</div>
<div className="curr-mod-meta">{fa(m.lessons)} درس · {fa(m.minutes)} دقیقه</div>
<Icon name="chevron" className="ic-sm" style={{ color: 'var(--ink-3)', transform: openMod === i ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform .2s' }} />
</div>
{openMod === i && (
<div className="curr-mod-body">
{(sampleLessons[i] || []).map((lt, li) => (
<div key={li} className="curr-lesson">
<div className="li"><Icon name="play" className="ic-sm" /></div>
<span>{lt}</span>
{i === 0 && li === 1 && <span className="free">رایگان</span>}
<span className="dur">{fa(8 + li)}:۰۰</span>
</div>
))}
</div>
)}
</div>
))}
</div>
{/* about instructor */}
<h2 className="block-title">درباره مدرس</h2>
<div className="about-ins" style={{ marginBottom: 32 }}>
<div className="av-xl" style={{ background: c.instructor.color }}>{c.instructor.initials}</div>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{c.instructor.name}</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{c.instructor.topic}</div>
<p>آرش پاکدل ۱۰ سال در شرکت‌های فناوری ایرانی به‌عنوان طراح ارشد محصول کار کرده و تجربه‌ی همکاری با تیم‌های مهندسی، محصول و بازاریابی را دارد. علاقه‌اش آموزش طراحی به زبان ساده و کاربردی است.</p>
<div className="about-stats">
<div><b>{fa(c.instructor.rating)}</b><span>امتیاز</span></div>
<div><b>{fa(c.instructor.students)}</b><span>دانشجو</span></div>
<div><b>{fa(c.instructor.courses)}</b><span>دوره</span></div>
<div><b>۱۰</b><span>سال تجربه</span></div>
</div>
</div>
</div>
{/* reviews */}
<h2 className="block-title">نظرات دانشجویان</h2>
<div className="reviews-summary">
<div style={{ textAlign: 'center' }}>
<div className="big-rating">{fa(c.rating)}</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 1, marginTop: 4 }}>
{[1,2,3,4,5].map(n => <Icon key={n} name="star" className="ic-sm" style={{ color: 'oklch(0.74 0.13 70)', fill: 'oklch(0.78 0.13 70)' }} />)}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 6 }}>از {fa(c.reviews)} نظر</div>
</div>
<div>
{[[5,82],[4,12],[3,4],[2,1],[1,1]].map(([s, p]) => (
<div key={s} className="bar-row">
<span className="star-num">{fa(s)}</span>
<Icon name="star" className="ic-sm" style={{ color: 'oklch(0.74 0.13 70)' }} />
<div className="bar-track"><i style={{ width: p + '%' }} /></div>
<span className="pct">{fa(p)}%</span>
</div>
))}
</div>
</div>
<div style={{ marginBottom: 32 }}>
{c.reviewsList.map((r, i) => (
<div key={i} className="review-card">
<div className="review-h">
<div className="av" style={{ background: r.color }}>{r.initials}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{r.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--ink-3)' }}>{r.when}</div>
</div>
<div className="review-stars">
{[1,2,3,4,5].map(n => <Icon key={n} name="star" className="ic-sm" style={{ color: n <= r.rating ? 'oklch(0.74 0.13 70)' : 'var(--line)', fill: n <= r.rating ? 'oklch(0.78 0.13 70)' : 'transparent' }} />)}
</div>
</div>
<p style={{ margin: 0, fontSize: 13.5, lineHeight: 1.8, color: 'var(--ink-2)' }}>{r.text}</p>
</div>
))}
</div>
{/* faq */}
<h2 className="block-title">سؤالات پرتکرار</h2>
<div>
{c.faq.map((f, i) => (
<div key={i} className="faq-card">
<div className="faq-q" onClick={() => setOpenFaq(openFaq === i ? -1 : i)}>
<span>{f.q}</span>
<Icon name={openFaq === i ? 'chevron' : 'plus'} className="ic-sm" style={{ color: 'var(--ink-3)', transform: openFaq === i ? 'rotate(90deg)' : 'none', transition: 'transform .2s' }} />
</div>
{openFaq === i && <div className="faq-a">{f.a}</div>}
</div>
))}
</div>
</div>
{/* sticky rail */}
<div className="sticky-rail">
<div className="price-card" style={{ background: 'linear-gradient(150deg, var(--accent-soft), oklch(0.97 0.03 80))', borderColor: 'transparent' }}>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>هنوز مطمئن نیستی؟</div>
<div style={{ fontSize: 16, fontWeight: 700 }}>یک پیش‌نمایش ۲ دقیقه‌ای ببین</div>
<button className="btn primary full" style={{ width: '100%', justifyContent: 'center' }}>
<Icon name="play" className="ic-sm" /> پخش پیش‌نمایش
</button>
</div>
<div className="price-card" style={{ marginTop: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>دوره را به دیگران معرفی کن</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>اگر کسی از طریق لینک تو خرید کنه، ۱۰٪ تخفیف می‌گیره و تو هم اعتبار رایگان دریافت می‌کنی.</div>
<div style={{
background: 'var(--surface-2)', border: '1px solid var(--line)', borderRadius: 8,
padding: '8px 10px', fontSize: 12, fontFamily: 'var(--font-num)', direction: 'ltr',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<Icon name="link" className="ic-sm" style={{ color: 'var(--ink-3)' }} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>ilo.ir/c/p1?ref=arash</span>
<button className="btn sm" onClick={copy} style={{ padding: '4px 10px' }}>
{copied ? '✓' : <Icon name="copy" className="ic-sm" />}
</button>
</div>
</div>
</div>
</div>
{/* related */}
<section className="s">
<h2 className="block-title">دوره‌های مرتبط</h2>
<div className="related-grid">
{related.map(r => (
<a key={r.id} className="rc" href="Ilo Course Page.html" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="rc-img" style={{ background: r.color }} />
<div className="rc-body">
<div style={{ fontSize: 11.5, color: 'var(--ink-3)' }}>{r.cat}</div>
<div style={{ fontSize: 14, fontWeight: 600, marginTop: 4 }}>{r.title}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{r.instructor}</div>
<div style={{ marginTop: 10, display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span className="num" style={{ fontSize: 14, fontWeight: 700 }}>{r.price}</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>تومان</span>
</div>
</div>
</a>
))}
</div>
</section>
{/* sticky bottom mobile cta */}
<div style={{ height: 24 }} />
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

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