| .claude | ||
| apps | ||
| design | ||
| frontend | ||
| ilo | ||
| locale | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| manage.py | ||
| README.md | ||
| requirements.txt | ||
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 for scope and phasing.
Stack
- Django 5.1 + Postgres 16
- DRF +
djangorestframework-simplejwtfor the API django-modeltranslationfor bilingual model fields (en + fa)django-filterfor catalog filtering,drf-spectacularfor 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_patternsURL prefixes - Fully containerized — no host Python needed; only Docker
Run locally
You need: Docker Desktop (or Docker Engine + compose v2).
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
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:
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
429withretry_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 outputapps.accounts.services.sms.SMSIRBackend— sms.ir template send via/v1/send/verify. RequiresSMSIR_API_KEY,SMSIR_LINE_NUMBER,SMSIR_TEMPLATE_ID. Param name isCodeby default; override withSMSIR_TEMPLATE_PARAM_NAMEif 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):
# 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. 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/lessontitle, lessonbody, highlighttext, FAQquestion/answer. The DB has*_faand*_encolumns; the bare attribute returns the active language (withfa → enfallback). - Lesson kinds:
video(requiresvideo_url),text(requiresbody),pdf(requirespdf_file),task(requiresbody),quiz(kind reserved for future quiz model — no quiz logic yet). is_previewflag on lessons → public detail exposesbody/video_url/pdf_fileonly whenis_preview=true; locked lessons return those asnull.
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. Onlystatus=publishedcourses 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/— requiresTeacherProfile.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 includediscount_active(computed) plus the rawdiscount_starts_at/discount_ends_atfields so the frontend can render the countdown timerPromoCodemodel:code,discount_percentordiscount_amount_toman, optionalcoursescope,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_tomanto 0, the gateway is skipped — the order is created withgateway=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 getsREFERRAL_BUYER_DISCOUNT_PERCENToff (default 10%); on successful payment aReferralrow is created withreferrer_credit_toman = REFERRAL_REFERRER_CREDIT_PERCENTof the post-discount amount - Self-referral and
promo_code + referral_codetogether are rejected at validate time - Referrer credit is tracking-only — actual payout is an ops process (or future Wallet step)
Featured instructors
TeacherProfile.is_featuredflag →GET /api/instructors/?is_featured=truereturns 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 getlessonsmatching 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?}→ returnsbase_amount_toman,discount_amount_toman,final_amount_toman,promo_code_applied,referrer_code_appliedwithout 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 viaX-SANDBOX: 1header. Same Toman→Rial × 10 convention. Switch viaPAYMENT_BACKENDenv var; configureIDPAY_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=10REFERRAL_REFERRER_CREDIT_PERCENT=10IDPAY_API_KEY=andIDPAY_SANDBOX=true
KPIs + activity + engagement (step 6B — done, awaiting review)
New plumbing
User.last_active_at— bumped on every authenticated API request via a customTrackingJWTAuthentication(debounced to one DB write per user per 5 minutes)ActivityEventmodel (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 InstructorAnnouncementViewSetEnrollment.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 hasvalue,unit,delta_percentvs prior period,good, and a 12-pointsparkfor the sparkline. Satisfaction returns 0 until Reviews ship.GET /api/instructor/revenue/monthly/— last 12 calendar months ofrevenue_toman+orders_countGET /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(defaultFalse) — opt-in per lesson; videos are non-downloadable unless the instructor flips this onCourse.is_coming_soon(defaultFalse) — explicit instructor flag for the catalog badgeTeacherProfile.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_secondsis_new(created withinCOURSE_NEW_WINDOW_DAYS, default 30 days)is_bestseller(≥COURSE_BESTSELLER_THRESHOLDpaid orders in the lastCOURSE_BESTSELLER_WINDOW_DAYS, defaults10&90)is_coming_soon(the model flag)instructorblock 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 returnscourse_countper category (counted across published courses only)GET /api/instructors/— featured-instructors strip; sorted bystudents_countthencourses_countGET /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=30COURSE_BESTSELLER_THRESHOLD=10COURSE_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 pendingOrder, returns{order_id, redirect_url, amount_toman, gateway}. Redirect the user's browser toredirect_url. Returns400if the course is free or the student is already enrolled.GET /api/me/orders/— order history (any status), most recent firstPOST /api/courses/<slug>/enroll/— still works for free courses; returns402for 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). Weselect_for_updatethe Order, verify with the gateway, markpaid(orfailed), 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 withStatus=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 byZARINPAL_SANDBOX). Hand-rolled withrequests, 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:
- Register a ZarinPal merchant account, get
MerchantID - 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 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 withprogress_percent,completed_lessons,total_lessons,last_lesson_id/title,next_lesson_id/title(matches the design'sMY_COURSESshape)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 itGET /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/— bilingualbodyfield 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_tomanon Course;Ordermodel 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 (/enrollreturns 402 for paid); idempotent callback underselect_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_downloadableopt-in toggle,MyOrderDetailViewfor checkout success page; 10 tests added - Step 6B — KPIs + activity + engagement ✅ :
User.last_active_at(auto-tracked via custom JWT auth),ActivityEventmodel +record_eventhelper hooked into all engagement-touching flows,Enrollment.engagement_status()bucket, instructor enrollments enriched withstudent_last_active_at+engagement_statusfilter, new/api/instructor/kpis/,/revenue/monthly/,/activity/endpoints (sparkline + 12-month chart + paginated feed); 18 tests added - Step 6C — promos + referrals + search + IDPay ✅ :
PromoCode+Referralmodels with snapshotted discount on Order (base_amount/discount/amount),User.referral_codeauto-generated, zero-amount auto-complete bypasses gateway,discount_starts_at/ends_atfor catalog countdown,TeacherProfile.is_featured+ filter, global/api/search/, checkout/preview/endpoint, IDPay payment backend, fa+en.pofiles 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_KEYis 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:
STORAGESis wired butdjango-storages[s3]is NOT yet in requirements. Add it (and theAWS_*env vars) when we deploy to a host with MinIO/ArvanCloud bucket.
Notes & decisions
requirements.txtinstead ofpyproject.toml— simpler Docker layer caching.TIME_ZONE = "Asia/Tehran".- Custom User uses
phone_numberasUSERNAME_FIELD;full_nameis the onlyREQUIRED_FIELD. Email is optional. - OTP code stored in a separate
OTPCodemodel (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.