Go to file
2026-05-02 20:01:30 +03:30
.claude vibe coding 2026-05-02 20:01:30 +03:30
apps vibe coding 2026-05-02 20:01:30 +03:30
design vibe coding 2026-05-02 20:01:30 +03:30
ilo vibe coding 2026-05-02 20:01:30 +03:30
locale vibe coding 2026-05-02 20:01:30 +03:30
.dockerignore vibe coding 2026-05-02 20:01:30 +03:30
.env.example vibe coding 2026-05-02 20:01:30 +03:30
.gitattributes Initial commit 2026-05-02 09:13:41 +03:30
.gitignore vibe coding 2026-05-02 20:01:30 +03:30
docker-compose.yml vibe coding 2026-05-02 20:01:30 +03:30
Dockerfile vibe coding 2026-05-02 20:01:30 +03:30
manage.py vibe coding 2026-05-02 20:01:30 +03:30
README.md vibe coding 2026-05-02 20:01:30 +03:30
requirements.txt vibe coding 2026-05-02 20:01:30 +03:30

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

cp .env.example .env       # already exists locally; do this on a fresh clone
docker compose up --build

Open:

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 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.ConsoleSMSBackenddev 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):

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

Courses (step 3 — done, awaiting review)

Catalog modelCategoryCourseSectionLesson (+ 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):

CoursesGET/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":[…]}

SectionsGET/POST /api/instructor/sections/ · GET/PATCH/DELETE /api/instructor/sections/<id>/. Filter: ?course=<id>.

  • POST /api/instructor/sections/<id>/lessons/reorder/ — body {"ids":[…]}

LessonsGET/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 FAQsGET/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 enlocale/{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.ConsolePaymentBackenddev 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.