Ilo/README.md
2026-05-02 20:01:30 +03:30

324 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.