vibe coding
This commit is contained in:
parent
06c42312e3
commit
f746a9ff89
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
21
.dockerignore
Normal 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
53
.env.example
Normal 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
20
.gitignore
vendored
Normal 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
22
Dockerfile
Normal 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
323
README.md
Normal 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
0
apps/__init__.py
Normal file
0
apps/accounts/__init__.py
Normal file
0
apps/accounts/__init__.py
Normal file
69
apps/accounts/admin.py
Normal file
69
apps/accounts/admin.py
Normal 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)
|
||||
0
apps/accounts/api/__init__.py
Normal file
0
apps/accounts/api/__init__.py
Normal file
144
apps/accounts/api/public.py
Normal file
144
apps/accounts/api/public.py
Normal 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)
|
||||
12
apps/accounts/api/public_urls.py
Normal file
12
apps/accounts/api/public_urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
57
apps/accounts/api/serializers.py
Normal file
57
apps/accounts/api/serializers.py
Normal 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)}
|
||||
23
apps/accounts/api/throttling.py
Normal file
23
apps/accounts/api/throttling.py
Normal 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
14
apps/accounts/api/urls.py
Normal 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
234
apps/accounts/api/views.py
Normal 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
7
apps/accounts/apps.py
Normal 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
26
apps/accounts/auth.py
Normal 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
|
||||
80
apps/accounts/migrations/0001_initial.py
Normal file
80
apps/accounts/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
18
apps/accounts/migrations/0003_user_last_active_at.py
Normal file
18
apps/accounts/migrations/0003_user_last_active_at.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
0
apps/accounts/migrations/__init__.py
Normal file
0
apps/accounts/migrations/__init__.py
Normal file
208
apps/accounts/models.py
Normal file
208
apps/accounts/models.py
Normal 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"])
|
||||
0
apps/accounts/services/__init__.py
Normal file
0
apps/accounts/services/__init__.py
Normal file
61
apps/accounts/services/otp.py
Normal file
61
apps/accounts/services/otp.py
Normal 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
|
||||
70
apps/accounts/services/sms.py
Normal file
70
apps/accounts/services/sms.py
Normal 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
237
apps/accounts/tests.py
Normal 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")
|
||||
77
apps/accounts/tests_public.py
Normal file
77
apps/accounts/tests_public.py
Normal 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)
|
||||
27
apps/accounts/validators.py
Normal file
27
apps/accounts/validators.py
Normal 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)."))
|
||||
0
apps/announcements/__init__.py
Normal file
0
apps/announcements/__init__.py
Normal file
13
apps/announcements/admin.py
Normal file
13
apps/announcements/admin.py
Normal 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")
|
||||
0
apps/announcements/api/__init__.py
Normal file
0
apps/announcements/api/__init__.py
Normal file
22
apps/announcements/api/serializers.py
Normal file
22
apps/announcements/api/serializers.py
Normal 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"]
|
||||
14
apps/announcements/api/urls.py
Normal file
14
apps/announcements/api/urls.py
Normal 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"),
|
||||
]
|
||||
84
apps/announcements/api/views.py
Normal file
84
apps/announcements/api/views.py
Normal 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")
|
||||
)
|
||||
7
apps/announcements/apps.py
Normal file
7
apps/announcements/apps.py
Normal 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"
|
||||
37
apps/announcements/migrations/0001_initial.py
Normal file
37
apps/announcements/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/announcements/migrations/__init__.py
Normal file
0
apps/announcements/migrations/__init__.py
Normal file
30
apps/announcements/models.py
Normal file
30
apps/announcements/models.py
Normal 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]
|
||||
8
apps/announcements/translation.py
Normal file
8
apps/announcements/translation.py
Normal 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
0
apps/core/__init__.py
Normal file
7
apps/core/apps.py
Normal file
7
apps/core/apps.py
Normal 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
111
apps/core/search.py
Normal 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
15
apps/core/urls.py
Normal 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
17
apps/core/views.py
Normal 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
0
apps/courses/__init__.py
Normal file
62
apps/courses/admin.py
Normal file
62
apps/courses/admin.py
Normal 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",)
|
||||
0
apps/courses/api/__init__.py
Normal file
0
apps/courses/api/__init__.py
Normal file
14
apps/courses/api/filters.py
Normal file
14
apps/courses/api/filters.py
Normal 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"]
|
||||
44
apps/courses/api/permissions.py
Normal file
44
apps/courses/api/permissions.py
Normal 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
|
||||
70
apps/courses/api/queries.py
Normal file
70
apps/courses/api/queries.py
Normal 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),
|
||||
)
|
||||
434
apps/courses/api/serializers.py
Normal file
434
apps/courses/api/serializers.py
Normal 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
29
apps/courses/api/urls.py
Normal 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
301
apps/courses/api/views.py
Normal 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
7
apps/courses/apps.py
Normal 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"
|
||||
158
apps/courses/migrations/0001_initial.py
Normal file
158
apps/courses/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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)'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
0
apps/courses/migrations/__init__.py
Normal file
0
apps/courses/migrations/__init__.py
Normal file
231
apps/courses/models.py
Normal file
231
apps/courses/models.py
Normal 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
505
apps/courses/tests.py
Normal 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)
|
||||
33
apps/courses/translation.py
Normal file
33
apps/courses/translation.py
Normal 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")
|
||||
0
apps/enrollments/__init__.py
Normal file
0
apps/enrollments/__init__.py
Normal file
29
apps/enrollments/admin.py
Normal file
29
apps/enrollments/admin.py
Normal 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"
|
||||
0
apps/enrollments/api/__init__.py
Normal file
0
apps/enrollments/api/__init__.py
Normal file
324
apps/enrollments/api/analytics.py
Normal file
324
apps/enrollments/api/analytics.py
Normal 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
|
||||
11
apps/enrollments/api/permissions.py
Normal file
11
apps/enrollments/api/permissions.py
Normal 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)
|
||||
172
apps/enrollments/api/serializers.py
Normal file
172
apps/enrollments/api/serializers.py
Normal 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()
|
||||
30
apps/enrollments/api/urls.py
Normal file
30
apps/enrollments/api/urls.py
Normal 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"),
|
||||
]
|
||||
307
apps/enrollments/api/views.py
Normal file
307
apps/enrollments/api/views.py
Normal 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
7
apps/enrollments/apps.py
Normal 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"
|
||||
66
apps/enrollments/migrations/0001_initial.py
Normal file
66
apps/enrollments/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
34
apps/enrollments/migrations/0002_activityevent.py
Normal file
34
apps/enrollments/migrations/0002_activityevent.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/enrollments/migrations/__init__.py
Normal file
0
apps/enrollments/migrations/__init__.py
Normal file
161
apps/enrollments/models.py
Normal file
161
apps/enrollments/models.py
Normal 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}>"
|
||||
0
apps/enrollments/services/__init__.py
Normal file
0
apps/enrollments/services/__init__.py
Normal file
17
apps/enrollments/services/events.py
Normal file
17
apps/enrollments/services/events.py
Normal 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
524
apps/enrollments/tests.py
Normal 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)
|
||||
0
apps/payments/__init__.py
Normal file
0
apps/payments/__init__.py
Normal file
55
apps/payments/admin.py
Normal file
55
apps/payments/admin.py
Normal 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",)
|
||||
0
apps/payments/api/__init__.py
Normal file
0
apps/payments/api/__init__.py
Normal file
72
apps/payments/api/preview.py
Normal file
72
apps/payments/api/preview.py
Normal 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)
|
||||
70
apps/payments/api/serializers.py
Normal file
70
apps/payments/api/serializers.py
Normal 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
27
apps/payments/api/urls.py
Normal 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
215
apps/payments/api/views.py
Normal 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
7
apps/payments/apps.py
Normal 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"
|
||||
44
apps/payments/migrations/0001_initial.py
Normal file
44
apps/payments/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
0
apps/payments/migrations/__init__.py
Normal file
0
apps/payments/migrations/__init__.py
Normal file
196
apps/payments/models.py
Normal file
196
apps/payments/models.py
Normal 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}>"
|
||||
0
apps/payments/services/__init__.py
Normal file
0
apps/payments/services/__init__.py
Normal file
274
apps/payments/services/gateway.py
Normal file
274
apps/payments/services/gateway.py
Normal 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))()
|
||||
221
apps/payments/services/orders.py
Normal file
221
apps/payments/services/orders.py
Normal 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
285
apps/payments/tests.py
Normal 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)
|
||||
392
apps/payments/tests_phase6c.py
Normal file
392
apps/payments/tests_phase6c.py
Normal 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
625
design/Ilo Auth.html
Normal 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
608
design/Ilo Checkout.html
Normal 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
521
design/Ilo Course Page.html
Normal 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
Loading…
Reference in New Issue
Block a user