# 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 # 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":"","full_name":"Ali","role":"teacher"}' # 4. Use the access token curl http://localhost:8000/api/auth/me/ -H 'Authorization: Bearer ' ``` **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//` — 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//`. Filters: `?status=draft|published`, `?category=`, `?search=...`. Ordering: `created_at`, `updated_at`, `published_at`. - `POST /api/instructor/courses//publish/` — **requires `TeacherProfile.is_approved=True`** (admins approve via the admin "Approve selected teacher profiles" action) - `POST /api/instructor/courses//unpublish/` - `POST /api/instructor/courses//sections/reorder/` — body `{"ids":[…]}` *Sections* — `GET/POST /api/instructor/sections/` · `GET/PATCH/DELETE /api/instructor/sections//`. Filter: `?course=`. - `POST /api/instructor/sections//lessons/reorder/` — body `{"ids":[…]}` *Lessons* — `GET/POST /api/instructor/lessons/` · `GET/PATCH/DELETE /api/instructor/lessons//`. Filters: `?section=`, `?section__course=`, `?kind=`, `?status=`, `?is_preview=`. *Course highlights* (bullets on the public course page) — `GET/POST/PATCH/DELETE /api/instructor/highlights/`. Filter: `?course=`. *Course FAQs* — `GET/POST/PATCH/DELETE /api/instructor/faqs/`. Filter: `?course=`. **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": ""}` 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//checkout/` body now accepts optional `{promo_code, referral_code}` (mutually exclusive) - New: `POST /api/courses//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//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=`, `?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//` — public instructor profile + their published courses (catalog shape) - `GET /api/me/orders//` — 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//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//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//?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=`. **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//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//` — full course tree with **all published lesson content unlocked** (no preview gating once enrolled). Includes per-lesson completion state. - `POST /api/me/lessons//complete/` — mark a lesson complete (auto-marks the enrollment as completed when all published lessons are done) - `POST /api/me/lessons//uncomplete/` — reverse it - `GET /api/me/courses//announcements/` — instructor's posts on this course **Instructor flow** (Bearer JWT, `role=teacher`, owner-only): - `GET /api/instructor/courses//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=` ## 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/`, `/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.