324 lines
24 KiB
Markdown
324 lines
24 KiB
Markdown
# 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.
|